diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index c46bb59b44..1adde54c7b 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -376,6 +376,9 @@ "simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml": "sha256:531c446f0e95054f8ced17be9a180f8b0a823f7e9d5ce466c94c2f9cff90a111", "simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md": "sha256:a35a6372aabdf7cbc211f1bd5820d85d3467c9ed50f84e05caa3339382379ce7", "simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md": "sha256:a6ed9530289a783c3d7a1ddb038fba6b7daf7feb773298a57e811791e354d438", + "simplification/2026-08-03-explicit-config-dsh-entrypoint.i18n.yaml": "sha256:5466161f3fb8f2e8117fe8ff242675cc9fe9ef264d1e29b9bc586891c73c051a", + "simplification/2026-08-03-explicit-config-dsh-entrypoint.md": "sha256:f23accae7d05c2e75cb73ec69b492307f1ce7526ecfa9f6b12a621e02fd1a0c3", + "simplification/2026-08-03-explicit-config-dsh-entrypoint.zh.md": "sha256:a32d2c6ecf748a16a2c35b59cd2da2fda75769e3ab24be6a2e026d8655466db4", "testing/2026-06-20-remove-redundant-snapshot-log-expected-output.i18n.yaml": "sha256:4177012c0821a8c22499852ecdf096af56d7263cb91c5d9d1bcd552cc26a3e00", "testing/2026-06-20-remove-redundant-snapshot-log-expected-output.md": "sha256:45234e7cc04b6010c6141f8d5924c04547300098f96262d423c50108e7c7011a", "testing/2026-06-20-remove-redundant-snapshot-log-expected-output.zh.md": "sha256:15e5a4ad3dee0bb711480cabe45cd97ec37bbdba19c2c2b47d1e9c203b07a48b", diff --git a/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.i18n.yaml b/.agents/notes/archived/simplification/2026-08-03-explicit-config-dsh-entrypoint.i18n.yaml similarity index 68% rename from .agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.i18n.yaml rename to .agents/notes/archived/simplification/2026-08-03-explicit-config-dsh-entrypoint.i18n.yaml index 8ff1af7e8e..b699495f93 100644 --- a/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.i18n.yaml +++ b/.agents/notes/archived/simplification/2026-08-03-explicit-config-dsh-entrypoint.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.md -2026-08-03-explicit-config-dsh-entrypoint.md: e0d1e954d9cef472ea59345a3d2ef5a67bd03ae8 -2026-08-03-explicit-config-dsh-entrypoint.zh.md: b5b464e3b45a6f3909bbf087f7005ad3f819424a +2026-08-03-explicit-config-dsh-entrypoint.md: 4474e786b3a99ff0ee81ac54fcb6eb5aaff5ee04 +2026-08-03-explicit-config-dsh-entrypoint.zh.md: 11b7b27c7560aaa43a9fdcfb12a9a17f9433e619 diff --git a/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.md b/.agents/notes/archived/simplification/2026-08-03-explicit-config-dsh-entrypoint.md similarity index 99% rename from .agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.md rename to .agents/notes/archived/simplification/2026-08-03-explicit-config-dsh-entrypoint.md index e0d1e954d9..4474e786b3 100644 --- a/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.md +++ b/.agents/notes/archived/simplification/2026-08-03-explicit-config-dsh-entrypoint.md @@ -1,6 +1,7 @@ # Agent Note: Explicit-config dsh entrypoint Status: implemented +Archived: 2026-08-08 English | [中文](2026-08-03-explicit-config-dsh-entrypoint.zh.md) diff --git a/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.zh.md b/.agents/notes/archived/simplification/2026-08-03-explicit-config-dsh-entrypoint.zh.md similarity index 98% rename from .agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.zh.md rename to .agents/notes/archived/simplification/2026-08-03-explicit-config-dsh-entrypoint.zh.md index b5b464e3b4..11b7b27c75 100644 --- a/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.zh.md +++ b/.agents/notes/archived/simplification/2026-08-03-explicit-config-dsh-entrypoint.zh.md @@ -1,6 +1,7 @@ -# Agent Note:显式配置的 dsh 入口 +# Agent Note: 显式配置的 dsh 入口 Status: implemented +Archived: 2026-08-08 [English](2026-08-03-explicit-config-dsh-entrypoint.md) | 中文 diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml index 7216c38d38..d1ae953836 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md -2026-06-18-shared-persistence-write-coordinator.md: 66b73b60ceec9497f1f1226747b8cebd831eb426 -2026-06-18-shared-persistence-write-coordinator.zh.md: 424ce6ec7384e8af7b979a29f58c31379a1d1850 +2026-06-18-shared-persistence-write-coordinator.md: 12131ebf8380fb8ba816618f0cbaf72cb004623a +2026-06-18-shared-persistence-write-coordinator.zh.md: a11926b602651d8a7dc641f61371e882685b508b diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md index 66b73b60ce..12131ebf83 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md +++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md @@ -14,7 +14,7 @@ Extract a backend-agnostic `PersistenceCoordinator` into `dsh-session-persistenc Composition, not inheritance. The coordinator is a concrete class the backend holds, not a base class the backend extends. The Agent Note's risk — "a coordinator must not make unusual backends fight an inheritance hierarchy" — is avoided: a backend exposes only the hooks and cannot reach the coordinator's private orchestration state. A third-party backend MAY still implement the abstract service directly without the coordinator, including immutable logical inspection and the default preparation fallback through `load`. -The coordinator holds one controller for each exact live `Session`; the controller combines initialization, pending events, and the shared flush promise. Each `session/event` starts an eager drain, and `session/flush` observes quiescence rather than initiating the ordinary write path. The [flush-controller simplification](../simplification/2026-07-23-collapse-persistence-flush-state.md) owns this lifecycle. +The coordinator holds one lifecycle entry for each exact live `Session`: initialization plus a package-private write controller that owns pending events, a fixed batching deadline, the active write, failure retention, and the shared flush barrier. Each `session/event` enters that bounded write path, and `session/flush` bypasses the wait to observe quiescence. The [flush-controller simplification](../simplification/2026-07-23-collapse-persistence-flush-state.md) owns controller consolidation; the [bounded batching decision](2026-08-08-bounded-session-persistence-write-batching.md) owns scheduling cadence. The coordinator retires a session from `session/disposed`: it waits for the controller's initialization and current flush, serializes a final drain, and removes the controller and owned per-id state only after success. A failure leaves the controller discoverable for backend teardown to retry. Settled per-id chain tails remove themselves only when they are still current, so a completion cannot erase a newer operation for the same id. Backend teardown unregisters write-path listeners, flushes every remaining controller, awaits per-id operations, and then closes the backend. @@ -35,7 +35,7 @@ The single design choice that keeps the seam clean: the crash-repair "where is t ## Testing -The shared `runPersistenceContract` (public-API contract) runs for every backend and proves that `inspect` balances an interrupted logical view without changing storage or revisions before `prepare` or `load` commits recovery. `runCoordinatorContract` (`tests/coordinator-contract.ts`) covers adoption, HMR, collision, session and backend disposal drains, and crash-tail repair through an in-memory reference, JSONL, and SQLite. `persistence.spec.ts` and `preparations.spec.ts` cover preparation reuse and reservation, bounded prepared-state eviction, eager follow-up batches, live-controller cleanup, same-id chain-tail races, failed-drain retry, and close ordering. The per-backend specs retain storage mechanics only. A through-coordinator torn-tail repair test per real backend keeps the opaque-marker branch covered because the contract crash case produces synthetic closers without a torn marker. +The shared `runPersistenceContract` (public-API contract) runs for every backend and proves that `inspect` balances an interrupted logical view without changing storage or revisions before `prepare` or `load` commits recovery. `runCoordinatorContract` (`tests/coordinator-contract.ts`) covers adoption, HMR, collision, session and backend disposal drains, and crash-tail repair through an in-memory reference, JSONL, and SQLite. `persistence.spec.ts`, `preparations.spec.ts`, and `write-behind.spec.ts` cover preparation reuse and reservation, bounded prepared-state eviction, fixed-window follow-up batches, live-controller cleanup, same-id chain-tail races, failed-batch retry, and close ordering. The per-backend specs retain storage mechanics only. A through-coordinator torn-tail repair test per real backend keeps the opaque-marker branch covered because the contract crash case produces synthetic closers without a torn marker. ## Alternatives considered @@ -44,4 +44,4 @@ The shared `runPersistenceContract` (public-API contract) runs for every backend ## Consequences -The coordinator adds one indirection, an opaque torn marker, detached session-retirement tasks, and bounded prepared Session state, but centralizes correctness-heavy orchestration previously duplicated by every backend. Session disposal remains an observe-only event, so the session owner does not await persistence retirement; the coordinator contains failures, preserves pending events in the live controller, and makes backend teardown the quiescence boundary. Its hook surface stays narrow: identity, adoption, collision checks, preparation, and immutable inspection reuse `loadStored`; materialization stays atomic inside `appendBatch`; and listing bypasses the coordinator. Read models use `inspect` rather than `load`, so observing a persisted open turn does not commit interruption closers; the [Session preparation decision](2026-08-05-session-preparation.md) owns reuse, reservation, and publication. New backends implement storage primitives rather than copy the eager write lifecycle. +The coordinator adds one indirection, an opaque torn marker, detached session-retirement tasks, and bounded prepared Session state, but centralizes correctness-heavy orchestration previously duplicated by every backend. Session disposal remains an observe-only event, so the session owner does not await persistence retirement; the coordinator contains failures, preserves pending events in the live controller, and makes backend teardown the quiescence boundary. Its hook surface stays narrow: identity, adoption, collision checks, preparation, and immutable inspection reuse `loadStored`; materialization stays atomic inside `appendBatch`; and listing bypasses the coordinator. Read models use `inspect` rather than `load`, so observing a persisted open turn does not commit interruption closers; the [Session preparation decision](2026-08-05-session-preparation.md) owns reuse, reservation, and publication. New backends implement storage primitives rather than copy the bounded write lifecycle. diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md index 424ce6ec73..a11926b602 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md @@ -14,7 +14,7 @@ Status: implemented 组合,而非继承。协调器是后端持有的具体类,不是后端继承的基类。本 Agent Note 的风险——「协调器不得让非常规后端与继承层级作斗争」——由此规避:后端只暴露钩子,无法触及协调器的私有编排状态。第三方后端仍然可以完全不使用协调器、直接实现抽象服务,包括不可变逻辑检查,以及通过 `load` 实现的默认准备回退。 -协调器为每个存活的 `Session` 实例持有一个控制器;该控制器统合初始化、待处理事件与共享 flush promise。每个 `session/event` 都会立即启动排空,而 `session/flush` 只观察完全停稳,不会发起常规写入路径。[flush 控制器简化](../simplification/2026-07-23-collapse-persistence-flush-state.md)定义该生命周期。 +协调器为每个存活的 `Session` 实例持有一个生命周期条目:初始化,加上一个包私有写入控制器,后者负责待处理事件、固定批处理截止时间、活跃写入、失败保留和共享 flush 屏障。每个 `session/event` 都进入这条有界写入路径,`session/flush` 则绕过等待以观察完全停稳。控制器归并由 [flush 控制器简化](../simplification/2026-07-23-collapse-persistence-flush-state.md)定义;调度节奏由[有界批处理决策](2026-08-08-bounded-session-persistence-write-batching.md)定义。 协调器通过 `session/disposed` 退役会话:它等待控制器完成初始化和当前 flush,串行执行最后一次排空,且仅在成功后才移除控制器与其拥有的每 id 状态。失败时保持控制器可被找到,以供后端 teardown(拆除)重试。每个 id 的已结算链尾仅在其仍是当前链尾时才移除自身,因此旧操作完成后不会抹除同一 id 的新操作。后端 teardown 会注销写入路径监听器、flush 每个剩余的控制器、等待所有按 id 串行化的操作,最后关闭后端。 @@ -35,7 +35,7 @@ Status: implemented ## 测试 -共享的 `runPersistenceContract`(公开 API 契约)为每个后端运行,并证明 `inspect` 会配平被中断的逻辑视图但不改变存储或修订版本,随后由 `prepare` 或 `load` 提交恢复。`runCoordinatorContract`(`tests/coordinator-contract.ts`)通过内存参考实现、JSONL 与 SQLite 覆盖接管、HMR、碰撞、会话与后端 dispose 排空和崩溃尾部修复。`persistence.spec.ts` 与 `preparations.spec.ts` 覆盖准备复用与预留、有界准备状态淘汰、立即执行的后续批次、存活控制器清理、同 id 链尾竞态、排空失败重试与关闭顺序。各后端自身的测试规格只保留存储机制。每个真实后端都有一个经由协调器的崩溃尾部修复测试,以覆盖不透明 marker 分支,因为契约中的崩溃用例会产生合成 closers,却不会产生 torn marker。 +共享的 `runPersistenceContract`(公开 API 契约)为每个后端运行,并证明 `inspect` 会配平被中断的逻辑视图但不改变存储或修订版本,随后由 `prepare` 或 `load` 提交恢复。`runCoordinatorContract`(`tests/coordinator-contract.ts`)通过内存参考实现、JSONL 与 SQLite 覆盖接管、HMR、碰撞、会话与后端 dispose 排空和崩溃尾部修复。`persistence.spec.ts`、`preparations.spec.ts` 与 `write-behind.spec.ts` 覆盖准备复用与预留、有界准备状态淘汰、固定窗口后续批次、存活控制器清理、同 id 链尾竞态、失败批次重试与关闭顺序。各后端自身的测试规格只保留存储机制。每个真实后端都有一个经由协调器的崩溃尾部修复测试,以覆盖不透明 marker 分支,因为契约中的崩溃用例会产生合成 closers,却不会产生 torn marker。 ## 曾考虑的替代方案 @@ -44,4 +44,4 @@ Status: implemented ## 后果 -协调器增加了一层间接、一个不透明的 torn marker、脱离会话生命周期的退役任务,以及有界的已准备 Session 状态,但将此前每个后端重复的、对正确性要求很高的编排逻辑集中到一处。会话 dispose 仍是仅观察事件,因此会话所有者不会等待持久化退役;协调器会收容失败、在存活控制器中保留待处理事件,并以后端 teardown 为完全停稳边界。其钩子面保持窄小:标识校验、接管、碰撞检查、准备与不可变检查共用 `loadStored`;物化保持在 `appendBatch` 内原子完成;列举绕过协调器。读模型使用 `inspect` 而非 `load`,因此观察已持久化但仍开放的轮次时不会提交中断 closers;复用、预留与发布由 [Session 准备阶段决策](2026-08-05-session-preparation.md)定义。新后端只需实现存储原语,而无需复制立即写入生命周期。 +协调器增加了一层间接、一个不透明的 torn marker、脱离会话生命周期的退役任务,以及有界的已准备 Session 状态,但将此前每个后端重复的、对正确性要求很高的编排逻辑集中到一处。会话 dispose 仍是仅观察事件,因此会话所有者不会等待持久化退役;协调器会收容失败、在存活控制器中保留待处理事件,并以后端 teardown 为完全停稳边界。其钩子面保持窄小:标识校验、接管、碰撞检查、准备与不可变检查共用 `loadStored`;物化保持在 `appendBatch` 内原子完成;列举绕过协调器。读模型使用 `inspect` 而非 `load`,因此观察已持久化但仍开放的轮次时不会提交中断 closers;复用、预留与发布由 [Session 准备阶段决策](2026-08-05-session-preparation.md)定义。新后端只需实现存储原语,而无需复制有界写入生命周期。 diff --git a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml index 56c9e53319..b6788a47ef 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md -2026-06-21-mandatory-app-attribution-headers.md: a8ffe91c431cdc7907626bbc3eaf8096035777de -2026-06-21-mandatory-app-attribution-headers.zh.md: ac4affce583d5d81253f320ff022f670d4d66cc8 +2026-06-21-mandatory-app-attribution-headers.md: ad9d65805c8f0c96bd811b5036310d019760627e +2026-06-21-mandatory-app-attribution-headers.zh.md: 3021c7fcca00f2e929d997625c303f9a27dbf673 diff --git a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md index a8ffe91c43..ad9d65805c 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md +++ b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md @@ -32,7 +32,7 @@ The provider-neutral identity is owned by `dsh-llm` (`packages/llm/llm/src/attri - product token for `User-Agent`: `deepseek-harness` (continuity with the pre-Agent Note wire value and the repo/org identity) - version: read from the owning package's manifest via `createRequire`, never a hand-copied constant -- app URL: `https://github.com/deepseek-ai/deepseek-harness-sdk` - the planned public home; a `FIXME` in `attribution.ts` blocks release until that repository actually exists +- app URL: `https://github.com/deepseek-ai/deepseek-harness-sdk` - the planned public home, which must exist before release The default is mandatory and non-empty. White-label deployments pass their own `AppIdentity` to `attributionHeaders(identity)` - the override seam is the function parameter, with no deployment config plumbing until a consumer needs it - and omission falls back to the harness default rather than suppressing attribution. There is no per-request API for the model, user prompt, session id, cwd, user email, API key owner, or local machine identity to influence these fields. @@ -77,7 +77,7 @@ The landed contract: **Providers see that traffic comes from the harness.** That is the point, but it means deployments that previously blended into generic SDK traffic become identifiable. Mitigation: send only static public product data and let forks/white-label deployments pass their own `AppIdentity`. -**The app URL points at a repository that does not exist yet.** `deepseek-ai/deepseek-harness-sdk` is the planned public home; until it is created the URL is a dangling promise. The `FIXME` marker on the constant blocks a release from shipping with it unresolved (see `docs/development.md` marker semantics). +**The app URL points at a repository that does not exist yet.** `deepseek-ai/deepseek-harness-sdk` is the planned public home; until it is created the URL is a dangling promise that blocks release. **Header support differs by client library.** The hand-rolled adapter sets headers directly; the pi-ai-backed adapter depends on pi-ai continuing to honor `StreamOptions.headers` (merged last over provider defaults). The wire-level mock-server tests are the guard: if a pi-ai upgrade stops delivering the header, the suite goes red. This is useful pressure on the abstraction: a provider adapter that cannot set mandatory headers cannot fully implement the harness LLM contract. diff --git a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md index ac4affce58..3021c7fcca 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md @@ -32,7 +32,7 @@ LLM(大语言模型)提供方请求应当标识发出请求的产品。这 - `User-Agent` 的产品 token:`deepseek-harness`(与 Agent Note 之前的线路值及仓库/组织身份保持连续性) - 版本:通过 `createRequire` 从所属包的 manifest(元数据清单)读取,绝不手动复制常量 -- 应用 URL:`https://github.com/deepseek-ai/deepseek-harness-sdk`——计划中的公开主页;`attribution.ts` 中的 `FIXME` 标记在该仓库实际存在之前阻塞发布 +- 应用 URL:`https://github.com/deepseek-ai/deepseek-harness-sdk`——计划中的公开主页,且必须在发布前实际存在 默认值是强制的且非空。白标部署通过向 `attributionHeaders(identity)` 传入自己的 `AppIdentity` 来覆盖——覆盖 seam 就是函数参数,在有消费方需要之前不做部署配置管道——省略时回退到 harness 默认值而非抑制归属。没有逐请求 API 允许模型、用户提示词、会话 id、cwd、用户邮箱、API key 所有者或本地机器身份影响这些字段。 @@ -77,7 +77,7 @@ LLM(大语言模型)提供方请求应当标识发出请求的产品。这 **提供方看到流量来自 harness。** 这正是目的,但意味着此前混在通用 SDK 流量中的部署变得可识别。缓解措施:仅发送静态公开产品数据,并允许 fork/白标部署传入自己的 `AppIdentity`。 -**应用 URL 指向一个尚不存在的仓库。** `deepseek-ai/deepseek-harness-sdk` 是计划中的公开主页;在它创建之前,该 URL 是一个悬空承诺。常量上的 `FIXME` 标记阻塞发布,不允许带着未解决的问题出门(见 `docs/development.md` 标记语义)。 +**应用 URL 指向一个尚不存在的仓库。** `deepseek-ai/deepseek-harness-sdk` 是计划中的公开主页;在它创建之前,该 URL 是一个阻塞发布的悬空承诺。 **不同客户端库的头部支持有差异。** 手写适配器直接设置头部;基于 pi-ai 的适配器依赖 pi-ai 继续尊重 `StreamOptions.headers`(最后合并覆盖提供方默认值)。线路级 mock 服务器测试是守卫:如果 pi-ai 升级后不再投递该头部,套件会变红。这对抽象施加了有益的压力:一个无法设置强制头部的提供方适配器不能完整实现 harness 的 LLM 契约。 diff --git a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml index 48d3522a70..072823d0dc 100644 --- a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md -2026-07-02-tool-render-intent-union.md: d82141f519bff66df000f1316093aacd38b8e42b -2026-07-02-tool-render-intent-union.zh.md: e145908e019e71765a475b0ca9d22414e9b42d23 +2026-07-02-tool-render-intent-union.md: 67607b2848305439513503d7e03ad5e2a2e4020a +2026-07-02-tool-render-intent-union.zh.md: 9cbab75ce87f6d313ca4b6ac8dda043733361f0d diff --git a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md index d82141f519..67607b2848 100644 --- a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md +++ b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md @@ -14,7 +14,7 @@ A tool declares how its calls render in a UI (an editor's tool-call card) throug - Which combinations are *valid* is unwritten: a `terminal` call that also sets `content` means "description above the card"; a generic call that sets `terminal` is meaningless but representable. The type permits nonsense. - There is no way to express the one file-tool affordance an editor most wants — a **diff card** (`{path, oldText, newText}`, which Zed renders as an inline diff / new-file preview). `ToolCallPresentation.content` is the *LLM* `ContentBlock[]` vocabulary (text/image), so a tool literally cannot ask for a diff. -The existing `FIXME(tool-presentation)` in `packages/core/tools/src/index.ts` named the fix: "redesign the type so a tool declares its render INTENT once (e.g. a tagged union over card kinds) rather than a bag of optional fields the bridge stitches together." An earlier rejected collapse-tool-owned-presentation proposal deferred it explicitly: rich rendering "should return later as a tagged render-intent union after there are at least two real tools and two real consumers to validate the vocabulary." That bar is met by multiple producer families plus the TUI and host/client-runtime (Web) consumers. +An earlier rejected collapse-tool-owned-presentation proposal deferred rich rendering until it could "return later as a tagged render-intent union after there are at least two real tools and two real consumers to validate the vocabulary." That bar is met by multiple producer families plus the TUI and host/client-runtime (Web) consumers. ## Decision diff --git a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md index e145908e01..9cbab75ce8 100644 --- a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md @@ -14,7 +14,7 @@ Status: implemented - 哪些组合是*合法的*没有文档说明:一个设置了 `content` 的 `terminal` 调用意味着「卡片上方的描述」;一个设置了 `terminal` 的 generic 调用毫无意义但类型上可表达。类型允许无意义的状态存在。 - 无法表达编辑器最需要的文件工具能力:**diff 卡片**(`{path, oldText, newText}`,Zed 将其渲染为内联 diff / 新文件预览)。`ToolCallPresentation.content` 使用的是 *LLM(大语言模型)* 的 `ContentBlock[]` 词汇(text/image),工具根本无法请求 diff 展示。 -`packages/core/tools/src/index.ts` 中已有的 `FIXME(tool-presentation)` 指出了修复方向:「重新设计类型,让工具一次性声明其渲染意图(例如按卡片种类的带标签联合类型),而非一堆由 bridge 拼接的可选字段。」一个早先被否决的折叠工具自有呈现提案明确推迟了此事:富渲染「应当在至少有两个真实工具和两个真实消费方验证词汇之后,以带标签 render-intent 联合类型的形式回归。」该条件已由多个生产者族,加上 TUI 与宿主/客户端运行时(Web)这些消费方满足。 +一个早先被否决的折叠工具自有呈现提案把富渲染推迟到它能够「在至少有两个真实工具和两个真实消费方验证词汇之后,以带标签 render-intent 联合类型的形式回归」之时。该条件已由多个生产者族,加上 TUI 与宿主/客户端运行时(Web)这些消费方满足。 ## 决策 diff --git a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml index 8a2190b500..26354042d7 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md -2026-07-05-prompt-variables-and-tool-guidance-ownership.md: 94f5fa409e7b539b48750d12576c7a342a30c9ba -2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md: 341b3a89f423c9cc7d2fc56f1ea25a1985680d0d +2026-07-05-prompt-variables-and-tool-guidance-ownership.md: a3b5021daf323971308760bde4f97651db8edbda +2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md: 490d41302ea4f4e14e11e301acbf47170f95cace diff --git a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md index 94f5fa409e..a3b5021daf 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md +++ b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md @@ -10,7 +10,7 @@ The assembled system prompt had four defects, all of one family: facts the harne **The model could not know its own name.** `AgentOptions.model` drives every request, but no prompt text carried it — and nothing COULD carry it: sections in `dsh-system-prompt` were context-global while the model name is per-agent, and `assemble()` took no per-agent input at all. -**Tool guidance was hand-written prose in leaf YAML.** The bash/subagent/todo_write usage guidance lived in the coding-agent and ACP persona strings — two drifting copies (the ACP one was already abridged) — while `dsh-tool-fs` and `dsh-tool-web` owned their guidance as `ctx.systemPrompt.section()` contributions. Loading or dropping a tool plugin meant editing every deployment's persona by hand; both YAMLs carried a `FIXME(config-comments)` apologizing for a symptom of the split, and the old terminal welcome banner hand-enumerated the tool set too. +**Tool guidance was hand-written prose in leaf YAML.** The bash/subagent/todo_write usage guidance lived in the coding-agent and ACP persona strings — two drifting copies (the ACP one was already abridged) — while `dsh-tool-fs` and `dsh-tool-web` owned their guidance as `ctx.systemPrompt.section()` contributions. Loading or dropping a tool plugin meant editing every deployment's persona by hand, and the old terminal welcome banner hand-enumerated the tool set too. **The persona rendered after tool guidance.** The loop string-joined `agent.options.systemPrompt` AFTER the assembled sections, so the model read "Use the read tool…" before "You are a coding agent" — backwards relative to the identity-first convention (Claude Code, Codex) and a second composition path besides the section pipeline. diff --git a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md index 341b3a89f4..490d41302e 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md @@ -10,7 +10,7 @@ Status: implemented **模型无法知道自己的名字。** `AgentOptions.model` 驱动每个请求,但没有任何提示词文本携带它——也不可能携带:`dsh-system-prompt` 中的 section 是上下文全局的,而模型名称是 per-agent 的,`assemble()` 根本不接受任何 per-agent 输入。 -**工具指导是 leaf YAML 中的手写行文。** bash/subagent/todo_write 的使用指导存放在 coding-agent 和 ACP persona 字符串里——两份漂移的副本(ACP 那份已经被删减)——而 `dsh-tool-fs` 和 `dsh-tool-web` 则通过 `ctx.systemPrompt.section()` 贡献各自的指导。加载或卸载一个工具插件意味着手动编辑每个部署的 persona;两份 YAML 都带着一条 `FIXME(config-comments)` 为这种分裂的症状道歉,旧终端欢迎横幅也手动枚举了工具集。 +**工具指导是 leaf YAML 中的手写行文。** bash/subagent/todo_write 的使用指导存放在 coding-agent 和 ACP persona 字符串里——两份漂移的副本(ACP 那份已经被删减)——而 `dsh-tool-fs` 和 `dsh-tool-web` 则通过 `ctx.systemPrompt.section()` 贡献各自的指导。加载或卸载一个工具插件意味着手动编辑每个部署的 persona,旧终端欢迎横幅也手动枚举了工具集。 **Persona 渲染在工具指导之后。** agent loop(智能体循环)将 `agent.options.systemPrompt` 字符串拼接在已组装的 section 之后,于是模型先读到「Use the read tool…」再读到「You are a coding agent」——与 identity-first 约定(Claude Code、Codex)相反,且是 section 流水线之外的第二条组合路径。 diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml index d326b413bb..2926746ee1 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md -2026-07-05-reconstructable-requests.md: 2f559a3052b9fb84f788975a64799e4f020b0d3e -2026-07-05-reconstructable-requests.zh.md: 26abdc024a166856e51ebf09f086c7868fc8236d +2026-07-05-reconstructable-requests.md: ebca9b99cad791159302da9c2bbce9f4df147aab +2026-07-05-reconstructable-requests.zh.md: 91ef3fd04502f2c2f092da60a8bd909cf7fa25df diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md index 2f559a3052..ebca9b99ca 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md +++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md @@ -53,4 +53,3 @@ Like MiniCode, the conversation advances append-only and resets only when model- - Tool-result trimming (planned) needs no new mechanism: a logged single-entry surface replace (`start === end`) carrying a trimmed `tool/result` under the same `callId` — compaction-family, replay-correct, cache-bust batched by the same pressure logic. - Session logs grow one `request/header` snapshot per loop instance plus snapshots on real changes. This is larger than a delta codec but small beside chunk-heavy logs and retains one replay representation. `SESSION_FORMAT_VERSION` stays `0`; legacy delta events are rejected rather than migrated. - Snapshot expected outputs changed once (every transcript gains its header events); the fs-writing fixtures are stored in the normalized authored form with cwd-relative tool arguments, because replay only round-trips cwd-independent argument paths. -- FIXME(call-config-shape): revisit `LlmCallConfig`'s exact field set — which fields are genuinely epoch-level for cache purposes (`model` certainly; the sampling scalars sit there out of caution), and where provider-specific extras (reasoning options, extra body params) belong when an adapter needs them. diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md index 26abdc024a..91ef3fd045 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md @@ -53,4 +53,3 @@ Status: implemented - 工具结果裁剪(计划中)无需新机制:一个已记录的单条目 surface replace(`start === end`),携带同一 `callId` 下裁剪后的 `tool/result`——属压缩家族,回放正确,缓存击穿由相同的压力逻辑批量处理。 - 会话日志每个循环实例增长一个 `request/header` 快照,并在真正变更时增加快照。它比 delta 编解码器更大,但相对分片密集型日志仍然很小,并只保留一种回放表示。`SESSION_FORMAT_VERSION` 保持 `0`;旧的 delta 事件被拒绝而非迁移。 - 快照预期输出变更一次(每个 transcript(文本记录)增加其 header 事件);写入文件系统的 fixture(测试前置数据)以规范化的撰写形式存储,工具参数使用 cwd 相对路径,因为回放只对 cwd 无关的参数路径做往返。 -- FIXME(call-config-shape):重新审视 `LlmCallConfig` 的确切字段集——哪些字段对缓存而言真正属于 epoch 级别(`model` 毫无疑问;采样标量出于谨慎放在那里),以及当适配器需要时,提供方特定的额外项(推理(reasoning)选项、额外 body 参数)应归属何处。 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml index bdb07d5f8a..65cbe478ae 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md -2026-07-19-gui-layering-and-rpc-protocol.md: 34077302c53081f6ee9171d64dce9af342710d71 -2026-07-19-gui-layering-and-rpc-protocol.zh.md: bc51542ac8159ee7cba234b4ee8b4db47a7f9b58 +2026-07-19-gui-layering-and-rpc-protocol.md: 8e020e4fe9b60100671c0cf0e98e28532d850f94 +2026-07-19-gui-layering-and-rpc-protocol.zh.md: 55fa8084083aa83fbb4a38f8e41d2e5624b6e58e diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md index 34077302c5..8e020e4fe9 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md @@ -10,7 +10,7 @@ English | [中文](2026-07-19-gui-layering-and-rpc-protocol.zh.md) We need a UI integration layer. Beyond the existing ACP/stdio baseline, more product UI shapes are coming — Web (server), Electron, and others. We call these shapes Clients, uniformly, and want the following capabilities: -- One `dsh` process supporting both `dsh web` (serve) and `dsh -p` (headless) — one process, two modes (a design reservation) +- One `dsh` process supporting both `dsh web` (serve) and `dsh run` (headless) — one process, two modes (a design reservation) - Launching inside Electron with the same Web technology shape as `dsh web` That demands a stable layered responsibility model in the engineering codebase, so future client shapes plug in cleanly. @@ -31,7 +31,7 @@ Directories layer as follows: - **Fetch-arrival plugin packages** (`ui-layout`, `ui-sidebar`, `ui-conversation`, `ui-trajectory`): dual-entry — the root index is the node half (an empty `apply`, existing so the host Loader governs lifecycle and the web plugin registry discovers the package.json `dshClient` declaration); the implementation lives under `src/client/`, shipped as the `./client` subpath (a tsdown closure-factory bundle). Cross-plugin consumption of `/client` is type-only; value cooperation goes through cordis services. - `apps/` holds the externally exported application shapes, assembled from Client / Host mixtures. - `apps/web` (`dsh-frontend`) is the vite application: a thin `main.ts` over the shell surface exported by `dsh-client-web`. - - `apps/cli` (`@deepseek-ai/dsh`) dispatches shapes: `dsh web` = startHost + webserver + the built `dsh-frontend` dist; `dsh -p` = headless in-process calls, zero HTTP. + - `apps/cli` (`@deepseek-ai/dsh`) dispatches shapes: `dsh web` = startHost + webserver + the built `dsh-frontend` dist; `dsh run` = headless in-process calls, zero HTTP. - A future Electron shape reuses the same web client packages over an IPC fetch carrier. ``` @@ -215,7 +215,7 @@ All four quadrant full forms pass through `onEnvelope`; the base implementation | Subclass | Package | doFetch | Purpose | |---|---|---|---| -| `InProcessApiClient` | apiproxy itself | the injected `{ fetch }` handler | **The isomorphic point**: `new InProcessApiClient(toFetchHandler(api))` never touches the network yet runs the real wire serialization/zod/SSE framing — `dsh -p` headless is the protocol's second real consumer | +| `InProcessApiClient` | apiproxy itself | the injected `{ fetch }` handler | **The isomorphic point**: `new InProcessApiClient(toFetchHandler(api))` never touches the network yet runs the real wire serialization/zod/SSE framing — `dsh run` headless is the protocol's second real consumer | | `WebApiClient` | dsh-client-connection | `globalThis.fetch` uplink + one same-origin WebSocket downlink per logical stream | the browser shape; physical boundary in the [WebSocket downlink carrier](2026-08-04-websocket-downlink-carrier.md) | | `FixtureApiClient` | dsh-client-connection | unused (protocol-layer override) | serverless UI development (`?fixture`): overrides the `callUnary`/`openMux`/`openHost`/`respond` virtuals and is itself the fake server (frame rpcIds minted by it, semantics self-consistent) | | (future) IPC bridge subclass | apps/electron | IPC serialization round trip | swaps only doFetch; contract and base class unchanged | diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md index bc51542ac8..55fa808408 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md @@ -9,7 +9,7 @@ Status: implemented ## Problem 需要提供 UI 对接层,除已有 ACP/stdio基础版本外,还需要 Web(server) 、 Electron 、等其他产品 UI 形态。我们把这些形态统一称为 Client。希望有如下能力支持: -- 以 `dsh` 进程,同时支持 `dsh web`(启动) 和 `dsh -p`(headless) ,一个进程两种模式(设计预留) +- 以 `dsh` 进程,同时支持 `dsh web`(启动) 和 `dsh run`(headless) ,一个进程两种模式(设计预留) - 以与 `dsh web` 同构的 Web 技术形态,在 Electron 中启动 那么当前的工程代码需要稳定的分层职责模型,便于以后接入各类 client 形态。 @@ -29,7 +29,7 @@ Status: implemented - **fetch 到达插件包**(`ui-layout`、`ui-sidebar`、`ui-conversation`、`ui-trajectory`):双入口——根入口是 node 半边(空 `apply`,其存在是为了让 host Loader 管辖生命周期、让 web 插件注册表发现 package.json 的 `dshClient` 声明);实现住在 `src/client/` 下,经 `./client` 子路径发布(tsdown 闭包工厂 bundle)。跨插件消费 `/client` 只限类型;值层面的协作走 cordis 服务。 - `apps/` 作为对外导出的应用形态入口,可以由 Client / Host 混合组装。 - `apps/web`(`dsh-frontend`)是 vite 应用:`dsh-client-web` 导出的壳表面之上的一层薄 `main.ts`。 - - `apps/cli`(`@deepseek-ai/dsh`)做形态分发:`dsh web` = startHost + webserver + 构建出的 `dsh-frontend` dist;`dsh -p` = headless 进程内直调,零 HTTP。 + - `apps/cli`(`@deepseek-ai/dsh`)做形态分发:`dsh web` = startHost + webserver + 构建出的 `dsh-frontend` dist;`dsh run` = headless 进程内直调,零 HTTP。 - 将来的 Electron 形态经由 IPC fetch 载体复用同一套 web client 包。 ``` @@ -213,7 +213,7 @@ export type ResponseValue = | 子类 | 所在包 | doFetch | 用途 | |---|---|---|---| -| `InProcessApiClient` | apiproxy 本包 | 注入的 `{ fetch }` handler | **同构点**:`new InProcessApiClient(toFetchHandler(api))` 全程不过网络但真跑 wire 序列化/zod/SSE 帧——`dsh -p` headless 即协议第二真实消费者 | +| `InProcessApiClient` | apiproxy 本包 | 注入的 `{ fetch }` handler | **同构点**:`new InProcessApiClient(toFetchHandler(api))` 全程不过网络但真跑 wire 序列化/zod/SSE 帧——`dsh run` headless 即协议第二真实消费者 | | `WebApiClient` | dsh-client-connection | `globalThis.fetch` 上行 + 每逻辑流一条同源 WebSocket 下行 | 浏览器形态;物理边界见 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.md) | | `FixtureApiClient` | dsh-client-connection | 不用(协议层覆写) | 无 server 的 UI 开发(`?fixture`):覆写 `callUnary`/`openMux`/`openHost`/`respond` 虚方法,自己就是假 server(帧 rpcId 由它 mint,语义自洽) | | (将来)IPC 桥子类 | apps/electron | IPC 序列化往返 | 仅换 doFetch,契约/基类零改 | diff --git a/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.i18n.yaml index c462dcec34..1c6a23eec8 100644 --- a/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-27-compiler-independent-typert-model.md: 338476924dfb5d9832d0b64bf01b8d3c297cd6d6 -2026-07-27-compiler-independent-typert-model.zh.md: a88f4dbba50696071552ea12a63b69ecac202418 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.md +2026-07-27-compiler-independent-typert-model.md: 15641e5f785c5d2daecbfc2d5cffedd64d8384a7 +2026-07-27-compiler-independent-typert-model.zh.md: 7bddd3ffde343346c163f55ef493c32bad1e8314 diff --git a/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.md b/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.md index 338476924d..15641e5f78 100644 --- a/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.md +++ b/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.md @@ -22,7 +22,7 @@ PackageModel recognizes Cordis services, events, `@typert object` reference obje [`dsh-typert-registry`](../../../../packages/typert/registry/README.md) provides `ctx.typert` and handles runtime registration only: one contribution atomically carries package-face reflection and an optional Zod schema, and Cordis effect disposal revokes it. The registry neither analyzes TypeScript nor merges the two faces. JSON Schema is an on-demand projection of registered Zod schemas. -Package artifact publication is explicit opt-in. When invoked, `WorkspaceTypertGenerator` validates that each requested host face exposes the user-facing subpath `package/typert` from the root artifact `package/lib/typert.host.{js,d.ts}`, or that each requested client face exposes `package/client/typert` from `package/lib/typert.client.{js,d.ts}`. It neither edits exports nor runs as part of the ordinary root build or typecheck, so those commands do not generate whole-workspace Typert artifacts. Generated declarations keep `TYPERT` typed as `unknown`, so business packages do not depend on the registry. +Package artifact publication remains explicit opt-in through package exports. When invoked, `WorkspaceTypertGenerator` validates that each requested host face exposes the user-facing subpath `package/typert` from the root artifact `package/lib/typert.host.{js,d.ts}`, or that each requested client face exposes `package/client/typert` from `package/lib/typert.client.{js,d.ts}`; it never edits those exports. The later [TypeRT Remote design](2026-08-02-typert-remote-method-calls.md) adds a whole-workspace Host contract pass to root build, typecheck, lint, and documentation typecheck. For opted-in Host packages, that pass emits both local reflection and strict Host-for-Client `/remote` contracts before consumers resolve them. Generated local declarations keep `TYPERT` typed as `unknown`, so business packages do not depend on the registry. At build time, `CordisCatalogProjector` consumes the analyzed `FaceModel` and `TypeGraph` once to generate `docs/cordis-catalog/events.md`, `docs/cordis-catalog/services.md`, and the static `SERVICE_API`, `EVENT_API`, and `TYPE_API` catalog committed for `tool-cordis`. `tool-cordis` reads that static catalog and has no runtime dependency on `ctx.typert`. [`dsh-typert-loader`](../../../../packages/typert/loader/README.md) and the registry remain an independent runtime path: the loader follows Cordis Loader entry lifecycle events, imports an explicitly published `./typert` host artifact, and registers it through `ctx.typert`; neither component supplies the current `cordis_inspect` catalog. @@ -50,4 +50,4 @@ For each supported node kind and literal category, Zod emitter tests run both su New generation targets and static checks can reuse the same TypeGraph, and business categories can extend PackageModel without parsing the AST again. Preserving pre-evaluation types and independent faces makes the model more complex than a flattened schema; emitters must explicitly declare their supported scope and fail on missing capabilities. -Explicit opt-in keeps artifact publication and package exports under package ownership, while ordinary root builds and typechecks incur no whole-workspace Typert generation phase. The static Cordis catalogs remain reproducible from the canonical model without coupling `tool-cordis` to runtime registry state. `ctx.typert` reflects only artifacts mounted in the current runtime, and unloading does not control Zod instances that consumers retain after importing them directly. +Explicit package opt-in keeps artifact publication and exports under package ownership. Repository orchestration may still run the whole-workspace Host contract pass for every opted-in package; that pass remains owned by the later Remote Gateway Agent Note. The static Cordis catalogs remain reproducible from the canonical model without coupling `tool-cordis` to runtime registry state. `ctx.typert` reflects only artifacts mounted in the current runtime, and unloading does not control Zod instances that consumers retain after importing them directly. diff --git a/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.zh.md b/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.zh.md index a88f4dbba5..7bddd3ffde 100644 --- a/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.zh.md @@ -22,7 +22,7 @@ PackageModel 识别 Cordis service、event、`@typert object` 引用对象和 `@ [`dsh-typert-registry`](../../../../packages/typert/registry/README.md) 提供 `ctx.typert`,且只负责运行时注册:一个 contribution 原子携带 package-face reflection 与可选 Zod schema,并随 Cordis effect 撤销。注册表不分析 TypeScript,也不合并两个 face。JSON Schema 是对已注册 Zod schema 的按需投影。 -包产物发布采用显式 opt-in。`WorkspaceTypertGenerator` 仅在被调用时校验所请求 face 的根目录产物协议:host face 必须通过面向用户的 subpath `package/typert` 暴露 `package/lib/typert.host.{js,d.ts}`,client face 必须通过 `package/client/typert` 暴露 `package/lib/typert.client.{js,d.ts}`。它既不修改 exports,也不作为根目录普通 build 或 typecheck 的一部分运行,因此这些命令不会生成全仓 Typert 产物。生成的声明将 `TYPERT` 类型保持为 `unknown`,因此业务包不依赖注册表。 +包产物发布仍通过 package exports 采用显式 opt-in。`WorkspaceTypertGenerator` 仅在被调用时校验所请求 face 的根目录产物协议:host face 必须通过面向用户的 subpath `package/typert` 暴露 `package/lib/typert.host.{js,d.ts}`,client face 必须通过 `package/client/typert` 暴露 `package/lib/typert.client.{js,d.ts}`;它不会修改这些 exports。后续的 [TypeRT Remote 设计](2026-08-02-typert-remote-method-calls.md) 为根目录 build、typecheck、lint 与文档类型检查增加了全仓 Host 契约 pass。对于已 opt-in 的 Host 包,该 pass 会在消费方解析两者之前生成本地反射产物与严格的 Host-for-Client `/remote` 契约。生成的本地声明将 `TYPERT` 类型保持为 `unknown`,因此业务包不依赖注册表。 构建期的 `CordisCatalogProjector` 一次消费分析后的 `FaceModel` 与 `TypeGraph`,生成 `docs/cordis-catalog/events.md`、`docs/cordis-catalog/services.md`,以及为 `tool-cordis` 提交的静态 `SERVICE_API`、`EVENT_API` 和 `TYPE_API` catalog。`tool-cordis` 读取该静态 catalog,运行时不依赖 `ctx.typert`。[`dsh-typert-loader`](../../../../packages/typert/loader/README.md) 与注册表仍是独立的运行时路径:loader 监听 Cordis Loader 配置项生命周期事件,导入显式发布的 `./typert` host 产物,并通过 `ctx.typert` 注册;两者都不是当前 `cordis_inspect` catalog 的数据源。 @@ -50,4 +50,4 @@ Zod emitter 对支持的节点和各类 literal 逐类执行成功与失败 pars 新增生成目标或静态检查可复用同一 TypeGraph,业务类目也可在 PackageModel 上扩展,而无需再次解析 AST。保留计算前类型和独立 face 的代价是模型比打平后的 schema 更复杂,emitter 必须显式声明支持范围并对缺失能力失败。 -显式 opt-in 使产物发布与 package exports 由各包自行管理,根目录普通 build 和 typecheck 不会引入全仓 Typert 生成阶段。静态 Cordis catalog 可从标准模型复现,同时不把 `tool-cordis` 与运行时注册表状态耦合。`ctx.typert` 只反映当前运行时中已挂载的产物;对于消费方直接导入后仍持有的 Zod 实例,卸载流程无法控制。 +包级显式 opt-in 使产物发布与 exports 由各包自行管理。仓库编排仍可为每个已 opt-in 的包运行全仓 Host 契约 pass;该 pass 仍由后续 Remote Gateway Agent Note 负责说明。静态 Cordis catalog 可从标准模型复现,同时不把 `tool-cordis` 与运行时注册表状态耦合。`ctx.typert` 只反映当前运行时中已挂载的产物;对于消费方直接导入后仍持有的 Zod 实例,卸载流程无法控制。 diff --git a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml index 5a884924f9..92a30e931c 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md -2026-07-29-dsh-source-launch-tsx-esm.md: 21e912c7c7bbdd70142c202105d9a3035442884a -2026-07-29-dsh-source-launch-tsx-esm.zh.md: dc6150b7017777eea99778cc9813e17402354462 +2026-07-29-dsh-source-launch-tsx-esm.md: aabe6c2d6a92b5cc6568eee1d42df3a6998cf0ca +2026-07-29-dsh-source-launch-tsx-esm.zh.md: fdeb8a976e02bb9311a6ec9d097d09151a998a04 diff --git a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md index 21e912c7c7..aabe6c2d6a 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md +++ b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md @@ -35,4 +35,4 @@ The node-compat CI matrix (Node 22.19 and 26) gains `dsh-source-launch-smoke` (` - One launch vector across the whole engines range, including future Node lines that change native TypeScript support; the smoke gate enforces it per matrix line. - TypeScript transformation is delegated to tsx/esbuild again, reversing the prior note's goal of proving Node-native transformation; that goal is unreachable while vendored sources use non-erasable syntax and Node ships no transform mode. - The runtime declared-dependency enforcement in source launches is gone; undeclared workspace imports now surface only through static gates or built-mode resolution failures. -- Startup improves ~0.4s over the full tsx default (`demo:headless` and ACP keep `--import tsx`; their graphs were not audited for CJS-hook dependence and their launch latency is not on the interactive path). +- Startup improves ~0.4s over the full tsx default (`demo:headless` now aliases the same `dsh run` source launch; ACP keeps `--import tsx` because its graph was not audited for CJS-hook dependence and its launch latency is not on the interactive path). diff --git a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md index dc6150b701..fdeb8a976e 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md @@ -35,4 +35,4 @@ node-compat CI 矩阵(Node 22.19 与 26)新增 `dsh-source-launch-smoke`(` - 整个 engines 范围(包括未来改变原生 TypeScript 支持的 Node 版本线)只有一个启动向量;冒烟门禁按矩阵行强制执行。 - TypeScript 转换重新委托给 tsx/esbuild,逆转了前一篇 Agent Note「证明 Node 原生转换可用」的目标;在 vendor 源码使用不可擦除语法且 Node 不再提供 transform 模式的情况下,该目标不可达。 - 源码启动中的运行时依赖声明强制不复存在;未声明的 workspace import 现在只能通过静态门禁或构建模式的解析失败暴露。 -- 启动相比完整 tsx 默认形态快约 0.4s(`demo:headless` 与 ACP 保持 `--import tsx`:其依赖图未就 CJS 钩子依赖性做审计,且其启动延迟不在交互路径上)。 +- 启动相比完整 tsx 默认形态快约 0.4s(`demo:headless` 现为同一条 `dsh run` 源码启动命令的别名;ACP 保留 `--import tsx`,因为它的依赖图尚未就 CJS 钩子依赖性做审计,且其启动延迟不在交互路径上)。 diff --git a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml index 27da5f7663..5524e0d54d 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md -2026-07-29-request-level-llm-config-credentials.md: f12a2496a767decc3ce2b065f6be03009aec8992 -2026-07-29-request-level-llm-config-credentials.zh.md: 2d132c0fd0ab2205ca013b49226a9764293cc11d +2026-07-29-request-level-llm-config-credentials.md: 238400ea41f25a716729d1721c113645c2c8ba72 +2026-07-29-request-level-llm-config-credentials.zh.md: b0d04d4303bf0ccf5ebc74af8c4a3e493f861d63 diff --git a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md index f12a2496a7..238400ea41 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md +++ b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md @@ -14,7 +14,7 @@ The [settings seam](2026-07-28-user-settings-seam.md) shipped without a producti **Per-request resolution, not fiber rebuilds.** The adapters take an options thunk (and a per-stream credential resolver) instead of frozen construction facts, resolving once per operation — the Pi pattern, with its tested semantics: two requests straddling a change see two configurations, one request resolves exactly once, and an in-flight stream keeps the facts it started with. This deletes the entire swap machinery a rebuild design needs (`DUPLICATE_ADAPTER` ordering, `NO_ADAPTER` windows, a deferred-activation state machine) and makes a missing key a *request-time* actionable failure (`MISSING_CREDENTIAL` naming every entry point) while the route stays registered and the catalog stays browsable. The one registration-captured fact — the retry policy the `ctx.llm` registry snapshots at `registerAdapter` (plus pi-ai's route *set*) — re-registers the same adapter instance in one synchronous section when it changes. -**Secrets are references, values live behind `ctx.credentials`.** Configuration (both planes) carries `apiKeyEnv: DEEPSEEK_API_KEY`; the three-package credential seam resolves it per operation. `credentials-local` layers the live process environment (read-only, wins — a launch-time override is operator intent and must be *visibly* read-only, so shadowed writes reject instead of appearing to succeed) over `$DSH_HOME/.env` (writable, byte-preserving line edits, a quoting ladder dotenv reads back verbatim, wholesale snapshot replacement on reload so a deleted entry never lingers — the Claude Code additive-reapply lesson). Resolution order in the adapters is literal `apiKey` first (preserving the historical `config.apiKey ?? env` observable semantics), then the seam, then — only without a mounted seam — the raw environment variable. +**Secrets are references, values live behind `ctx.credentials`.** Configuration (both planes) carries `apiKeyEnv: DEEPSEEK_API_KEY`; the three-package credential seam resolves it per operation. `credentials-local` layers the live process environment (read-only, wins — a launch-time override is operator intent and must be *visibly* read-only, so shadowed writes reject instead of appearing to succeed) over the provider-managed document (writable, wholesale snapshot replacement on reload so a deleted entry never lingers — the Claude Code additive-reapply lesson). That document was `$DSH_HOME/.env` in dotenv form; the [credentials document split](2026-08-04-credentials-yaml-and-user-environment-layer.md) later moved it to `$DSH_HOME/.credentials.yaml` and freed the old path to become the user's environment layer. Adapters resolve the reference through the seam, or — only without a mounted seam — through the environment layers. **Per-plugin namespaces, schema ≡ `Config`.** Each adapter registers its own namespace (`llm-deepseek`, `llm-pi-ai`) with its plugin `Config` schema and its `cordis.yml` entry as the composition `base` — a settings section is the same YAML shape as the entry config, and `resolveAdapterOptions`/`resolveProfiles` stay the one explicit resolve step for both. A live snapshot failing a beyond-schema bound keeps the last good facts (the seam's last-good philosophy extended one level up); the entry config itself still fails load. pi-ai's `providers` became a dict keyed by route so base and user layers merge per provider and the route set is structural; the array shape fails loud with migration directions, and an empty dict is the valid dormant posture — a composition ships the adapter bare and every route stays a user-plane decision. diff --git a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md index 2d132c0fd0..b0d04d4303 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md @@ -14,7 +14,7 @@ Status: implemented **按请求解析,而非重建 fiber。**适配器改为接收一个 options thunk(外加按流调用的凭据解析器),不再持有冻结的构造期事实,每个操作解析一次——即 Pi 的模式,连同其经测试固定的语义:跨越一次变更的两个请求看到两份配置,一个请求恰好解析一次,进行中的流保持其起始事实。这删掉了重建式设计所需的整套切换机制(`DUPLICATE_ADAPTER` 顺序问题、`NO_ADAPTER` 窗口、延迟激活状态机),并把密钥缺失变成*请求时*可行动的失败(`MISSING_CREDENTIAL` 点名每个配置入口),同时路由保持注册、catalog 保持可浏览。唯一在注册期捕获的事实——`ctx.llm` 注册表在 `registerAdapter` 时快照的重试策略(外加 pi-ai 的路由*集合*)——在其变化时于一个同步区段内原地重新注册同一适配器实例。 -**机密是引用,值藏在 `ctx.credentials` 背后。**配置(两个面)携带 `apiKeyEnv: DEEPSEEK_API_KEY`;三包凭据 seam 按操作解析它。`credentials-local` 把活跃进程环境(只读、优先——启动时覆盖是操作者意图,必须*可见地*只读,因此被遮蔽的写入直接拒绝而不是表面成功)叠加在 `$DSH_HOME/.env` 之上(可写、保字节行级编辑、dotenv 能逐字读回的引号阶梯、重载时整体替换快照使删除的条目绝不滞留——来自 Claude Code 增量重放(additive reapply)的教训)。适配器内的解析顺序为:字面 `apiKey` 优先(保留历史 `config.apiKey ?? env` 的可观察语义),然后是 seam,最后——仅在未挂载 seam 时——原始环境变量。 +**机密是引用,值藏在 `ctx.credentials` 背后。**配置(两个面)携带 `apiKeyEnv: DEEPSEEK_API_KEY`;三包凭据 seam 按操作解析它。`credentials-local` 把活跃进程环境(只读、优先——启动时覆盖是操作者意图,必须*可见地*只读,因此被遮蔽的写入直接拒绝而不是表面成功)叠加在 provider 管理的文档之上(可写、重载时整体替换快照使删除的条目绝不滞留——来自 Claude Code 增量重放(additive reapply)的教训)。该文档当时是 dotenv 形式的 `$DSH_HOME/.env`;[凭据文档拆分](2026-08-04-credentials-yaml-and-user-environment-layer.md)后来把它移到 `$DSH_HOME/.credentials.yaml`,并让旧路径转为用户的环境层。适配器通过 seam 解析该引用;仅在未挂载 seam 时,才通过各环境层解析。 **按插件划分 namespace,schema ≡ `Config`。**每个适配器注册自己的 namespace(`llm-deepseek`、`llm-pi-ai`),schema 用其插件 `Config` schema,组合 `base` 用其 `cordis.yml` 条目——settings 分节与 entry 配置是同一种 YAML 形状,`resolveAdapterOptions`/`resolveProfiles` 对两者仍是唯一的显式 resolve 步骤。存活快照若违反 schema 之外的约束,则保留最后可用事实(seam 的最后可用值哲学向上延伸一层);entry 配置本身仍会加载失败。pi-ai 的 `providers` 改为以路由为键的字典,base 层与用户层因此按提供方合并,路由集合也由结构直接表达;数组形状响亮失败并给出迁移指引,而空字典是合法的休眠姿态——组合可以裸挂该适配器,把每一条路由都留给用户面决定。 diff --git a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.i18n.yaml index 1dc80bbf84..4becd41658 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md -2026-07-30-credential-boundaries-and-atomic-registration.md: 6fe5f554acbfd804db9625fcaa794d513c8799c4 -2026-07-30-credential-boundaries-and-atomic-registration.zh.md: b58a9e68bb1fa36282ad270542b6a64a0b014726 +2026-07-30-credential-boundaries-and-atomic-registration.md: 94b32c3cfaa3e1c5059573881a2f393d29aed3ac +2026-07-30-credential-boundaries-and-atomic-registration.zh.md: 2506ce391c9125323faf107e3c04c52785e0cc98 diff --git a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md index 6fe5f554ac..94b32c3cfa 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md +++ b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md @@ -10,15 +10,15 @@ English | [中文](2026-07-30-credential-boundaries-and-atomic-registration.zh.m Review found the credential path leaking across boundaries it had drawn. The shipped surfaces hoisted `$DSH_HOME/.env` into `process.env` before cordis booted, so on the next run `credentials-local` classified every key it had stored itself as a read-only ambient launch override: `describe()` reported `source: 'env'` with `writable: false`, `set`/`unset` rejected as shadowed, and a key stored from the web page or TUI became unrotatable and undeletable while the adapter kept using the value captured at launch. The store's own write path repeated the settings-local defects that same review round fixed (two independent chains, whole-file render from a stale cache), plus editor bugs of its own: a physical line inside another key's quoted multi-line value read as an assignment, CRLF endings degraded to LF, a multi-line entry reported `writable: true` while `set` always threw, and `credentials/updated` was emitted bare after the commit, so one broken observer made a durable write look failed. On the read side, the file's `0600` mode stops other OS users but not the model, whose bash and filesystem tools run as the same user. -Two request-path defects sat beside them. DeepSeek's per-request resolution kept connection facts in a last-good snapshot but re-read the literal `apiKey` from the raw configuration, so a settings generation the resolver rejected could still put its key on the previous generation's endpoint. pi-ai handed the SDK `undefined` when a configured `apiKeyEnv` resolved to nothing, letting pi-ai's own environment discovery authenticate with an unrelated provider key — another tenant, silently billed. And its route swap disposed the old registration before creating the new one: a route another adapter owned dropped every existing route, after which the facts cache could equal the registry's, so restoring the working configuration never re-applied. +Two request-path defects sat beside them. DeepSeek resolved connection and credential facts independently, so a settings generation the resolver rejected could still pair its credential choice with the previous generation's endpoint. pi-ai handed the SDK `undefined` when a configured `apiKeyEnv` resolved to nothing, letting pi-ai's own environment discovery authenticate with an unrelated provider key — another tenant, silently billed. And its route swap disposed the old registration before creating the new one: a route another adapter owned dropped every existing route, after which the facts cache could equal the registry's, so restoring the working configuration never re-applied. ## Decision -**`$DSH_HOME/.env` belongs to the credential provider alone.** No surface loads it into `process.env`. The genuine launch environment and the invoking directory's `.env` (loaded by the bin) stay the read-only ambient layer, so a composition without the provider resolves keys exactly as before, while a stored key stays file-sourced and writable across restarts — proven by a real restart in the loader composition rather than by a unit assertion about `describe()`. +**The credential document belongs to the credential provider alone.** No surface loads it into `process.env`. It was `$DSH_HOME/.env` here; the [credentials document split](2026-08-04-credentials-yaml-and-user-environment-layer.md) later moved it to `$DSH_HOME/.credentials.yaml`, so today it is the old path that is loaded — as the user's ordinary environment layer, holding no provider-managed secret. The genuine launch environment and the invoking directory's `.env` (loaded by the bin) stay the read-only ambient layer, so a composition without the provider resolves keys exactly as before, while a stored key stays file-sourced and writable across restarts — proven by a real restart in the loader composition rather than by a unit assertion about `describe()`. **The stored credential has no boundary against the model, and the READMEs say so.** `0600` under a `0700` directory stops other OS users; the model's bash and filesystem tools run as that same user, and the shipped default confines nothing. What the harness does hold to is narrower and stated as exactly that: no surface hoists the document into `process.env`, and the model is never handed a resolved path to it, so reaching the value takes a deliberate read of a path it was not given. An OS-keychain provider — a store the model's processes cannot read at all — is recorded as the real answer rather than implied by a partial one. -**One request, one generation.** DeepSeek's resolved snapshot carries the credential facts (literal key and reference) beside the endpoint, and `resolveApiKey` receives that snapshot instead of re-reading configuration. A rejected generation now contributes nothing at all. pi-ai defers to provider-native discovery only for a profile naming no credential; a configured reference that misses fails with `MISSING_CREDENTIAL` naming the route and the reference. The boot-time credential probe is deleted: it could run before the credentials service mounted and reported every failure as a missing key, while the first request already gives the accurate error. +**One request, one generation.** DeepSeek's resolved snapshot carries the credential reference beside the endpoint, and `resolveApiKey` receives that snapshot instead of re-reading configuration. A rejected generation now contributes nothing at all. pi-ai defers to provider-native discovery only for a profile naming no credential; a configured reference that misses fails with `MISSING_CREDENTIAL` naming the route and the reference. The boot-time credential probe is deleted: it could run before the credentials service mounted and reported every failure as a missing key, while the first request already gives the accurate error. **Route replacement is a registry operation, not a caller sequence.** `registerAdapter` returns a handle carrying `replace(providers)`: the candidate set is validated in full first (conflicts, names, provider metadata), then swapped in one synchronous section. A refused replacement leaves the previous routes registered and serving, and the caller's facts cache only advances after the registry actually holds the new set, so reverting to a working configuration re-applies. pi-ai's registration facts are sorted by provider, so a settings document that merely reorders its keys is no longer a route change. diff --git a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md index b58a9e68bb..2506ce391c 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md @@ -14,15 +14,15 @@ Status: implemented 在读取一侧,文件的 `0600` 权限挡得住其他 OS 用户,却挡不住模型:它的 bash 与文件系统工具就以同一个用户身份运行。 -与之并排的还有两个请求路径缺陷。DeepSeek 的按请求解析把连接事实保存在最后可用快照里,却仍从原始配置重新读取字面 `apiKey`,于是被 resolver 拒绝的那一代设置,照样能把自己的密钥送到上一代的端点上。配置了 `apiKeyEnv` 却解析不到值时,pi-ai 会把 `undefined` 交给 SDK,让 pi-ai 自己的环境发现拿一个毫不相干的提供方密钥完成鉴权——那是另一个租户,账单还悄悄记在它头上。而且它的路由替换是先释放旧注册、再创建新注册:只要有一条路由已被别的适配器占有,现有路由就会被全部丢掉,此后事实缓存可能与注册表中的事实相等,于是把配置改回可用状态也不会重新生效。 +与之并排的还有两个请求路径缺陷。DeepSeek 分别解析连接事实与凭据事实,因此被 resolver 拒绝的那一代设置仍可能把自己的凭据选择与上一代的端点配在一起。配置了 `apiKeyEnv` 却解析不到值时,pi-ai 会把 `undefined` 交给 SDK,让 pi-ai 自己的环境发现拿一个毫不相干的提供方密钥完成鉴权——那是另一个租户,账单还悄悄记在它头上。而且它的路由替换是先释放旧注册、再创建新注册:只要有一条路由已被别的适配器占有,现有路由就会被全部丢掉,此后事实缓存可能与注册表中的事实相等,于是把配置改回可用状态也不会重新生效。 ## 决策 -**`$DSH_HOME/.env` 只归凭据提供方所有。**没有任何一个面会把它加载进 `process.env`。真正的启动环境,以及调用目录中由 bin 加载的 `.env`,仍然是那一层只读的环境来源,因此不挂载该提供方的组合,解析密钥的方式与从前完全一致,而存下的密钥跨重启仍然来源于文件、仍然可写——这一点由 Loader 组合中的一次真实重启来证明,而不是靠对 `describe()` 的单元断言。 +**凭据文档只归凭据提供方所有。**没有任何一个面会把它加载进 `process.env`。当时该文档是 `$DSH_HOME/.env`;[凭据文档拆分](2026-08-04-credentials-yaml-and-user-environment-layer.md)后来把它移到 `$DSH_HOME/.credentials.yaml`,因此如今被加载的正是那条旧路径——作为用户的普通环境层,其中不含任何 provider 管理的密钥。真正的启动环境,以及调用目录中由 bin 加载的 `.env`,仍然是那一层只读的环境来源,因此不挂载该提供方的组合,解析密钥的方式与从前完全一致,而存下的密钥跨重启仍然来源于文件、仍然可写——这一点由 Loader 组合中的一次真实重启来证明,而不是靠对 `describe()` 的单元断言。 **存下的凭据对模型没有边界,而 README 就是这么写的。**`0700` 目录下的 `0600` 挡得住其他 OS 用户;模型的 bash 与文件系统工具正是以同一用户身份运行,而已交付的默认值不约束任何东西。harness 真正守住的更窄,也就照这个宽度写下来:没有任何一个面会把该文档提升进 `process.env`,模型也从不会拿到它的解析后路径,因此要拿到这个值,需要刻意去读一条并未交给它的路径。OS 钥匙串(keychain)提供方——一个模型的进程根本读不到的存储——被记录为真正的答案,而不是靠一个残缺的方案去暗示它。 -**一次请求,一代设置。**DeepSeek 解析出的快照在端点旁一并携带凭据事实(字面密钥与引用),`resolveApiKey` 接收这份快照,而不再重新读取配置。被拒绝的那一代如今完全不再贡献任何东西。只有当一个 profile 完全没有点名凭据时,pi-ai 才交给提供方原生的发现流程;配置了引用却解析不到,就以 `MISSING_CREDENTIAL` 失败,并点名该路由与该引用。启动时的凭据探测被删除:它可能在凭据服务挂载之前就运行,并把每一种失败都报成密钥缺失,而第一次请求本就会给出准确的错误。 +**一次请求,一代设置。**DeepSeek 解析出的快照在端点旁一并携带凭据引用,`resolveApiKey` 接收这份快照,而不再重新读取配置。被拒绝的那一代如今完全不再贡献任何东西。只有当一个 profile 完全没有点名凭据时,pi-ai 才交给提供方原生的发现流程;配置了引用却解析不到,就以 `MISSING_CREDENTIAL` 失败,并点名该路由与该引用。启动时的凭据探测被删除:它可能在凭据服务挂载之前就运行,并把每一种失败都报成密钥缺失,而第一次请求本就会给出准确的错误。 **路由替换是注册表的操作,不是调用方的一串步骤。**`registerAdapter` 返回一个携带 `replace(providers)` 的句柄:候选集合先被完整校验(冲突、名称、提供方元数据),再在一个同步区段内完成替换。被拒绝的替换会让先前的路由保持注册并继续服务,而调用方的事实缓存只有在注册表确实持有新集合之后才会推进,因此改回可用配置时会重新生效。pi-ai 的注册事实按提供方排序,因此仅仅调换键顺序的设置文档不再算作路由变更。 diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index 71ded0fa8d..d3f73c3b5c 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md -2026-08-02-typert-remote-method-calls.md: 215c647bcd7413b92625ee670022dc7316e3045a -2026-08-02-typert-remote-method-calls.zh.md: 0ce431b7cbc948e937f722f2769b15a1d26dcec9 +2026-08-02-typert-remote-method-calls.md: f3db8b9eec5eb8fb610fc58edad9313e4f715326 +2026-08-02-typert-remote-method-calls.zh.md: da4459432e8c9e08821bcded2921cbd33fc5e8c8 diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index 215c647bcd..f3db8b9eec 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -217,6 +217,8 @@ Host lib build The existing top-level `build` still runs `build:lib` before `build:web`, but `build:lib` must complete the Host and Remote artifacts before starting Client TypeScript compilation. A clean build must not depend on stale `.d.ts` files from an earlier build. +Compiler-backed repository gates that resolve the consumer surface have the same prerequisite even when their primary inputs are source files. The public `typecheck`, `lint`, and `doc-typecheck` commands run the Host contract pass first. The gate scheduler may use their `*:contracts-ready` variants only after an explicit TypeRT-contract or complete-build dependency, so parallel lanes neither read missing declarations nor run concurrent generators against the same outputs. + ## The `/remote` package entry Every business package that provides Remote methods exports a generated `/remote` subpath: @@ -490,6 +492,7 @@ The package topology is `api/remotes → api/gateway → client/connection → h - Goal Service directly decorates mutation methods whose business signatures already match the Remote contract and keeps `remoteExportCreate(...)` only to adapt `GoalView` into `CreateGoalResult`, without a second route, codec, or Client method list. - A clean `build:lib` emits Host and consumer Remote artifacts before Client compilation, including the business package's JS, DTS, and declaration map under `/remote`. +- After `clean`, standalone `typecheck`, `lint`, and `doc-typecheck` regenerate the Remote contracts; the pre-push hook uses the same prepared typecheck, and CI source consumers wait for one shared contract pass. - Importing `@deepseek-ai/dsh-goal/remote` adds the strict `ctx.remote.goals.create(...)` type and declaration navigation to `remoteExportCreate`; omitting that import omits the namespace. - Mounting the same import's JS contribution supplies endpoint, parameter, result, lookup, Context, and Zod reflection and materializes the call without a handwritten stub. - Root and Agent-scoped calls cross the real shared `/api` carrier, resolve `agentId` to the live Agent, invoke the original Goal receiver, and return through the existing RPC envelope. @@ -501,7 +504,7 @@ The package topology is `api/remotes → api/gateway → client/connection → h ## Consequences -Remote API types depend on generated `lib` declarations. Build orchestration must finish the Host contract pass before compiling Host and Client consumers; an incorrect order makes a clean build depend on stale artifacts. +Remote API types depend on generated `lib` declarations. Build and gate orchestration must finish the Host contract pass before compiling or semantically analyzing Host and Client consumers; an incorrect order makes a clean command depend on stale artifacts. Source navigation requires a Remote package to publish both its declaration map and the `src` file referenced by the map. If package `files` omits either side, types still compile but consumer navigation stops at the generated DTS. The workspace manifest check must therefore treat both as one publication contract. diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index 0ce431b7cb..da4459432e 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -217,6 +217,8 @@ Host lib build 现有顶层 `build` 仍表现为先 `build:lib`、再 `build:web`,但 `build:lib` 内部必须先完成 Host 与 Remote artifact,再启动 Client TypeScript 编译。一次干净构建不能依赖上次残留的 `.d.ts`。 +即使主要输入是源文件,需要通过编译器解析消费方 surface 的仓库门禁也有相同的前置条件。公共 `typecheck`、`lint` 和 `doc-typecheck` 命令会先执行 Host 契约 pass。门禁调度器仅可在显式的 TypeRT 契约依赖或完整构建依赖完成后使用对应的 `*:contracts-ready` 变体,使并行 lane 既不会读取缺失的声明,也不会针对同一输出并发运行多个生成器。 + ## `/remote` 包入口 每个提供 Remote 方法的业务包导出生成的 `/remote` 子路径: @@ -490,6 +492,7 @@ Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebS - Goal Service 直接装饰业务签名已经符合 Remote 契约的变更类方法,仅保留 `remoteExportCreate(...)` 把 `GoalView` 适配为 `CreateGoalResult`,无需第二条路由、第二份 codec 或 Client 方法清单。 - 一次干净的 `build:lib` 会在 Client 编译前生成 Host 与消费方 Remote 产物,包括业务包 `/remote` 下的 JS、DTS 和 declaration map。 +- `clean` 后,单独运行 `typecheck`、`lint` 或 `doc-typecheck` 都会重新生成 Remote 契约;pre-push 钩子使用同一个已包含契约准备步骤的 typecheck,CI 中的源码消费方则等待一次共享的契约 pass。 - 导入 `@deepseek-ai/dsh-goal/remote` 会加入严格的 `ctx.remote.goals.create(...)` 类型,并可通过 declaration 导航到 `remoteExportCreate`;不导入时不会出现该 namespace。 - 挂载同一次 import 得到的 JS contribution 会提供 endpoint、参数、结果、lookup、Context 和 Zod 反射,并在无需手写 stub 的情况下实体化调用。 - Root 与 Agent-scoped 调用会经过真实的共享 `/api` carrier,将 `agentId` 解析为活 Agent,调用原始 Goal receiver,并通过既有 RPC envelope 返回。 @@ -501,7 +504,7 @@ Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebS ## 后果 -Remote API 类型依赖生成的 `lib` 声明,构建编排必须在 Host 和 Client 消费端编译前完成 contract pass;顺序错误会让干净构建依赖陈旧产物。 +Remote API 类型依赖生成的 `lib` 声明,构建与门禁编排必须在对 Host 和 Client 消费方进行编译或语义分析之前完成 Host 契约 pass;顺序错误会使干净环境中的命令依赖陈旧产物。 源码导航依赖 Remote package 同时发布 declaration map 和 map 指向的 `src`。package `files` 漏掉任一侧时类型仍可编译,但消费端跳转会停在生成 DTS,因此 workspace manifest 校验必须把两者作为同一发布契约。 diff --git a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.i18n.yaml index 9300571e28..2969995da6 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.md -2026-08-03-pi-ai-declared-provider-catalog.md: d75b6bdb91d60026636bf320f8c6625590849a41 -2026-08-03-pi-ai-declared-provider-catalog.zh.md: f8dba9900b1a7a3abcb16c70a35cc18f0c44219f +2026-08-03-pi-ai-declared-provider-catalog.md: f908eb6293b77680193fcd8f7be7a9089477855a +2026-08-03-pi-ai-declared-provider-catalog.zh.md: ce91abd6dc71f790c72766cd3f819096d596182c diff --git a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.md b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.md index d75b6bdb91..f908eb6293 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.md +++ b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.md @@ -14,7 +14,7 @@ The adapter also streamed through `streamSimple` from `@earendil-works/pi-ai/com A provider route is a **declaration**, and the installed catalog is its default. `resolveProfiles` no longer checks route keys against `getBuiltinProviders()`. Instead each route resolves to a materialized model list plus the pi-ai `Provider` that serves it: -- `catalog.ts` merges the installed catalog under the profile's own entries. A profile's `models` list *replaces* the route's catalog (an absent or empty list serves it unchanged), and each entry defaults its unset fields from the installed model of the same `id`. Only the fields the harness consumes are configurable — `id`, `name`, `contextWindow`, `maxTokens`. Pricing and input modalities are absent from the surface because nothing reads them: `replay.ts` zeroes pi-ai's cost metadata and `context.ts` keeps only text blocks. Reasoning is absent for a different reason: a bare capability flag would make pi-ai advertise effort levels with no `thinkingLevelMap` to spell them, so it rides the installed entry or is absent. Materialization spreads the installed entry and overrides those four fields, rather than enumerating the result: an enumerated rebuild silently drops every `Model` field this package does not model, which is how `headers` went missing from an nvidia route once already. +- `catalog.ts` merges the installed catalog under the profile's own entries. A profile's `models` list *replaces* the route's catalog (an absent or empty list serves it unchanged), and each entry defaults its unset fields from the installed model of the same `id`. Only the fields the harness consumes are configurable — at this note's writing `id`, `name`, `contextWindow`, `maxTokens`; [[2026-08-08-pi-ai-per-model-reasoning-declarations]] later added `reasoningEfforts` and `compat`, which is also where the original "reasoning rides the installed entry or is absent" stance was revisited (a bare capability flag stays rejected; a full per-level declaration with wire spellings does not have its problem). Pricing and input modalities remain absent from the surface because nothing reads them: `replay.ts` zeroes pi-ai's cost metadata and `context.ts` keeps only text blocks. Materialization spreads the installed entry and overrides the configured fields, rather than enumerating the result: an enumerated rebuild silently drops every `Model` field this package does not model, which is how `headers` went missing from an nvidia route once already. - `provider.ts` builds the route's `Provider`. A catalog route that keeps its catalog protocol **reuses** the installed provider with `getModels()` replaced; every other route is built by `createProvider()` over a protocol table whose entries are the same `@earendil-works/pi-ai/api/*.lazy` factories pi-ai's own provider factories use. That table is narrower than pi-ai's full API set on purpose — it holds only protocols a profile can completely describe with a key, an endpoint, and headers, so Bedrock (SigV4 plus a region), Vertex (project, location, ADC), Azure (provider environment plus an api-version), and Codex (OAuth) are absent rather than offered as routes that cannot authenticate. Catalog routes still reach them through their own provider; only an explicit override is refused. - `adapter.ts` turns each resolution into an **immutable snapshot** — the profiles plus a `createModels()` collection holding those providers — and every operation captures a whole snapshot before its first `await`. - A model's **explicitly configured** `maxTokens` becomes the seam's `defaultMaxTokens`. The value inherited from the installed catalog does not: pi-ai requires `Model.maxTokens` as the model's output *capability*, while `defaultMaxTokens` is a cap the deployment chose to send on requests that name none, and materializing the former as the latter would start capping every request at a number nobody picked. @@ -35,7 +35,7 @@ The configurable-provider directory is now the installed catalog **joined with** pi-ai reports a model with no reasoning metadata as supporting the single level `off`, and the adapter used to pass that straight through. It reaches the seam as a one-item effort list, which every surface renders as a picker holding one selectable control — and that control is a lie: `off` becomes an *omitted* reasoning option at dispatch, byte-for-byte the request that naming no effort already produces. A provider whose own default is to think keeps thinking while the surface shows `off` selected. -`reasoningInfo` therefore omits the seam's `reasoning` field whenever `model.reasoning` is falsy. The condition is the model's own metadata, not where the model came from, so this covers every hand-declared model **and** the 251 installed-catalog models pi-ai marks as non-reasoning. Those previously offered the lone `off`; they now offer nothing, and the surface shows the provider default alone. Models that do carry reasoning metadata are untouched — their level list still crosses the seam unfiltered, `off` included, because there it selects between real alternatives. +`reasoningInfo` therefore omits the seam's `reasoning` field whenever `model.reasoning` is falsy. The condition is the model's own metadata, not where the model came from, so this covers every hand-declared model whose entry declares no `reasoningEfforts` ([[2026-08-08-pi-ai-per-model-reasoning-declarations]] made declared efforts carry that metadata) **and** the 251 installed-catalog models pi-ai marks as non-reasoning. Those previously offered the lone `off`; they now offer nothing, and the surface shows the provider default alone. Models that do carry reasoning metadata are untouched — their level list still crosses the seam unfiltered, `off` included, because there it selects between real alternatives. ### Credentials stay outside pi-ai @@ -50,7 +50,7 @@ A route's auth follows from that. A catalog route keeps the installed provider's - **Keep `createProvider()` but skip the `Models` collection**, streaming through `provider.streamSimple(model, ctx, {apiKey})`. Smallest diff and the credential path is untouched, but `createProvider`'s `auth` is a required field that this path never invokes — a required-by-signature implementation with no caller. It also leaves `refreshModels` needing a hand-built `RefreshModelsContext`, and keeps the adapter off the runtime pi-ai actually supports. - **Reuse the installed provider for catalog routes and `createProvider()` only for declared ones**, with no shared resolution. Zero risk to catalog behavior, but catalog materialization, endpoint override, and per-model configuration would each exist twice, and a catalog route that repoints its protocol would have to jump paths mid-resolution. The chosen split confines the asymmetry to provider construction, where it is forced by pi-ai not exposing a built provider's API implementations. - **Rebuild every route through `createProvider()`**, including catalog ones. Fully symmetric, but a built `Provider` does not expose its `api`, so the protocol table would become the ceiling on which providers work — Bedrock loads its Smithy module through a separate entry point and would silently stop working. -- **Expose pi-ai's whole `Model` shape** (cost, input modalities, `thinkingLevelMap`, `compat`). Maximum configurability, but no current consumer reads those fields, so a configured price or modality would change nothing while reading as supported. +- **Expose pi-ai's whole `Model` shape** (cost, input modalities, `thinkingLevelMap`, `compat`). Maximum configurability, but no current consumer read those fields then, so a configured price or modality would change nothing while reading as supported. The consumer-driven half of this arrived later: [[2026-08-08-pi-ai-per-model-reasoning-declarations]] opened reasoning (as `reasoningEfforts`, not a raw `thinkingLevelMap`) and the two reasoning-dispatch `compat` switches once selectors and dispatch actually consumed them; cost and modalities stay closed for the original reason. - **Keep one mutable `Models` collection and re-sync it.** Fewer allocations, and correct for every operation that resolves synchronously. It is exactly wrong for the one that does not: `stream()` awaits a credential between capturing its model and dispatching it. - **Simulate an atomic directory swap with dispose-then-register.** No seam change, and it works whenever the new set is valid — which is the case that never needed atomicity. diff --git a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.zh.md b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.zh.md index f8dba9900b..ce91abd6dc 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.zh.md @@ -14,7 +14,7 @@ Status: implemented 提供方路由是一份**声明**,已安装 catalog 是它的默认值。`resolveProfiles` 不再拿路由键去核对 `getBuiltinProviders()`,而是把每条路由解析成一份物化模型列表,外加服务它的 pi-ai `Provider`: -- `catalog.ts` 把已安装 catalog 合并到 profile 自身条目之下。profile 的 `models` 列表*替换*该路由的 catalog(列表缺席或为空则原样服务),每个条目从同 `id` 的已安装模型继承自身未设置的字段。只有 harness 会消费的字段可配置——`id`、`name`、`contextWindow`、`maxTokens`。定价与输入模态不出现在配置面,因为没有任何读取方:`replay.ts` 把 pi-ai 的成本元数据清零,`context.ts` 只保留文本块。推理缺席则是另一个理由:一个孤立的能力布尔量会让 pi-ai 公布出没有 `thinkingLevelMap` 可供拼写的档位,因此它沿用已安装条目或直接缺席。物化时以已安装条目铺底、再覆盖那四个字段,而不是逐字段枚举结果:枚举式重建会静默丢弃本包未建模的每一个 `Model` 字段——`headers` 就是这样从某条 nvidia 路由上消失过一次。 +- `catalog.ts` 把已安装 catalog 合并到 profile 自身条目之下。profile 的 `models` 列表*替换*该路由的 catalog(列表缺席或为空则原样服务),每个条目从同 `id` 的已安装模型继承自身未设置的字段。只有 harness 会消费的字段可配置——本 note 写就时为 `id`、`name`、`contextWindow`、`maxTokens`;[[2026-08-08-pi-ai-per-model-reasoning-declarations]] 之后加入了 `reasoningEfforts` 与 `compat`,当初「推理沿用已安装条目或直接缺席」的立场也在那里被重新审视(孤立的能力布尔量仍被拒绝;带 wire 拼写的逐档位完整声明没有它那个问题)。定价与输入模态仍不出现在配置面,因为没有任何读取方:`replay.ts` 把 pi-ai 的成本元数据清零,`context.ts` 只保留文本块。物化时以已安装条目铺底、再覆盖已配置的字段,而不是逐字段枚举结果:枚举式重建会静默丢弃本包未建模的每一个 `Model` 字段——`headers` 就是这样从某条 nvidia 路由上消失过一次。 - `provider.ts` 构造路由的 `Provider`。保持 catalog 协议不变的 catalog 路由会**复用**已安装提供方,只替换 `getModels()`;其余路由都由 `createProvider()` 基于一张协议表构造,表中条目正是 pi-ai 自己的提供方工厂所用的 `@earendil-works/pi-ai/api/*.lazy` factory。该表刻意窄于 pi-ai 的完整 API 集合——只保留 profile 能用密钥、端点与标头完整描述的协议,因此 Bedrock(SigV4 加 region)、Vertex(project、location、ADC)、Azure(提供方环境加 api-version)与 Codex(OAuth)不在其中,而不是被当作无法认证的路由提供出去。catalog 路由仍可经自己的 provider 抵达它们;被拒的只有显式覆盖。 - `adapter.ts` 把每次解析变成一份**不可变快照**——profiles 加上持有这些 provider 的 `createModels()` 集合——每个操作都在自己第一个 `await` 之前整体捕获一份。 - 模型**显式配置**的 `maxTokens` 会成为 seam 的 `defaultMaxTokens`;从已安装 catalog 继承来的那份不会:pi-ai 要求 `Model.maxTokens` 表示模型的输出**能力**,而 `defaultMaxTokens` 是部署选定、发给未点名上限的请求的那个值,把前者物化成后者会让每个请求都被一个无人选择的数字封顶。 @@ -35,7 +35,7 @@ Status: implemented pi-ai 把没有推理元数据的模型报告为只支持 `off` 一档,而适配器此前原样透传。它抵达 seam 时是一个单元素的 effort 列表,任何界面都会把它渲染成一个只有一项可选控件的选择器——而这个控件在撒谎:`off` 在派发时变成被*省略*的 reasoning 选项,与「不点名任何档位」产出的请求逐字节相同。自身默认就在思考的提供方会继续思考,界面却显示 `off` 已选中。 -因此只要 `model.reasoning` 为假,`reasoningInfo` 就省略 seam 的 `reasoning` 字段。判据是模型自身的元数据,而非模型的来源,所以它覆盖每一个手工声明的模型**以及** pi-ai 标记为不具备推理能力的那 251 个已安装 catalog 模型。它们此前提供那个孤零零的 `off`,现在什么也不提供,界面只剩提供方默认。携带推理元数据的模型不受影响——其档位列表仍不经筛选地穿过 seam、`off` 也在内,因为在那里它是在真实备选之间做选择。 +因此只要 `model.reasoning` 为假,`reasoningInfo` 就省略 seam 的 `reasoning` 字段。判据是模型自身的元数据,而非模型的来源,所以它覆盖条目未声明 `reasoningEfforts` 的每一个手工声明模型([[2026-08-08-pi-ai-per-model-reasoning-declarations]] 让声明的档位携带这份元数据)**以及** pi-ai 标记为不具备推理能力的那 251 个已安装 catalog 模型。它们此前提供那个孤零零的 `off`,现在什么也不提供,界面只剩提供方默认。携带推理元数据的模型不受影响——其档位列表仍不经筛选地穿过 seam、`off` 也在内,因为在那里它是在真实备选之间做选择。 ### 凭据留在 pi-ai 之外 @@ -50,7 +50,7 @@ pi-ai 的 `Models` 自带一套凭据概念——按提供方 id 索引的 `Cred - **保留 `createProvider()` 但不建 `Models` 集合**,改由 `provider.streamSimple(model, ctx, {apiKey})` 发起。改动最小且凭据路径原封不动,但 `createProvider` 的 `auth` 是必填字段,这条路上它永远不会被调用——一份因签名而必填、却没有调用方的实现。它还让 `refreshModels` 需要手工构造 `RefreshModelsContext`,并使适配器始终不在 pi-ai 真正支持的运行时上。 - **catalog 路由复用已安装提供方,只有声明式路由走 `createProvider()`**,且两者不共享解析。对 catalog 行为零风险,但 catalog 物化、端点覆盖与每模型配置这三件事都要各写两遍,而改指协议的 catalog 路由还得在解析中途跳到另一条路径。已采纳的拆法把不对称收敛在提供方构造这一处——那里的不对称是 pi-ai 不暴露已构造提供方的 API 实现所强加的。 - **让每条路由都经 `createProvider()` 重建**,包括 catalog 路由。完全对称,但已构造的 `Provider` 不暴露自己的 `api`,于是协议表会成为「哪些提供方能用」的天花板——Bedrock 经独立入口加载其 Smithy 模块,会因此静默失效。 -- **完整暴露 pi-ai 的 `Model` 形状**(成本、输入模态、`thinkingLevelMap`、`compat`)。可配置性最大,但这些字段当前没有任何读取方,因此配了价格或模态什么也不会改变,却看起来像是受支持的。 +- **完整暴露 pi-ai 的 `Model` 形状**(成本、输入模态、`thinkingLevelMap`、`compat`)。可配置性最大,但这些字段当时没有任何读取方,因此配了价格或模态什么也不会改变,却看起来像是受支持的。这条否决里由消费方驱动的那一半后来兑现了:[[2026-08-08-pi-ai-per-model-reasoning-declarations]] 在选择器与分派真正消费之后开放了推理(以 `reasoningEfforts` 的形态,而非裸 `thinkingLevelMap`)和两个推理分派 `compat` 开关;成本与模态仍因原有理由保持关闭。 - **保留单个可变 `Models` 集合并重新同步。** 分配更少,且对每个同步完成解析的操作都是正确的;唯独对那个不同步的操作恰恰是错的:`stream()` 会在捕获模型与派发模型之间 await 一次凭据。 - **用「先 dispose 再注册」模拟目录原子替换。** 无需改 seam,且在新集合有效时确实可用——而那正是从不需要原子性的那种情形。 diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml new file mode 100644 index 0000000000..22935b42ca --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md +2026-08-04-configuration-source-ownership.md: ef30a22c120af1437f348e52843e1dd45c9837ae +2026-08-04-configuration-source-ownership.zh.md: c1567c6e823a0bc8ed8d1f9ed50a11a5d204ede8 diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md new file mode 100644 index 0000000000..ef30a22c12 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md @@ -0,0 +1,73 @@ +# Agent Note: One ordering for configuration sources, and what a discovered file may not decide + +Status: implemented + +English | [中文](2026-08-04-configuration-source-ownership.zh.md) + +## Problem + +`$DSH_HOME/.env` had just [become an ordinary environment layer](2026-08-04-credentials-yaml-and-user-environment-layer.md), which left the harness resolving user-facing values from a flattened `process.env` that could no longer say where a value came from. Three consequences followed. + +A key stored through the web page stayed shadowed by an older key in the user's own `.env`, because the credential provider compared "the environment" against its file and the environment now included that file. The migration dead end the split was supposed to remove had simply moved. + +An endpoint could be redirected by the project. The invoking directory's `.env` is materialized like every other layer, and a base URL decides where a resolved API key is sent — so a `DEEPSEEK_BASE_URL` written into a workspace the model can edit would send the user's own credential, and the prompts carrying their code, to whatever host that file named. Nothing about the flattened view could distinguish that from the operator exporting the same variable. + +And `!!js process.env.X` in the shipped composition made the same value reachable twice: once through the entry config and once through whatever ladder its consumer applied, with the winner decided by layer order rather than by what the value means. + +## Decision + +**One ordering for non-secret values.** Every configurable value that is not itself a credential resolves in the same order; the domains differ only in which tiers exist. + +```text +explicit for this run per-operation override, CLI argument +> user settings settings.yaml +> composition profile bundles, user patch layers, --patch overlays +> this launch's shell inherited process environment +> discovered file /.env, then $DSH_HOME/.env +> defaults schema default, provider public default +``` + +Settings sit above composition because that is what the [settings seam](2026-07-28-user-settings-seam.md) does: a plugin registers its cordis entry config as the `base` layer and the user's section layers over it, and the seam cannot tell a value a profile's bundles set from one its user patch layer or a `--patch` overlay set — all arrive as entry config. The product CLI has no lever above stored settings, so a deployment that must pin a field against a user's settings ships its own bin or loader tree, or mounts no settings provider at all. Composition still outranks the environment, so a stale `DEEPSEEK_BASE_URL` in a shell cannot rewrite a configured endpoint. + +**Credentials keep a narrower, separate ordering**, and this note does not unify them: + +```text +inherited process environment (read-only, wins) +> $DSH_HOME/.credentials.yaml (provider-managed, writable) +> /.env +> $DSH_HOME/.env +``` + +The launching environment wins because `DEEPSEEK_API_KEY=… dsh`, a CI secret, and a container `-e` are the one override an operator must be able to apply per run without editing machine state, and because it cannot be edited from inside it must be *visibly* read-only. Configuration is meant to carry only the *reference* — which name to resolve — and that name follows the non-secret ordering above. + +**The project the harness is launched in is trusted, by default and without a prompt.** A checkout may carry its own endpoint, its own ordinary variables, and its own key; the key ranks below the managed store, so a key stored through the Models page is never displaced by one a checkout happens to contain. `EnvironmentSnapshot.getFrom(name, sources)` still searches only the layers a caller names, and omitting one is a refusal rather than a demotion — the mechanism exists for the decisions where a layer must be unreachable, not because the project is one of them today. + +**Trust does not extend to changing the harness itself.** `loadLayeredEnv` rejects, at load and before anything is materialized, any `.env` that sets a variable governing how a process launches (`PATH`, `SHELL`, `NODE_OPTIONS`, `LD_PRELOAD`), what code a runtime executes before the program it was asked to run (`BASH_ENV`, `PERL5OPT`, `PYTHONSTARTUP`, `RUBYOPT`, `JAVA_TOOL_OPTIONS`, the Git hook commands), where model-visible instructions load from (the whole `DSH_*` namespace, `HOME`, `XDG_*`), or how the network is reached and trusted (proxy and CA variables). Matching is case-insensitive, so `https_proxy` is not a bypass. + +The line is that these take effect with no user action, before any turn, outside the permission policy and the sandbox. `DSH_PERMISSION_MODE` would switch off the approvals that make trusting a project meaningful at all, and `BASH_ENV` runs a file of the project's choosing on every single `bash -c` the bash tool issues — the project's code running under the agent's policy is the deal; the project rewriting that policy is not. Enumerating these is a losing game one variable at a time, which is why the whole `DSH_*` namespace is denied rather than an audited subset, and why the list is organised by what a variable *does* rather than by which runtime owns it. There is no opt-out: an escape hatch would have to be readable from somewhere, and anything a discovered file could set is the hole itself. + +**`packages/util/environment` owns the snapshot**, deliberately as a utility rather than a three-package capability seam. The snapshot is frozen before Cordis starts and injected once by the launcher, so there is no runtime implementation to swap; consumers need types and pure functions, which a `util/` package gives them without depending on a UI package. `environmentOf(ctx)` returns the launcher's snapshot, or the inherited environment as the only layer — an SDK host or bare `cordis.yml` discovered no files, so its single layer really is what it was launched with, and the same trusted lookups keep working there unchanged. + +**`verify-config-source-ownership`** is a narrow tripwire for the ordinary single-line form of an `apiKey`/`baseURL`/`headers` environment inline in shipped Cordis configuration. Removing those inlines is what makes the deployment tier meaningful — with the shipped tree silent on `baseURL`, a present value means a human or deployment set it. Adapters own actual resolution; the gate makes no repository-wide claim about `process.env` access. + +## Consequences + +- The web credential form now takes effect against an older key in the user's `.env`; only a key exported in the launching shell still makes it read-only, and the diagnostic says so. +- A `.env` holding `DSH_*`, `PATH`, or a proxy variable fails the launch instead of being applied. Developers keeping switches in a repository `.env` move them to their shell — a deliberate, loud break. +- Composition is no longer overridable by a stale shell endpoint. It is still overridable by a user's stored `settings.yaml`, which is the settings seam's layering and not something this note changes; the product CLI offers no flag above it, so a deployment that must win against stored settings owns its own bin or loader tree. +- Not solved: the layers are still materialized into `process.env`, so ordinary project variables continue to reach child processes under the subprocess scrub. Bootstrap variables cannot come from a file at all; the environment package records the remaining subprocess reach as a limitation. +- Exa and Perplexity still capture their key at load time rather than through the credential seam. They no longer read raw `process.env` — they resolve through the trusted layers — but converting them to per-request seam resolution is separate work. + +## Alternatives considered + +**Unify credentials into the non-secret ordering, by who authored each source.** Attempted and abandoned: it reads well, but the settings seam already fixes composition *below* the user section, so "authored by deployment" is not a tier the seam can express — and moving `.credentials.yaml` above the launching environment would take away the one override CI, containers, and a per-run `DEEPSEEK_API_KEY=…` depend on. Two orderings that each say why they are shaped that way beat one that describes neither accurately. + +**Withhold routing and credentials from the invoking project until it is explicitly trusted.** Rejected as the product's stance: a checkout is trusted by default, with no prompt and no stored trust record. The residual is real and worth naming — cloning a repository that carries a `.env` naming another endpoint or key routes that session through it — and a later project-trust gate is where that gets addressed, not a rule that makes the common case require ceremony. + +**Audit an allowlist of `DSH_*` variables a `.env` may set.** Rejected: the list would have to be re-audited on every new switch, and the failure mode of forgetting is silent. Denying the namespace fails safe. + +**Rank a bootstrap variable below the process layer instead of rejecting it.** Rejected: `PATH` and `NODE_OPTIONS` have no meaningful "loser" behavior — a user who put one in a `.env` believes it applies, and silently ignoring it is the "my setting has no effect" failure this whole series exists to remove. + +**Build the snapshot as a three-package capability seam (`environment` / `environment-local` / consumers).** Rejected as premature: the producer runs before Cordis exists and there is no second implementation to select. The repository rule is to not split preemptively. + +**Stop materializing the layers into `process.env`.** Deferred, not rejected: it would keep project variables out of child processes entirely, but it silently breaks any user patch layer that reads `!!js process.env.X`. The snapshot is already the authority for everything the harness resolves, so this can land later without changing any ladder. diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md new file mode 100644 index 0000000000..c1567c6e82 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md @@ -0,0 +1,75 @@ +# Agent Note: 配置来源的统一顺序,以及被发现的文件不得决定什么 + +Status: implemented + +[English](2026-08-04-configuration-source-ownership.md) | 中文 + +## Problem + +`$DSH_HOME/.env` 刚刚[变成普通环境层](2026-08-04-credentials-yaml-and-user-environment-layer.md),这使得 harness 解析面向用户的值时面对的是一个压平的 `process.env`,再也说不清某个值来自哪里。由此产生三个后果。 + +通过 Web 页面存下的密钥仍然被用户自己 `.env` 里更旧的密钥遮蔽,因为凭据 provider 是拿「环境」与自己的文件比较,而现在环境包含了那个文件。这次拆分本该消除的迁移死路,只是换了个位置。 + +endpoint 可以被项目重定向。调用目录的 `.env` 和其他层一样会被物化,而 base URL 决定已解析的 API key 发往何处——于是写进模型可编辑工作区的 `DEEPSEEK_BASE_URL`,会把用户自己的凭据、以及承载其代码的提示词,一起发给该文件指定的任何主机。压平的视图无法把这件事和运维显式 export 同一个变量区分开。 + +而已交付组合里的 `!!js process.env.X` 让同一个值有两条抵达路径:一条经 entry config,一条经消费方各自的 ladder,胜负取决于层序而非这个值的语义。 + +## Decision + +**非密钥值走同一条顺序。** 每个本身不是凭据的可配置值都按同一顺序解析;各领域的差别只在于哪些层存在。 + +```text +explicit for this run per-operation override, CLI argument +> user settings settings.yaml +> composition profile bundles, user patch layers, --patch overlays +> this launch's shell inherited process environment +> discovered file /.env, then $DSH_HOME/.env +> defaults schema default, provider public default +``` + +自上而下依次是:本次运行的显式意图、用户 settings、composition、本次启动的 shell、被发现的文件、默认值。 + +settings 在 composition 之上,因为 [settings seam](2026-07-28-user-settings-seam.md) 就是这么做的:插件把自己的 cordis entry config 注册为 `base` 层,用户 section 叠加其上,而 seam 无法区分某个值是 profile 的 bundle 设的,还是它的用户 patch 层或某个 `--patch` overlay 设的——它们都以 entry config 的形式抵达。产品 CLI(命令行界面)没有高于已存 settings 的手段,因此需要把某字段钉死、不被用户已存 settings 覆盖的部署方,应自带 bin 或 loader 配置树,或者干脆不挂载 settings provider。composition 仍然高于环境,所以 shell 里陈旧的 `DEEPSEEK_BASE_URL` 无法改写已配置的 endpoint。 + +**凭据保留一条更窄的独立顺序**,本 Note 不把它并入上表: + +```text +inherited process environment (read-only, wins) +> $DSH_HOME/.credentials.yaml (provider-managed, writable) +> /.env +> $DSH_HOME/.env +``` + +继承环境优先,因为 `DEEPSEEK_API_KEY=… dsh`、CI 机密与容器 `-e` 是运维必须能按次施加、且无需改动机器状态的那一种覆盖;而它无法从进程内部修改,就必须*可见地*只读。配置本应只携带*引用*——解析哪个名字——该名字本身遵循上面的非密钥顺序。 + +**harness 被启动于其中的项目默认可信,且不做询问。** 一个 checkout 可以携带自己的 endpoint、自己的普通变量和自己的密钥;密钥排在受管存储之下,因此通过 Models 页存下的密钥绝不会被 checkout 中恰好带有的那一个顶掉。`EnvironmentSnapshot.getFrom(name, sources)` 仍然只搜索调用方点名的层,省略某层仍是拒绝而不是降级——该机制是为「某一层必须不可达」的那些决策准备的,而项目层今天不在其列。 + +**信任不延伸到改变 harness 本身。** `loadLayeredEnv` 会在加载时、且在物化任何内容之前,拒绝任何设置了下列变量的 `.env`:决定进程如何启动的(`PATH`、`SHELL`、`NODE_OPTIONS`、`LD_PRELOAD`)、决定运行时在执行被要求运行的程序之前先执行哪些代码的(`BASH_ENV`、`PERL5OPT`、`PYTHONSTARTUP`、`RUBYOPT`、`JAVA_TOOL_OPTIONS`、Git 的钩子命令)、决定模型可见指令从哪里加载的(整个 `DSH_*` 命名空间、`HOME`、`XDG_*`),以及决定网络如何抵达与信任的(proxy 与 CA 变量)。匹配不区分大小写,因此 `https_proxy` 不是绕过手段。 + +这条界线在于:它们无需任何用户动作、在任何一轮开始之前、且在权限策略与沙箱之外就生效。`DSH_PERMISSION_MODE` 会关掉让「信任项目」根本成立的那道审批,而 `BASH_ENV` 会在 bash 工具发出的每一次 `bash -c` 上执行项目指定的文件——项目的代码在 agent 的策略下运行是约定,项目改写那份策略不是。一个变量一个变量地枚举是必输的游戏,所以整个 `DSH_*` 命名空间被拒绝而不是只拒绝一份经审查的子集,也所以这份清单是按变量*做什么*而不是按哪个运行时拥有它来组织的。不设逃生门:逃生门本身总得从某处读取,而任何被发现的文件能设置的东西,就是那个漏洞本身。 + +**`packages/util/environment` 拥有该快照**,刻意做成 utility 而不是三包能力 seam。快照在 Cordis 启动前就冻结,并由启动器一次性注入,因此不存在需要切换的运行时实现;消费方需要的只是类型和纯函数,而 `util/` 包能提供这些且不必依赖 UI 包。`environmentOf(ctx)` 返回启动器的快照,或者返回只含继承环境的那一层——SDK 宿主或裸 `cordis.yml` 从未发现过任何文件,它那唯一一层确实就是它被启动时的环境,因此同样的受信查询在那里原样继续工作。 + +**`verify-config-source-ownership`** 仅作为一道窄门禁,检查已交付 Cordis 配置中从环境内联 `apiKey`/`baseURL`/`headers` 的普通单行写法。删除这些内联正是「部署层」得以成立的原因——已交付配置树对 `baseURL` 保持沉默之后,「有值」就意味着「人或部署设过它」。实际解析由适配器负责;该门禁不声称覆盖仓库范围内的 `process.env` 访问。 + +## Consequences + +- Web 凭据表单现在能压过用户 `.env` 里更旧的密钥;只有在启动 shell 里 export 的密钥才会让它变成只读,诊断信息也会这么说。 +- 含 `DSH_*`、`PATH` 或 proxy 变量的 `.env` 会导致启动失败而不是被应用。把开关放在仓库 `.env` 里的开发者需要改放到 shell——这是一次刻意且响亮的破坏。 +- composition 不再会被陈旧的 shell endpoint 覆盖。但它仍然会被用户已存的 `settings.yaml` 覆盖,这是 settings seam 的分层方式,本 Note 不改变它;产品 CLI 没有高于它的标志,因此需要压过已存 settings 的部署方要自带 bin 或 loader 配置树。 +- 未解决的:各层仍然会被物化进 `process.env`,因此普通项目变量继续按子进程清洗规则抵达子进程。bootstrap 变量完全不能来自文件;其余变量抵达子进程的限制记录在环境包中。 +- Exa 与 Perplexity 仍在加载时捕获密钥,而不是经凭据 seam。它们不再读裸 `process.env`——改为经受信层解析——但把它们改造成按请求经 seam 解析是另一件事。 + +## Alternatives considered + +**按「来源由谁书写」把凭据并入非密钥顺序。** 尝试过并放弃:它读起来很顺,但 settings seam 已经把 composition 固定在用户 section *之下*,因此「部署授权」根本不是该 seam 能表达的一层;而把 `.credentials.yaml` 抬到启动环境之上,会夺走 CI、容器和一次性 `DEEPSEEK_API_KEY=…` 所依赖的那唯一一种覆盖。两条各自说清自身形状成因的顺序,好过一条两边都描述不准的顺序。 + +**在项目被显式信任之前,不给它路由与凭据能力。** 作为产品立场被否决:checkout 默认可信,不询问,也不存储信任记录。残留风险是真实的、值得写明——克隆一个携带 `.env`、其中指定了另一个 endpoint 或密钥的仓库,会让该会话经由它——处理它的地方是日后的 project trust 门禁,而不是一条让常见情形都要走仪式的规则。 + +**审查出一份 `.env` 可设置的 `DSH_*` 白名单。** 否决:每新增一个开关都要重新审查,而遗漏的失败模式是静默的。拒绝整个命名空间是 fail safe。 + +**把 bootstrap 变量排在 process 层之下,而不是拒绝它。** 否决:`PATH` 和 `NODE_OPTIONS` 没有有意义的「输了之后」行为——把它写进 `.env` 的用户认为它生效,而静默忽略正是整个系列要消除的那种「我的设置没有效果」。 + +**把快照做成三包能力 seam(`environment` / `environment-local` / 消费方)。** 作为过早拆分而否决:生产方在 Cordis 存在之前就运行,也没有第二个实现需要选择。仓库规则是不要预先拆分。 + +**不再把各层物化进 `process.env`。** 延后而非否决:它能让项目变量彻底进不了子进程,但会静默破坏任何读 `!!js process.env.X` 的用户 patch 层。快照已经是 harness 解析一切的依据,因此这件事以后落地也不改变任何 ladder。 diff --git a/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.i18n.yaml new file mode 100644 index 0000000000..ba4444166c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.md +2026-08-04-credentials-yaml-and-user-environment-layer.md: 4ecbc41adf4e22c74ecf425c2caf628efdf7cf54 +2026-08-04-credentials-yaml-and-user-environment-layer.zh.md: 370179b442783f4f8ecd8e3badbd236a924f5f81 diff --git a/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.md b/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.md new file mode 100644 index 0000000000..4ecbc41adf --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.md @@ -0,0 +1,49 @@ +# Agent Note: Splitting the credential store from the user environment layer + +Status: implemented + +English | [中文](2026-08-04-credentials-yaml-and-user-environment-layer.zh.md) + +## Problem + +`$DSH_HOME/.env` carried two incompatible jobs. It was the writable secret store of [`credentials-local`](../../../../packages/credentials/credentials-local/README.md), so no surface could hoist it into `process.env` — hoisting would make every stored key read as a read-only launch override and block rotation from the Models page. But its name and dotenv format promise an environment file, so users put non-secrets in it and those values reached nothing: a `DEEPSEEK_BASE_URL` beside a working `DEEPSEEK_API_KEY` in the same file was silently ignored, because only the credential provider read the document and it addresses credential references alone. + +One file cannot be both a store the Harness owns and isolates and a layer that propagates by ordinary environment rules. The [request-level credential decision](2026-07-29-request-level-llm-config-credentials.md) chose dotenv to match peer products' home `.env`, and the conflation was not visible until a non-secret needed the same file. + +## Decision + +The two jobs become two files under the Harness home. + +**`.credentials.yaml` is the provider-managed store.** A strict YAML mapping of `CredentialRef` to non-empty string, with no `version` field and no wrapper level: + +```yaml +DEEPSEEK_API_KEY: sk-… +OPENAI_API_KEY: sk-… +``` + +Because the document holds credentials and nothing else, every deviation is a rejection rather than a skipped entry: a non-mapping root, a key that is not a POSIX identifier, a non-string value, an empty string, a duplicate key, and malformed YAML all fail — loud at boot and at a write, warn-and-keep-the-last-good-snapshot on a live reload. A silently ignored key would read as "the secret I stored has no effect", which is the failure this change exists to remove. The dotenv physical-line editor is replaced by a patch of the parsed document, so comments and untouched entries keep their formatting, any string value round-trips (multi-line included), and no entry is unwritable for want of a quoting style. The writer lock, read-modify-write, atomic `0600` write under a `0700` directory, exact-path watcher, content-equality self-write suppression, and quiescent disposal are unchanged. + +**`$DSH_HOME/.env` is the user's ordinary environment layer.** `loadLayeredEnv` in [`dsh-app-boot`](../../../../packages/ui/app-boot/README.md) parses the invoking directory's `.env` and then the Harness home's, giving `user < project < inherited` by materializing each accepted value only when the process has no higher-layer value. The Harness home is resolved from the inherited environment *before* either file loads, so a project `.env` cannot redirect which user document is read. Only the product CLI layers these files; SDK and example bins keep loading their own directory through `loadEnv` and must not inherit a developer's `$DSH_HOME`. + +Credential precedence distinguishes the inherited environment from discovered files: the inherited value stays the read-only per-run override, the managed document wins next, and project then user `.env` values remain writable fallbacks. A `set` therefore replaces a discovered-file value instead of rejecting a write that only the flattened `process.env` view would consider shadowed. + +There is no migration. A key already in `$DSH_HOME/.env` keeps resolving as a fallback, while the managed document wins as soon as the Models page stores that reference. + +## Consequences + +- Given up: a key left in `$DSH_HOME/.env` is materialized into `process.env`, so it reaches subprocesses under the [subprocess credential scrub](../../../../packages/subprocess/subprocess/README.md) rather than staying inside the provider. It remains a writable fallback below `.credentials.yaml`; a secret the Harness should own and isolate belongs in the managed document, which is never materialized. +- Bought: a non-secret in the user's `.env` finally takes effect, which was the original defect; the document format can reject what it cannot serve; and `0600` covers a file that holds only secrets instead of a file users are told to put ordinary configuration in. +- The `0600` the provider writes is also enforced on what it reads: on POSIX, a document with any group or other permission bit fails the launch before its contents are read, at boot and on every reload, and the diagnostic names the `chmod 600` repair. Windows has no mode to inspect — its ACLs are not expressible here — so the check is skipped rather than faked. +- The `0600` boundary still stops other OS users and not the model, unchanged by this split — the [provider README](../../../../packages/credentials/credentials-local/README.md) owns that limit and the keychain-provider deferral. + +## Alternatives considered + +**Keep one `$DSH_HOME/.env` and teach the CLI to hoist it.** Rejected: hoisting the store is precisely what makes stored keys unrotatable, which is why [app-boot documented the exclusion](../../../../packages/ui/app-boot/README.md) in the first place. The conflict is the file's two jobs, not the loader. + +**`$DSH_HOME/.credentials.env` — a second dotenv file.** Rejected: dotenv suits an environment layer but cannot express "a managed document indexed by credential reference". It cannot reject a non-string or an unaddressable key, and its line editor already refused values it could not quote, leaving entries readable but unwritable. + +**Add a `version` field to the new document.** Rejected: the format is one schema-constrained string mapping with no historical variant to discriminate. While the product is unreleased, changing the structure and rejecting the old one beats promising a migration protocol. + +**Migrate credential-shaped keys out of `$DSH_HOME/.env` on first run.** Rejected: migration code turns a short-lived format into a long-lived maintenance surface, and classifying which keys in an unknown file are secrets is exactly the ambiguity this split removes. The old file keeps working as environment, which is a truthful outcome rather than a silent one. + +**Drop the user `.env` layer entirely and keep only the inherited environment.** Rejected here as out of scope: it is a coherent design (fewer layers, one place per value), but it removes a workflow users have, and the layering question belongs with the deferred precedence decision rather than with this split. diff --git a/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.zh.md b/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.zh.md new file mode 100644 index 0000000000..370179b442 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.zh.md @@ -0,0 +1,49 @@ +# Agent Note: 把凭据存储与用户环境层拆开 + +Status: implemented + +[English](2026-08-04-credentials-yaml-and-user-environment-layer.md) | 中文 + +## Problem + +`$DSH_HOME/.env` 同时承担了两件互不相容的工作。它是 [`credentials-local`](../../../../packages/credentials/credentials-local/README.md) 的可写密钥存储,因此任何表层都不能把它提升进 `process.env`——一旦提升,每个已存密钥都会读作只读的启动时覆盖,从而阻断从 Models 页轮换密钥。但它的文件名和 dotenv 格式承诺的是一个环境文件,于是用户把非密钥值放进去,而那些值哪儿也到不了:同一个文件里,一个能用的 `DEEPSEEK_API_KEY` 旁边的 `DEEPSEEK_BASE_URL` 会被静默忽略,因为只有凭据 provider 读这份文档,而它只寻址凭据引用。 + +一个文件无法既是由 Harness 拥有并隔离的存储,又是按普通环境规则传播的层。[请求级凭据决策](2026-07-29-request-level-llm-config-credentials.md)当初选择 dotenv 是为了对齐同类产品的 home `.env`,而这种混同直到有非密钥值需要用同一个文件时才暴露出来。 + +## Decision + +两件工作在 Harness home 下拆成两个文件。 + +**`.credentials.yaml` 是 provider 管理的存储。** 一个从 `CredentialRef` 到非空字符串的严格 YAML mapping,没有 `version` 字段,也没有包装层: + +```yaml +DEEPSEEK_API_KEY: sk-… +OPENAI_API_KEY: sk-… +``` + +因为该文档只存放凭据、别无他物,任何偏离都是拒绝而不是跳过条目:非 mapping 的根、非 POSIX 标识符的键、非字符串值、空字符串、重复键以及格式错误的 YAML 全部失败——启动时和写入时响亮失败,运行期热重载则告警并保留最后可用快照。被静默忽略的键读起来就是「我存进去的密钥没有生效」,而这正是本次变更要消除的失败。dotenv 物理行编辑器被替换为对已解析文档打补丁,因此注释与未触及条目的排版都会保留,任何字符串值都能往返(含多行),也不会再有条目因为缺少可用引号样式而不可写。写锁、read-modify-write、`0700` 目录下的 `0600` 原子写、精确路径 watcher、按内容相等抑制自写、以及 dispose 时的完全停稳,均保持不变。 + +**`$DSH_HOME/.env` 是用户的普通环境层。** [`dsh-app-boot`](../../../../packages/ui/app-boot/README.md) 中的 `loadLayeredEnv` 先解析调用目录的 `.env`,再解析 Harness home 的,并且只在进程中没有更高层值时物化每个已接受的值,从而得到 `用户 < 项目 < 继承`。Harness home 在两个文件加载*之前*就从继承的环境解析完毕,因此项目 `.env` 无法改变读取哪份用户文档。只有产品 CLI(命令行界面)叠加这两个文件;SDK 与示例 bin 仍通过 `loadEnv` 加载各自的目录,绝不继承开发者的 `$DSH_HOME`。 + +凭据优先级会区分继承环境与发现的文件:继承值仍是只读的按次覆盖,其后是受管文档,再后是仍可写的项目与用户 `.env` 后备值。因此 `set` 会替换发现文件中的值,而不是因为扁平化的 `process.env` 视图认为写入会被遮蔽就加以拒绝。 + +不做迁移。已经放在 `$DSH_HOME/.env` 里的密钥会继续作为后备值解析;Models 页一旦存储该引用,受管文档就会优先。 + +## Consequences + +- 放弃的:留在 `$DSH_HOME/.env` 里的密钥会被物化进 `process.env`,因而会按[子进程凭据清洗](../../../../packages/subprocess/subprocess/README.md)的规则抵达子进程,而不再留在 provider 内部。它仍是 `.credentials.yaml` 之下的可写后备值;需要由 Harness 拥有并隔离的密钥属于受管文档,后者永不物化。 +- 换来的:用户 `.env` 里的非密钥值终于生效,这正是最初的缺陷;文档格式可以拒绝它无法承担的内容;`0600` 保护的是一个只存密钥的文件,而不是一个我们同时叫用户往里写普通配置的文件。 +- provider 写入时用的 `0600` 同样约束它读取的内容:在 POSIX 上,只要文档带有任何 group 或 other 权限位,就会在读取内容之前让启动失败——启动时与每次 reload 都检查,诊断里给出 `chmod 600` 的修复命令。Windows 没有可检查的 mode(其 ACL 无法在此表达),因此跳过该检查而不是伪造它。 +- `0600` 这条边界仍然只挡其他 OS 用户、挡不住模型,本次拆分未改变这一点——该限制及 keychain provider 的延后项归 [provider README](../../../../packages/credentials/credentials-local/README.md) 所有。 + +## Alternatives considered + +**保留单一的 `$DSH_HOME/.env`,让 CLI 去提升它。** 否决:提升存储本身正是让已存密钥无法轮换的原因,这也是 [app-boot 当初记录该排除](../../../../packages/ui/app-boot/README.md)的理由。冲突来自这个文件的两份工作,而不是加载器。 + +**`$DSH_HOME/.credentials.env`——第二个 dotenv 文件。** 否决:dotenv 适合环境层,却无法表达「一份按凭据引用索引的受管文档」。它无法拒绝非字符串或无法寻址的键,而且它的行编辑器本来就会拒绝无法加引号的值,留下可读却不可写的条目。 + +**给新文档加 `version` 字段。** 否决:该格式只有一个受 schema 约束的字符串 mapping,没有需要判别的历史变体。在未发布阶段,直接修改结构并拒绝旧结构,好过提前承诺迁移协议。 + +**首次运行时把形似凭据的键从 `$DSH_HOME/.env` 迁出。** 否决:迁移代码会把短命格式变成长期维护面,而判断一个未知文件里哪些键是密钥,恰恰是本次拆分要消除的歧义。旧文件继续作为环境工作,这是诚实的结果,而不是静默的结果。 + +**彻底取消用户 `.env` 层,只保留继承的环境。** 在此处否决为超出范围:它本身是自洽的设计(层次更少、每个值只有一处来源),但会移除用户已有的工作流,而分层问题属于那个被延后的优先级决策,不属于本次拆分。 diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml index eed6bee5f0..3e1d038514 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md -2026-08-05-profile-plugin-bundles.md: 11a8ac3d4005371ca9596ba237aaf42a8e770dee -2026-08-05-profile-plugin-bundles.zh.md: 0e9ebf657ccb9d05967d90a935b356acf287a24c +2026-08-05-profile-plugin-bundles.md: 8613e600ad633818abb4319e614230340c3b3876 +2026-08-05-profile-plugin-bundles.zh.md: 03a771364c4f3262801f28e68b96ec5de633e5ec diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md index 11a8ac3d40..8613e600ad 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md @@ -12,7 +12,9 @@ The `dsh` launcher hardcoded its compositions: `base.cordis.yml` + `web.cordis.y Everything becomes a **profile**: a directory `$DSH_HOME/profiles/` with a `package.json` (pnpm-managed out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list) and a user `cordis.patch.yml`. A **bundle** is an npm package declaring `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; the two manifest kinds live under distinct `dsh.profile` / `dsh.bundle` keys so a package.json states which role it plays. The tree composes over an empty root by applying each bundle's patch in `dsh.profile.bundles` order, then the user layer, then `--patch` overlays, then flag patches — one `applyEntryPatches` call, identical for boot, flag derivation, and `--dump-config`. -The shipped compositions became bundles: `@deepseek-ai/dsh-base` (the former base rows as one insert), `@deepseek-ai/dsh-web-app` (the former web overlay plus a runtime glue plugin that owns what used to be launcher code — frontend-dist resolution, the web-surface prompt section, bash runtime variables, the URL line), and `@deepseek-ai/dsh-headless` (a one-shot runner plugin over base + web-app). `dsh web` stays as an alias for `--profile web` carrying the Web flag family; `dsh --profile headless "task"` replaces `-p`; `dsh --config` is removed (its uses migrate to `--patch`). `dsh plugin --profile ` is a thin pnpm forwarder that initializes the profile and reconciles `dsh.profile.bundles` after `add`/`remove` (a bundle-less package warns and stays a plain dependency). +The shipped compositions became bundles: `@deepseek-ai/dsh-base` (the former base rows as one insert), `@deepseek-ai/dsh-web-app` (the former web overlay plus a runtime glue plugin that owns what used to be launcher code — frontend-dist resolution, the web-surface prompt section, bash runtime variables, the URL line), and `@deepseek-ai/dsh-headless` (a one-shot runner plugin over base + web-app). `dsh web` stays as an alias for `--profile web` carrying the Web flag family; `dsh run [--profile ] "task"` owns one-shot execution and defaults to the headless profile, while generic `dsh --profile ` boots without a task; `dsh --config` is removed (its uses migrate to `--patch`). `dsh plugin --profile ` is a thin pnpm forwarder that initializes the profile and reconciles `dsh.profile.bundles` after `add`/`remove` (a bundle-less package warns and stays a plain dependency). + +The [`dsh run` command decision](../feature/2026-08-08-dsh-run-headless-command.md) owns the one-shot grammar; this note owns the profile composition it selects. Resolution is two-anchored by construction: `dsh.profile.bundles` names resolve from the dsh installation first, then the profile directory — so in-box bundles always come from the same installation as the running `dsh` and pnpm never manages them — while bare plugin names in patch rows resolve through the profile directory's Node parent-walk into the maintained flat fallback `$DSH_HOME/profiles/node_modules` (one symlink per package the installation's app and bundles depend on, healed on every launch). diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md index 0e9ebf657c..03a771364c 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md @@ -12,7 +12,9 @@ Status: implemented 一切都变成 **profile**:即目录 `$DSH_HOME/profiles/`,其中包含一个 `package.json`(pnpm 管理的树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表)和一份用户 `cordis.patch.yml`。**组合包**(bundle)是声明了 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;两种 manifest 分别位于互不相同的 `dsh.profile` / `dsh.bundle` 键下,因此一份 package.json 能说明自己扮演哪种角色。配置树在空的根之上组合:按 `dsh.profile.bundles` 顺序应用每个组合包的 patch,然后是用户层,然后是 `--patch` overlay,最后是 flag patch——全部收敛为一次 `applyEntryPatches` 调用,启动、flag 派生与 `--dump-config` 使用完全相同的路径。 -已交付的组合改造成了组合包:`@deepseek-ai/dsh-base`(原有基础行合并为一次插入)、`@deepseek-ai/dsh-web-app`(原 web overlay,外加一个接管原启动器代码的运行时粘合插件——前端 dist 解析、web 表层提示词段落、bash 运行时变量、URL 行)、`@deepseek-ai/dsh-headless`(叠加在 base + web-app 之上的一次性 runner 插件)。`dsh web` 保留为携带 Web flag 家族的 `--profile web` 别名;`dsh --profile headless "task"` 取代 `-p`;`dsh --config` 被移除(其用途迁移到 `--patch`)。`dsh plugin --profile ` 是一层薄薄的 pnpm 转发器,负责初始化 profile,并在 `add`/`remove` 后调和 `dsh.profile.bundles`(没有组合包声明的包会给出警告,保持为普通依赖)。 +已交付的组合改造成了组合包:`@deepseek-ai/dsh-base`(原有基础行合并为一次插入)、`@deepseek-ai/dsh-web-app`(原 web overlay,外加一个接管原启动器代码的运行时粘合插件——前端 dist 解析、web 表层提示词段落、bash 运行时变量、URL 行)、`@deepseek-ai/dsh-headless`(叠加在 base + web-app 之上的一次性 runner 插件)。`dsh web` 保留为携带 Web flag 家族的 `--profile web` 别名;`dsh run [--profile ] "task"` 负责一次性执行,默认使用 headless profile,而通用的 `dsh --profile ` 只启动 profile,不携带任务;`dsh --config` 被移除(其用途迁移到 `--patch`)。`dsh plugin --profile ` 是一层薄薄的 pnpm 转发器,负责初始化 profile,并在 `add`/`remove` 后调和 `dsh.profile.bundles`(没有组合包声明的包会给出警告,保持为普通依赖)。 + +[`dsh run` 命令决策](../feature/2026-08-08-dsh-run-headless-command.md)负责一次性语法;本 Agent Note 负责该语法所选择的 profile 组合。 解析在构造上就是双锚点的:`dsh.profile.bundles` 中的名称先从 dsh 安装目录解析,再从 profile 目录解析——因此内置组合包始终来自与运行中 `dsh` 相同的安装,pnpm 从不管理它们——而 patch 行中的裸插件名称经 profile 目录的 Node 父目录逐级查找,落到受维护的扁平回退目录 `$DSH_HOME/profiles/node_modules`(安装目录的应用与各组合包所依赖的每个包各一个符号链接,每次启动时修复)。 diff --git a/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.i18n.yaml new file mode 100644 index 0000000000..05c9a1fdd2 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md +2026-08-08-bounded-session-persistence-write-batching.md: 46dc612492fa1bfa805f77f865f14b168f52776f +2026-08-08-bounded-session-persistence-write-batching.zh.md: 4aafdd652aad87cfe74a449428cdbbf13312d260 diff --git a/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md b/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md new file mode 100644 index 0000000000..46dc612492 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md @@ -0,0 +1,59 @@ +# Agent Note: Bounded session persistence write batching + +Status: implemented + +English | [中文](2026-08-08-bounded-session-persistence-write-batching.zh.md) + +## Problem + +Streaming responses can emit many `assistant/chunk` events in a short interval. The persistence coordinator previously scheduled a backend append as soon as an idle queue received one event. Events arriving while that append was active shared a follow-up batch, but a fast backend could still produce many small durable appends. Each JSONL append creates and syncs a Zstandard frame or raw suffix, while each SQLite append opens and commits a transaction and increments the session revision. + +Dropping chunk events or replacing them with assembled messages would reduce logical storage, but it would also change the event log, replay, sequence numbers, timestamps, and provenance. The write-amplification problem does not require that larger semantic change. + +### Quantified baseline + +Repository fixtures make the logical volume concrete. Decoding the current packed rows in [`goal-multi-turn-actions`](../../../../apps/web/tests/snapshots/goal-multi-turn-actions/session.jsonl) yields 2,098 events: 2,017 chunks (96.1%). Their unpacked JSONL lines occupy 332,647 of 379,225 event bytes (87.7%), while chunk packing reduces the committed file to 89,176 bytes and 182 storage rows, including 23 packed chunk rows. [`permission-policy-context`](../../../../apps/web/tests/snapshots/permission-policy-context/session.jsonl) yields 813 events: 746 chunks (91.8%) and 118,935 of 184,821 unpacked event bytes (64.4%); its packed file is 84,917 bytes and 123 storage rows, including 14 packed rows. These are tracked deterministic fixtures, not a production workload distribution, but they demonstrate why deleting chunks would reduce logical volume and why the existing packed-row layout already removes much of their JSON envelope cost. + +SQLite stores one row per logical event, so those same logical logs would retain 2,098 and 813 event rows respectively; batching does not change those counts. JSONL writes one Zstandard frame and fsync per durable append batch, while SQLite performs one transaction and one session-revision increment per batch. Runtime files do not record former append boundaries, so fixture row counts cannot honestly be presented as fsync or transaction counts. + +The scheduling bound is deterministic. With an immediately resolving sink, the former immediate controller could issue one append for each event arriving after the previous append completed. A controller test admits 20 events 10 ms apart: the 200 ms fixed window hands all 20 to one append. This is a 20-to-1 reduction for that cadence, not a universal ratio. Sparse events, mandatory flushes, slow prior writes, and different arrival rates produce different batch sizes. + +## Decision + +The first-party JSONL and SQLite plugins expose `writeBatchMaxDelayMs`, a positive integer no greater than Node's timer limit. Its default is `200`. Each plugin resolves the value at load and passes it to `PersistenceCoordinator`; the coordinator remains the single owner of batching behavior. + +Each live Session receives a package-private `SessionWriteBehind`. When its pending queue changes from empty to non-empty, the controller starts one fixed window. Later events join that batch without resetting the deadline: this is bounded coalescing, not debounce. When the deadline expires, the controller hands the complete pending prefix to the existing per-id serialization and `appendBatch` path. At most one write for a Session is active. Events admitted during that write form a new pending prefix with their own fixed deadline; if that deadline expires before the active write completes, the new prefix starts immediately after it. + +`writeBatchMaxDelayMs` bounds only the controller's intentional batching wait. Event-loop scheduling, initialization, an earlier serialized operation, and backend I/O can delay durable completion, so the option is not a hard fsync or crash-loss SLA. + +`session/flush` cancels any remaining wait and becomes a shared quiescence barrier. It drains the active attempt and every event admitted while the barrier is running before it resolves. Session retirement and backend disposal use that same barrier, so lifecycle teardown never waits for the batching timer. The checkpoint policy continues to place mandatory barriers before model requests and top-level tool side effects. + +Every event remains durable in its original order and shape. The controller copies each event on admission; no `assistant/chunk`, `seq`, `time`, surface metadata, or storage record is removed or rewritten. JSONL can therefore encode more events in one append frame, and SQLite can insert more event rows in one transaction, without changing either on-disk format or schema version. + +A failed background append restores its complete batch before any newer pending events, reports the failure once, and pauses automatic retry. The next newly admitted event opens a fresh fixed window; an explicit flush, retirement, or disposal retries immediately and surfaces a repeated failure to its caller. This avoids a timer-driven failure loop while preserving the existing recoverable flush boundary. + +This decision supersedes only the immediate scheduling cadence in [Collapse live persistence into one flush controller](../simplification/2026-07-23-collapse-persistence-flush-state.md). That note remains authoritative for one controller per live Session, retained failed batches, per-id serialization, retirement, and quiescent disposal. The [shared persistence coordinator](2026-06-18-shared-persistence-write-coordinator.md) remains the owner of the backend hook boundary. + +## Alternatives considered + +**Do not persist streaming chunk events.** Rejected here: it changes the event-sourced authority and recovery semantics rather than only physical write cadence. The existing [assembled-message rejection](../../rejected/simplification/2026-06-20-assembled-assistant-messages-only.md) remains the guardrail until a no-information-loss replacement defines replay, fork, provenance, sequence, and crash behavior independently. The [packed-row decision](2026-07-26-packed-chunk-rows-by-default.md) remains the complementary JSONL storage-size optimization. + +**Write only at semantic checkpoints.** Rejected: it maximizes batching but makes the ordinary crash-loss window depend on a separately mounted policy. Bounded background writes preserve progress between checkpoints while mandatory flushes keep their stronger ordering contract. + +**Debounce from the latest event.** Rejected: a continuously streaming response could postpone its first write indefinitely. A fixed window from the first pending event provides a real upper bound on intentional coalescing wait. + +**Implement timers separately in JSONL and SQLite.** Rejected: scheduling, failure retention, flush races, and teardown are backend-neutral lifecycle concerns. Duplicating them would reopen the drift that `PersistenceCoordinator` removed. + +## Verification + +The controller tests use a fake clock to prove the fixed, non-resetting 200 ms window; immediate and shared flush barriers; events admitted during a barrier; an over-budget tail behind an active write; ordered failure retention; paused automatic retry; and explicit retry of an overlapping background failure. Coordinator tests run the controller through Session notifications, retirement, collision reclamation, and teardown. The JSONL and SQLite suites retain their storage-format, transaction, recovery, and shared persistence-contract coverage. + +## Consequences + +High-frequency event bursts normally produce fewer durable append operations while preserving the exact logical event count. The reduction depends on arrival rate and backend latency: a burst inside one 200 ms window becomes one batch, while mandatory flushes and sparse events can still produce small batches. + +This decision does not cap pending event count or bytes behind a slow backend, and it does not reduce SQLite rows or the decoded logical log. A demonstrated memory bound or logical-retention policy would require its own failure and replay contract rather than another hidden timer rule. + +An admitted event can remain only in memory during the configured window, and then while scheduling or backend work is outstanding. Deployments choose a smaller value for a narrower ordinary loss window or a larger value for stronger batching. Explicit durability boundaries remain unchanged and bypass the wait. + +The new deep module gives the timer, active write, pending prefix, retry pause, and barrier one owner. `PersistenceCoordinator` retains initialization and identity serialization; backends retain only durable storage primitives. Neither `SESSION_FORMAT_VERSION` nor SQLite `SCHEMA_VERSION` changes. diff --git a/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.zh.md b/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.zh.md new file mode 100644 index 0000000000..4aafdd652a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.zh.md @@ -0,0 +1,59 @@ +# Agent Note: 为会话持久化写入批处理设定上界 + +Status: implemented + +[English](2026-08-08-bounded-session-persistence-write-batching.md) | 中文 + +## 问题 + +流式响应可能会在短时间内发出大量 `assistant/chunk` 事件。此前,只要空闲队列收到一个事件,持久化协调器就会立即调度一次后端追加。该追加仍在进行时到达的事件会共用一个后续批次,但如果后端速度很快,仍可能产生大量小规模的持久化追加。每次 JSONL 追加都会创建并同步一个 Zstandard 帧或原始格式后缀,而每次 SQLite 追加都会打开并提交一个事务,同时递增会话修订版本。 + +丢弃分片事件或用组装后的消息替代它们可以减少逻辑存储量,但也会改变事件日志、回放、序列号、时间戳和来源信息。写放大问题不要求采取这项语义变化更大的方案。 + +### 量化基线 + +仓库 fixture(测试前置数据)让逻辑数据量有了具体依据。对当前 [`goal-multi-turn-actions`](../../../../apps/web/tests/snapshots/goal-multi-turn-actions/session.jsonl) 中的打包行进行解码,可得到 2,098 个事件,其中 2,017 个是分片(96.1%)。这些分片解包后的 JSONL 行共 332,647 字节,占全部事件 379,225 字节的 87.7%;分片打包则把仓库中的已提交文件缩小到 89,176 字节和 182 个存储行,其中包括 23 个打包分片行。[`permission-policy-context`](../../../../apps/web/tests/snapshots/permission-policy-context/session.jsonl) 可得到 813 个事件,其中 746 个是分片(91.8%);这些分片解包后的 JSONL 行共 118,935 字节,占全部事件 184,821 字节的 64.4%。其打包文件为 84,917 字节,共 123 个存储行,其中包括 14 个打包行。这些是纳入版本控制的确定性 fixture,不代表生产工作负载分布;但它们说明了删除分片为何会降低逻辑数据量,也说明现有打包行布局已经消除了大量 JSON 包装开销。 + +SQLite 每个逻辑事件存储一行,因此同样的逻辑日志会分别保留 2,098 和 813 个事件行;批处理不会改变这些数量。JSONL 每个持久化追加批次会写入一个 Zstandard 帧并执行一次 fsync,SQLite 每个批次会执行一次事务并递增一次会话修订版本。运行时文件不记录原有追加边界,因此不能把 fixture 的存储行数当作 fsync 或事务次数。 + +调度上界是确定的。当写入端会立即完成每次操作时,原来的即时控制器可能对每个在前一次追加完成后到达的事件分别发起一次追加。一个控制器测试以 10 ms 的间隔接纳 20 个事件:200 ms 固定窗口会把全部 20 个事件交给一次追加。对于这种到达节奏,追加次数从 20 次降至 1 次,但这不是普遍比例。稀疏事件、强制 flush、较慢的前序写入和不同到达速率都会产生不同的批次大小。 + +## 决策 + +第一方 JSONL 与 SQLite 插件公开 `writeBatchMaxDelayMs`,其值必须是一个不超过 Node 计时器上限的正整数,默认值为 `200`。每个插件都会在加载时解析该值,再传给 `PersistenceCoordinator`;批处理行为仍只由协调器负责。 + +每个活跃的 Session 都有一个包私有 `SessionWriteBehind`。当其待处理队列从空变为非空时,控制器会启动一个固定窗口。后续事件加入该批次但不会重置截止时间:这属于有界合并,而不是防抖。截止时间到达后,控制器会把完整的待处理前缀交给现有的按 id 串行化机制,并沿 `appendBatch` 路径写入。同一 Session 同时最多有一个活跃写入。该写入期间接纳的事件会形成新的待处理前缀,并拥有自己的固定截止时间;如果该截止时间在活跃写入完成前到期,新前缀会在前一次写入完成后立即开始写入。 + +`writeBatchMaxDelayMs` 只限制控制器为批处理而主动等待的时间。事件循环调度、初始化、此前的串行化操作和后端 I/O 都可能延后持久化完成时间,因此该选项并不对 fsync 完成时间或崩溃数据丢失提供硬性 SLA。 + +`session/flush` 会取消剩余等待,并充当共享的完全停稳屏障。它会在完成前等待活跃写入尝试,并排空屏障运行期间接纳的每个事件。Session 退役与后端 dispose(资源释放)共用该屏障,因此生命周期 teardown 绝不会等待批处理计时器。检查点策略仍会在模型请求与顶层工具副作用之前设置强制屏障。 + +每个事件仍会按原有顺序和形态持久化。控制器会在接纳时复制每个事件;任何 `assistant/chunk`、`seq`、`time`、surface 元数据或存储记录都不会被删除或重写。因此,JSONL 可以在一个追加帧中编码更多事件,SQLite 可以在一个事务中插入更多事件行,而无需改变任一种磁盘格式或 schema 版本。 + +后台追加失败后,控制器会把完整批次恢复到所有较新的待处理事件之前,报告一次该失败,并暂停自动重试。随后新接纳的第一个事件会开启新的固定窗口;显式 flush、退役或 dispose 会立即重试,如果故障再次发生,则会向调用方暴露该故障。这可以避免计时器驱动的失败循环,同时保留现有可恢复的 flush 边界。 + +本决策仅取代[将实时持久化归并到单个刷新控制器](../simplification/2026-07-23-collapse-persistence-flush-state.md)中的即时调度节奏。对于每个活跃 Session 使用一个控制器、保留失败批次、按 id 串行化、退役和完全停稳的 dispose,原 Agent Note 仍是权威记录。后端钩子边界仍由[共享持久化协调器](2026-06-18-shared-persistence-write-coordinator.md)定义。 + +## 备选方案 + +**不持久化流式分片事件。** 这里不采纳:这会改变事件日志作为真源的地位及恢复语义,而不只是改变物理写入节奏。在无信息损失的替代方案独立定义回放、fork、来源信息、序列和崩溃行为之前,现有的[拒绝仅保留组装消息的决策](../../rejected/simplification/2026-06-20-assembled-assistant-messages-only.md)仍是防护规则。[打包行决策](2026-07-26-packed-chunk-rows-by-default.md)仍是配套的 JSONL 存储体积优化。 + +**仅在语义检查点写入。** 不采纳:此方案会最大化批处理,却让普通的崩溃丢失窗口取决于另行挂载的策略。有界后台写入会在检查点之间持久化进度,而强制 flush 继续提供更强的顺序契约。 + +**按最新事件重置防抖窗口。** 不采纳:持续不断的流式响应可能无限期推迟首次写入。由第一个待处理事件启动的固定窗口,为主动合并等待提供了真正的上界。 + +**分别在 JSONL 与 SQLite 中实现计时器。** 不采纳:调度、失败保留、flush 竞态和 teardown 都是后端无关的生命周期问题。重复实现这些机制会重新引入 `PersistenceCoordinator` 已消除的实现漂移。 + +## 验证 + +控制器测试使用假时钟证明固定且不会重置的 200 ms 窗口、即时且可共享的 flush 屏障、屏障运行期间接纳的事件、在活跃写入之后已超过窗口时限的尾部批次、有序保留失败批次、暂停自动重试,以及显式 flush 会重试与其重叠发生的后台失败。协调器测试会在 Session 通知、退役、冲突回收和 teardown 路径中验证该控制器。JSONL 与 SQLite 测试套件继续覆盖存储格式、事务、恢复和共享持久化契约。 + +## 后果 + +高频事件突发通常会减少持久化追加操作,同时保持逻辑事件数量完全不变。减少幅度取决于事件到达速率和后端延迟:位于同一 200 ms 窗口内的突发事件会成为一个批次,而强制 flush 与稀疏事件仍可能产生小批次。 + +本决策不会限制因后端缓慢而积压的待处理事件数量或字节数,也不会减少 SQLite 行数或解码后的逻辑日志。若要建立经过验证的内存上界或逻辑保留策略,就必须为其另行定义失败与回放契约,而不是再引入一条隐式计时器规则。 + +接纳后的事件在配置窗口内可能只存在于内存中,此后在等待调度或后端工作完成期间也可能如此。部署可以选择较小的值以缩短普通丢失窗口,也可以选择较大的值以加强批处理。显式持久性边界保持不变,并会绕过等待。 + +新的 deep module 让计时器、活跃写入、待处理前缀、重试暂停和屏障由一个所有方统一负责。`PersistenceCoordinator` 继续负责初始化和按标识串行化;后端仍只负责持久存储原语。`SESSION_FORMAT_VERSION` 与 SQLite `SCHEMA_VERSION` 均不变。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.i18n.yaml index bdb4632744..eda7b29acf 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.md -2026-07-20-error-cause-chain-diagnostics.md: 7de1f4f631cec90048ccc8eab7a6654560d84846 -2026-07-20-error-cause-chain-diagnostics.zh.md: 6ce642eb4a18a1a412fb63c3bb00042a921f4da3 +2026-07-20-error-cause-chain-diagnostics.md: 89edd4529cc16617fdca736dbbea4b170400d6bd +2026-07-20-error-cause-chain-diagnostics.zh.md: 673921c913a9f1fefe6f85303cf5952f8edbcc1d diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.md b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.md index 7de1f4f631..89edd4529c 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.md +++ b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.md @@ -34,4 +34,4 @@ A TUI run against an unreachable DeepSeek endpoint failed with the single notice - Durable `turn/end` error messages include cause detail. Existing snapshot fixtures replay byte-identically because their scripted errors carry no `cause` (for such errors `errorChain(err)` equals `err.message`); only unit-test expectation strings changed. A fixture recorded from a real transport failure would carry the chain. - `errorChain` renders `message` without the class name (`String(error)` rendered `Error: `), so a bare `TypeError` in a log line loses its type label unless its message is empty (then the name is the fallback). The chain detail was judged worth more than the class name at these seams. - `dsh-stdio` output for failed turns is no longer silent; piped consumers that parsed the transcript see new `[turn …]` lines. -- Remaining `renderThrown` copies in `dsh-subagent`, `dsh-workflow`, `dsh-skill`, `dsh-workflow-workerthread`, and `cli-demo` still render without the chain; they wrap package-local errors that carry their own messages, and can adopt `errorChain` when their diagnostics prove insufficient. +- Remaining `renderThrown` copies in `dsh-subagent`, `dsh-workflow`, `dsh-skill`, and `dsh-workflow-workerthread` still render without the chain; they wrap package-local errors that carry their own messages, and can adopt `errorChain` when their diagnostics prove insufficient. diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.zh.md b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.zh.md index 6ce642eb4a..673921c913 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.zh.md @@ -34,4 +34,4 @@ TUI 连接不可达的 DeepSeek 端点时,失败只显示一条 `fetch failed` - 持久化的 `turn/end` 错误消息包含 cause 细节。现有快照 fixture(测试前置数据)字节级一致地回放,因为其脚本化错误不带 `cause`(对这类错误 `errorChain(err)` 等于 `err.message`);只有单元测试的期望字符串有变化。从真实传输失败录制的 fixture 会携带完整链。 - `errorChain` 渲染 `message` 而不带类名(`String(error)` 会渲染 `Error: `),因此日志行里的裸 `TypeError` 会丢失类型标签,除非消息为空(此时回退到类名)。在这些诊断界面上,链细节被判断为比类名更有价值。 - `dsh-stdio` 对失败轮次的输出不再沉默;解析 transcript 的管道消费方会看到新的 `[turn …]` 行。 -- `dsh-subagent`、`dsh-workflow`、`dsh-skill`、`dsh-workflow-workerthread`、`cli-demo` 里剩余的 `renderThrown` 副本仍不渲染链;它们包装的是自带消息的包内错误,等诊断信息证明不足时再采用 `errorChain`。 +- `dsh-subagent`、`dsh-workflow`、`dsh-skill`、`dsh-workflow-workerthread` 里剩余的 `renderThrown` 副本仍不渲染链;它们包装的是自带消息的包内错误,等诊断信息证明不足时再采用 `errorChain`。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.i18n.yaml index b8ab8490d0..2ede6629d7 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md -2026-07-21-compaction-summary-prefix-cache-reuse.md: d05d25cfa7c3984ce0ce75c38068a91a0e07dfe8 -2026-07-21-compaction-summary-prefix-cache-reuse.zh.md: f31c33e680b3db60a6fb758a522addca63224fea +2026-07-21-compaction-summary-prefix-cache-reuse.md: fb6524846f837b783423b3ed0e043cd4e1f7849d +2026-07-21-compaction-summary-prefix-cache-reuse.zh.md: e6072313b7089b9dda14c298fcb94db786220501 diff --git a/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md b/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md index d05d25cfa7..fb6524846f 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md +++ b/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md @@ -29,7 +29,7 @@ Auto-compaction always anchors at the surface head, so the shadowed region is th - **Keep the summarizer system prompt but reuse the rest** — rejected: the system slot is the very first token region a provider caches on, so a distinct summarizer system prompt invalidates the whole prefix regardless of what follows. Only moving the directive off the front recovers the cache. - **Send only the shadowed region without the `system`/`tools` head** — rejected: a differently-headed sequence still diverges from the cached request at the first token, so it caches no better while losing the framing the summary needs. - **Omit `tools` from the summarization request** (the model never calls one) — rejected: tool schemas are part of the cached token sequence; omitting them misaligns every following token and defeats reuse. -- **A dedicated `assistant/chunk`-emitting summarization sub-session for snapshot replay** — out of scope here; the replay gap predates this change and is tracked in the [compaction-seam note](../feature/2026-06-18-compaction-capability-seam.md). +- **A dedicated `assistant/chunk`-emitting summarization sub-session for snapshot replay** — rejected: the durable `compact/summary` event records the successful local call's position and complete output, while its explicit call marker prevents replay from treating template or remote output as a local stream. ## Consequences @@ -42,4 +42,4 @@ Auto-compaction always anchors at the surface head, so the shadowed region is th - **Unit:** `compact-basic.spec.ts` asserts the auxiliary call forwards `system`/`tools`/leading messages and appends the compaction instruction as the final message, and that `compactRegion` replays the latest routed header prefix. Existing content assertions read the summarizer input through the replayed messages rather than a transcript string. - **Loop:** `compact-loop-repro.spec.ts` classifies the summarization request by the compaction instruction in its trailing user message, and the overflow-recovery tests continue to pin conversation-vs-summary request counts across the real loop. -- **Snapshot gap unchanged:** the summarization call still emits no `assistant/chunk` events, so it remains outside keyless replay; the pre-existing gap is owned by the [compaction-seam note](../feature/2026-06-18-compaction-capability-seam.md). +- **Snapshot:** keyless replay reconstructs one canonical successful stream from a marked `compact/summary`; the [compaction-seam note](../feature/2026-06-18-compaction-capability-seam.md) owns the durable marker contract. diff --git a/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.zh.md b/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.zh.md index f31c33e680..e6072313b7 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.zh.md @@ -29,7 +29,7 @@ Status: implemented - **保留摘要器系统提示词但复用其余部分**——否决:system 槽位正是提供方最先做缓存的 token 区域,因此一个不同的摘要器系统提示词无论后面跟着什么都会使整个前缀失效。只有把指令移离前端才能恢复缓存。 - **只发送被遮蔽区域而不带 `system`/`tools` 头部**——否决:头部不同的序列在第一个 token 处仍然与已缓存请求分叉,因此缓存效果并不更好,反而丢失了摘要所需的框架。 - **从摘要请求中省略 `tools`**(模型从不调用任何工具)——否决:工具 schema 是已缓存 token 序列的一部分;省略它们会让后续每个 token 失去对齐,破坏复用。 -- **为快照回放专门建立一个发出 `assistant/chunk` 的摘要子会话**——此处超出范围;该回放缺口早于本次改动,记录在 [compaction-seam Agent Note](../feature/2026-06-18-compaction-capability-seam.md) 中。 +- **为快照回放专门建立一个发出 `assistant/chunk` 的摘要子会话**——否决:持久的 `compact/summary` 事件会记录成功本地调用的位置和完整输出,而显式调用标记可防止回放把模板或远程输出当作本地流。 ## 后果 @@ -42,4 +42,4 @@ Status: implemented - **单元:** `compact-basic.spec.ts` 断言辅助调用转发 `system`/`tools`/前导消息,并把压缩指令作为最后一条消息追加,且 `compactRegion` 回放最新的已路由 header 前缀。现有的内容断言通过回放的消息而非 transcript 字符串来读取摘要器输入。 - **循环:** `compact-loop-repro.spec.ts` 依据摘要请求尾部 user 消息中的压缩指令对其分类,溢出恢复测试则继续在真实循环中固定对话请求与摘要请求的数量。 -- **快照缺口未变:** 摘要调用仍然不发出 `assistant/chunk` 事件,因此它仍处于无密钥回放之外;这一既有缺口归 [compaction-seam Agent Note](../feature/2026-06-18-compaction-capability-seam.md) 所有。 +- **快照:** 无密钥回放会从带标记的 `compact/summary` 重建一条规范成功流;[compaction-seam Agent Note](../feature/2026-06-18-compaction-capability-seam.md) 负责持久标记契约。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.i18n.yaml index 8c3c24a722..0820c01b7f 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.md -2026-07-27-stable-snapshot-refresh-volatiles.md: e2e951cd9f78b319a701a3e60afba48786633f03 -2026-07-27-stable-snapshot-refresh-volatiles.zh.md: 8ba35f4bbc2a4b408a01e11f09bd767860964875 +2026-07-27-stable-snapshot-refresh-volatiles.md: c3eeeca01a7820b5f410bd895de998e944e58eb2 +2026-07-27-stable-snapshot-refresh-volatiles.zh.md: 388b67c67052074fa7423eae294e00b4122b2fe8 diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.md b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.md index e2e951cd9f..c3eeeca01a 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.md +++ b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.md @@ -8,9 +8,13 @@ English | [中文](2026-07-27-stable-snapshot-refresh-volatiles.zh.md) ACP snapshot comparison normalizes generated UUIDs, cwd aliases, spill locators, embedded event times, and omitted-byte counts, but refresh write-back persisted the fresh raw values. A behaviorally unchanged refresh therefore rewrote fixtures with new randomness or host-specific path spellings even though the comparison contract considered both logs equal. +Message identity needs a weaker structural precondition than aligned records: an unrelated log event can break record alignment while an inherited message's identity-free value remains unchanged across parent and child logs. Record mode also begins with freshly minted message UUIDs when it replaces an existing fixture. + ## Decision -Refresh write-back uses `normalizeSessionLog` as its sole volatile-value authority. It normalizes the original harvested records with the fresh run's ids, cwd, and every cwd alias, while normalizing fixture records with the fixture header context; literal replacements affect only the raw values being written. After existing record alignment, it recursively compares fresh and existing leaves through those normalized records: normalized-equivalent leaves retain the existing raw value, while normalized-distinct leaves retain the fresh semantic value. +Before record or refresh writes session fixtures, the shared snapshot support passes fixture-ready logs to one structural message-ID owner. It recognizes surface carriers through the session package's authoritative surface-type predicate and the correlated queued copies in `agent/inbox/spliced`, fingerprints every complete message with its top-level `id` removed, and records every ID-to-fingerprint edge across all parent/child logs. It reuses an existing UUID only when both its ID and fingerprint have degree one in the fresh and existing graphs, then rewrites only validated message `id` fields in those carriers. Repeated inherited occurrences with the same ID remain one candidate, while new, changed, duplicate-content, malformed, and conflicting messages keep their fresh IDs. ACP, JSON-RPC, and Web recorders run this pass after header scrubbing and cwd tokenization, so fixture spellings rather than raw host paths determine identity. + +Refresh write-back uses `normalizeSessionLog` as its volatile-value authority for aligned leaves. It normalizes the original harvested records with the fresh run's ids, cwd, and every cwd alias, while normalizing fixture records with the fixture header context; literal replacements are limited to fresh-run session IDs, cwd values, and spill paths. After existing record alignment, it recursively compares fresh and existing leaves through those normalized records: normalized-equivalent leaves retain the existing raw value, while normalized-distinct leaves retain the fresh semantic value. Complete message IDs in surface or inbox carriers are excluded from this path so positional reuse and structural reuse cannot assign the same committed UUID independently. Before reuse, the complete logical-record layout must align, apart from the existing packed-chunk and inserted-title equivalences. Normalized-equivalent changed strings form a log-wide bijection: one fresh string maps to exactly one existing string and vice versa, so repeated IDs remain correlated across records. An unexplained record mismatch or conflicting mapping disables normalized string reuse for that log. @@ -26,6 +30,6 @@ Object fields align by key. Array elements align only when all corresponding arr ## Consequences -Repeated refreshes no longer rewrite aligned fixture values solely because the normalizer classifies them as volatile, and new volatile categories added to the normalizer automatically inherit the write-back behavior. Structural ambiguity remains conservative: unmatched records, conflicting string mappings, resized arrays, and strings containing both semantic and volatile changes use fresh values rather than risk reusing misaligned data. +Record and refresh no longer rewrite an unchanged unique message UUID solely because another event changed the surrounding record layout, regardless of whether ACP, JSON-RPC, or Web owns the recording. Repeated refreshes also retain aligned fixture values that the normalizer classifies as volatile, and new volatile categories added to the normalizer automatically inherit that write-back behavior. Structural ambiguity remains conservative: unmatched records, conflicting string mappings, resized arrays, strings containing both semantic and volatile changes, malformed messages, and any message graph with a non-unique ID or fingerprint use fresh values rather than risk reusing misaligned data. -Focused unit coverage pins recursive object/array behavior, correlated IDs, ambiguous-layout fallback, conflicting mappings, fresh cwd aliases, volatile strings, and fresh semantic fields. Keyless refresh coverage proves approval UUIDs, cwd aliases, spill paths, and event-read volatility leave their committed fixtures byte-identical. +Focused unit coverage pins all authoritative surface-message shapes, durable inbox/surface correlation, scenario-wide parent/child correlation, cwd-bearing fixture-ready matching, unrelated event insertion, malformed-message isolation, both-axis graph ambiguity, single-owner write-back, recursive object/array behavior, conflicting mappings, fresh cwd aliases, volatile strings, and fresh semantic fields. Keyless refresh coverage proves approval UUIDs, cwd aliases, spill paths, and event-read volatility leave their committed fixtures byte-identical. diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.zh.md b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.zh.md index 8ba35f4bbc..388b67c670 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.zh.md @@ -8,9 +8,13 @@ Status: implemented ACP(Agent Client Protocol)快照比较会归一化生成的 UUID、cwd 别名、spill locator、嵌入的事件时间和省略字节数,但刷新写回会持久化本次生成的原始值。因此,即使比较契约将两份日志视为相等,一次行为未发生变化的刷新仍会用新的随机值或宿主特有的路径写法改写 fixture(测试前置数据)。 +消息身份所需的结构前提比记录对齐更弱:无关的日志事件可能破坏记录对齐,但继承而来的消息去除身份后的值在父级和子级日志之间仍保持不变。录制模式在替换现有 fixture 时也会从新生成的消息 UUID 开始。 + ## 决策 -刷新写回以 `normalizeSessionLog` 作为易变值的唯一判定依据。系统使用本次运行的 id、cwd 及全部 cwd 别名归一化原始收集记录,并使用 fixture 头部上下文归一化 fixture 记录;字面量替换只影响要写入的原始值。现有记录完成对齐后,系统基于这些归一化记录,递归比较本次生成记录与现有记录的叶节点:归一化后等价的叶节点保留现有原始值,归一化后不同的叶节点则保留本次生成的语义值。 +在录制或刷新写入会话 fixture 前,共享快照支持层会将可写入 fixture 的日志交给一个负责结构化处理消息 ID 的组件。该组件通过会话包的权威 surface 类型谓词识别 surface 载体,并识别 `agent/inbox/spliced` 中与这些载体关联的已排队消息副本;随后移除每条完整消息的顶层 `id` 并计算指纹,同时记录所有父级/子级日志中每条 ID 与指纹之间的关联边。仅当该 ID 与指纹在本次生成图和现有图中的度均为 1 时,才会复用现有 UUID,随后仅改写这些载体中通过验证的消息 `id` 字段。具有相同 ID、重复出现的继承消息仍算作一个候选项;新增、发生变化、内容重复、格式错误和存在冲突的消息则保留本次生成的 ID。ACP、JSON-RPC 和 Web 录制器会在擦除 header 并对 cwd 进行 token 化后执行这一步,因此消息身份取决于 fixture 中的写法,而非宿主机原始路径。 + +刷新写回以 `normalizeSessionLog` 作为已对齐叶值的易变值判定依据。系统使用本次运行的 id、cwd 及全部 cwd 别名归一化原始收集记录,并使用 fixture 头部上下文归一化 fixture 记录;字面量替换仅限于本次运行生成的会话 ID、cwd 值和 spill 路径。现有记录完成对齐后,系统基于这些归一化记录,递归比较本次生成记录与现有记录的叶节点:归一化后等价的叶节点保留现有原始值,归一化后不同的叶节点则保留本次生成的语义值。surface 或 inbox 载体中的完整消息 ID 不参与这一路径,以免按位置复用与结构复用各自独立分配同一个已提交 UUID。 复用前必须确保完整逻辑记录布局对齐,现有的打包分片与插入标题等价情形除外。归一化后等价但发生变化的字符串在整份日志范围内形成双射:一个本次生成的字符串只映射到一个现有字符串,反向亦然,因此跨记录重复出现的 ID 仍保持关联。出现无法解释的记录不匹配或映射冲突时,该日志会停用归一化字符串复用。 @@ -26,6 +30,6 @@ ACP(Agent Client Protocol)快照比较会归一化生成的 UUID、cwd 别 ## 后果 -重复刷新不再仅仅因为规范化器将已对齐的 fixture 值归类为易变值,就改写这些值;以后加入规范化器的新易变值类别也会自动继承该写回行为。结构有歧义时仍采取保守策略:记录无法匹配、字符串映射冲突、数组尺寸发生变化,或字符串同时包含语义变化与易变变化时,均使用本次生成的值,避免冒险复用未对齐的数据。 +录制和刷新不再仅仅因为另一个事件改变了周边记录布局,就改写未变化且唯一的消息 UUID,无论该录制由 ACP、JSON-RPC 还是 Web 负责。重复刷新也会保留规范化器归类为易变值的已对齐 fixture 值;以后加入规范化器的新易变值类别也会自动继承该写回行为。结构有歧义时仍采取保守策略:记录无法匹配、字符串映射冲突、数组尺寸发生变化、字符串同时包含语义变化与易变变化、消息格式错误,或消息图中的 ID 或指纹不唯一时,均使用本次生成的值,避免冒险复用未对齐的数据。 -聚焦的单元测试固定了递归处理对象与数组的行为、关联 ID、有歧义布局时的回退、映射冲突、本次运行的 cwd 别名、易变字符串以及本次生成的语义字段。无密钥刷新测试证明,审批 UUID、cwd 别名、spill 路径和事件读取中的易变值不会改变已提交 fixture 的任何字节。 +聚焦的单元测试固定了会话包权威谓词识别的所有 surface 消息形态、持久 inbox/surface 关联、场景范围内的父级/子级消息关联、带 cwd 的可写入 fixture 消息匹配、无关事件插入、格式错误消息隔离、消息图在 ID 与指纹两条轴上的歧义、由单一处理方负责的写回、递归处理对象与数组的行为、映射冲突、本次运行的 cwd 别名、易变字符串以及本次生成的语义字段。无密钥刷新测试证明,审批 UUID、cwd 别名、spill 路径和事件读取中的易变值不会改变已提交 fixture 的任何字节。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.i18n.yaml index fb5ee5debc..b1bdfe74c2 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md -2026-07-29-pnpm-setup-runner-isolation.md: 743535d0394cbea0374c412ba6968910ce858de4 -2026-07-29-pnpm-setup-runner-isolation.zh.md: 1e51070f88dead17b9d3f5625e337c558786aba2 +2026-07-29-pnpm-setup-runner-isolation.md: 74b672b3f90ea445ad1a8e283a5904056059b2f8 +2026-07-29-pnpm-setup-runner-isolation.zh.md: 32c667dc09e561504e8e053bf2a338ed2190d9e8 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md index 743535d039..74b672b3f9 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md @@ -6,7 +6,7 @@ English | [中文](2026-07-29-pnpm-setup-runner-isolation.zh.md) ## Problem -`pnpm/action-setup@v4` defaults its install destination to `~/setup-pnpm` and replaces that directory during setup. The self-hosted CI failover runs six GitHub Actions runner services under one VM user, so concurrent jobs shared the same destination. In [run 30375670773](https://github.com/deepseek-harness/deepseek-harness/actions/runs/30375670773), three jobs entered pnpm setup within 73 milliseconds; one setup removed another process's current working directory and two jobs failed in Node's `uv_cwd` initialization. A retry on another runner passed, making the failure timing-dependent rather than a repository-test regression. +`pnpm/action-setup@v4` defaults its install destination to `~/setup-pnpm` and replaces that directory during setup. The self-hosted CI failover runs six GitHub Actions runner services under one VM user, so concurrent jobs shared the same destination. In the reproducing run, three jobs entered pnpm setup within 73 milliseconds; one setup removed another process's current working directory and two jobs failed in Node's `uv_cwd` initialization. A retry on another runner passed, making the failure timing-dependent rather than a repository-test regression. ## Decision diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.zh.md b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.zh.md index 1e51070f88..32c667dc09 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -`pnpm/action-setup@v4` 的安装目标目录默认为 `~/setup-pnpm`,并会在设置期间替换该目录。自托管 CI 故障切换在同一个 VM 用户下运行六个 GitHub Actions runner 服务,因此并发作业会共用同一目标目录。在 [run 30375670773](https://github.com/deepseek-harness/deepseek-harness/actions/runs/30375670773) 中,三个作业在 73 毫秒内进入 pnpm 设置;其中一个设置过程删除了另一个进程的当前工作目录,导致两个作业在 Node 的 `uv_cwd` 初始化阶段失败。换到另一台 runner 重试后通过,说明该故障取决于时序,并非仓库测试回归。 +`pnpm/action-setup@v4` 的安装目标目录默认为 `~/setup-pnpm`,并会在设置期间替换该目录。自托管 CI 故障切换在同一个 VM 用户下运行六个 GitHub Actions runner 服务,因此并发作业会共用同一目标目录。在复现运行中,三个作业在 73 毫秒内进入 pnpm 设置;其中一个设置过程删除了另一个进程的当前工作目录,导致两个作业在 Node 的 `uv_cwd` 初始化阶段失败。换到另一台 runner 重试后通过,说明该故障取决于时序,并非仓库测试回归。 ## 决策 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml index 26108d6850..cede7ea5d9 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md -2026-07-30-web-transcript-log-ordered-projection.md: 558d60cf6f6638c3e776396dd754d31b95819b28 -2026-07-30-web-transcript-log-ordered-projection.zh.md: 2eb216fd1970700fb54a2aa17ec5ce515869e5b0 +2026-07-30-web-transcript-log-ordered-projection.md: 3b7aaeb1178ff79e38a1b9646a9dc78efaeae48b +2026-07-30-web-transcript-log-ordered-projection.zh.md: acd198b6d3f3c5d57e233e09ee66f5d151e4f8f1 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md index 558d60cf6f..3b7aaeb117 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md @@ -18,9 +18,11 @@ Node order is seq-monotonic by construction, and three things follow. The log-on `foldDegraded` is gone from `ConversationSnapshot`, and with it the padding sentinels, the `baseSeq` arithmetic they needed, and `degradedSeqs()`. They existed only to satisfy the core fold's `seq === index` assertion and to survive its throw; the fold they describe is no longer run. Deleting the flag is part of the fix, not cleanup after it — `degradedSeqs()` was already almost the log-ordered projection, reached after a thrown error instead of intended. -The marker's summary text comes from the checkpoint's own `compact/summary` provenance, never from the framed checkpoint payload, which is an instruction envelope written for the model. A window cut that left the provenance outside makes the row non-expandable rather than empty, the same soft-fall as a call-less tool result, and a later page supplying the provenance resolves the text. +The marker's summary text, replaced-item count, and estimated shadowed-token count come from the checkpoint's own `compact/summary` provenance, never from the framed checkpoint payload, which is an instruction envelope written for the model. A window cut that left the provenance outside makes those fields unavailable, the same soft-fall as a call-less tool result, and a later page supplying the provenance resolves them. -No persisted event, RPC envelope, compaction transaction, or model-visible surface changed, and no migration is required. +The [manual compaction command](../feature/2026-07-30-queued-manual-compaction.md) returns the summary event's seq as the successful `CommandResult.sourceEventSeq`, and `command/done` persists that optional reference. Chat pairs only a successful named `/compact` command whose reference equals exactly one loaded `CompactionSummaryNode.summaryEventSeq`. The running command first renders `compact · Compacting context…`; after the checkpoint lands, the same React key renders one collapsed `compact` disclosure at the checkpoint's flow position with the count and token estimate. Input rejection, no compactable history, cancellation, and failure remain generic command rows with complete handler-authored text. Automatic compaction has no command reference and keeps the standalone context-compacted marker. + +The explicit event reference matters because manual compaction permits durable context injection while its asynchronous summary is running: command and checkpoint rows are not guaranteed to be adjacent. The command lifecycle event gains one optional field, but the compaction transaction, RPC envelope, and model-visible surface do not change; pre-release persisted logs without the field keep the former two-row soft-fall and require no migration. ## Recognizing a checkpoint: one declaration, pinned at compile time @@ -57,6 +59,10 @@ The unmerged manual-compaction-queueing branch fixes the same interleaving bug b **Keep `foldDegraded` as a defensive flag.** Rejected: it described a specific failure of a fold that no longer runs. A flag no consumer can act on, reachable only through a `console.error`, is a false contract. +**Pair the nearest `/compact` row with the next checkpoint.** Rejected: context injection may land between them, and concurrent or malformed lifecycle records must degrade without stealing another checkpoint. The command result instead names the authoritative summary event, and ambiguous references pair nothing. + +**Parse the English settlement text for item and token counts.** Rejected: handler copy is presentation text, not a stable data contract. The marker reads the structured `compact/summary` payload already owning both values. + ## Consequences Compaction no longer erases web history; a session compacted several times shows one marker per landed compaction, in log order, and the same window renders identically live and after a cold resume. The pagination hole is closed by construction rather than defended against, and `ConversationSnapshot` loses a published field, which touched thirteen files. @@ -65,8 +71,8 @@ Compaction no longer erases web history; a session compacted several times shows The performance contract is unchanged and now simpler to state: one append materializes one node, an event that changes no node keeps the previous array reference — so a chunk storm costs nothing and `nodes()` is not even recomputed — and unchanged nodes keep their object identity. The window still grows with session length rather than with the surface, which is the trade the fix exists to make; a compaction used to bound the projection for exactly the long sessions compaction serves. -The web e2e scenario now seeds a real compaction transaction over its recorded turn, so the aria golden pins both halves of the fix through the real host and a real browser: the recorded prompt and full tool output are still on screen, and one marker sits after them. The seed recording itself is untouched and stays model-authentic — replay derives the compacted turn from the recording's own surface. +The web e2e scenario now seeds a real manual command lifecycle around a compaction transaction over its recorded turn, so the aria golden pins the complete behavior through the real host and a real browser: the recorded prompt and full tool output are still on screen, exactly one `compact` row reports scale after them, and its disclosure opens the exact summary. The seed recording itself is untouched and stays model-authentic — replay derives the manual compaction from the recording's own surface. ## Deferred -The terminal's [archived compaction progress decision](../../archived/feature/2026-07-30-compaction-progress-visibility.md) uses the live standalone bracket to drive a one-cell indicator and does not change this browser projection. The marker still carries no **scale**: the checkpoint's `sourceEventSeqs` hold the shadowed count, so a separately justified count or range can be added without coupling it to progress. +The terminal's [archived compaction progress decision](../../archived/feature/2026-07-30-compaction-progress-visibility.md) uses the live standalone bracket to drive a one-cell indicator and does not change this browser projection. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md index 2eb216fd19..acd198b6d3 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md @@ -18,9 +18,11 @@ surface 顺序还让另外两个问题成为结构性的。一次替换之后它 `foldDegraded` 从 `ConversationSnapshot` 消失,随之消失的是哨兵填充、它们所需的 `baseSeq` 算术,以及 `degradedSeqs()`。它们的存在只为满足核心 fold 的 `seq === index` 断言并在其抛错时存活;它们所描述的 fold 已不再运行。删除该标志是修复的一部分,而非修复之后的清理——`degradedSeqs()` 本身已几乎就是按日志顺序的投影,只是作为抛错后的落点而非本意到达。 -标记的摘要文本来自检查点自己的 `compact/summary` 溯源,绝不取自成框的检查点载荷——那是为模型撰写的指令信封。窗口切分把溯源留在窗口外时该行不可展开而非空白,与无调用的工具结果同一种软退让;后续补上溯源的分页会解析出文本。 +标记的摘要文本、被替换条目数量和估算的被遮蔽 token 数量都来自检查点自己的 `compact/summary` 溯源,绝不取自成框的检查点载荷——那是为模型撰写的指令信封。窗口切分把溯源留在窗口外时这些字段不可用,与无调用的工具结果同一种软退让;后续补上溯源的分页会解析出它们。 -没有任何持久化事件、RPC 信封、压缩事务或模型可见 surface 发生变化,也不需要迁移。 +[手动压缩命令](../feature/2026-07-30-queued-manual-compaction.md)会把摘要事件的 seq 作为成功结果的 `CommandResult.sourceEventSeq` 返回,`command/done` 则持久化这项可选引用。Chat 只会配对成功且名称为 `/compact`、其引用恰好等于唯一一个已加载 `CompactionSummaryNode.summaryEventSeq` 的命令。运行中的命令先渲染为 `compact · Compacting context…`;检查点落地后,同一个 React key 会在检查点的消息流位置渲染一条收起的 `compact` 展开项,并显示条目数量和 token 估算值。输入被拒绝、没有可压缩历史、取消和失败时仍使用通用命令行,并保留处理器撰写的完整文本。自动压缩没有命令引用,继续使用独立的上下文已压缩标记。 + +显式事件引用之所以重要,是因为手动压缩允许在异步摘要运行期间注入持久上下文:命令行与检查点行不保证相邻。命令生命周期事件增加一个可选字段,但压缩事务、RPC 信封和模型可见 surface 均不变化;不含该字段的预发布持久日志继续采用原先的两行软退让,无须迁移。 ## 识别检查点:同一份声明,在编译期钉住 @@ -57,6 +59,10 @@ const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact' **把 `foldDegraded` 留作一个防御性标志。** 已拒绝:它描述的是一个已不再运行的 fold 的特定失败。一个消费方无法据以行动、只能通过 `console.error` 到达的标志,是一份虚假契约。 +**把最近的 `/compact` 行与下一个检查点配对。** 已拒绝:两者之间可能落入上下文注入,并发或格式异常的生命周期记录也必须降级而不误取其他检查点。命令结果则指明权威摘要事件;引用存在歧义时不配对任何内容。 + +**解析英文结算文本中的条目数量和 token 数量。** 已拒绝:处理器文案是呈现文本,而非稳定的数据契约。标记读取本已持有这两个值的结构化 `compact/summary` 载荷。 + ## Consequences 压缩不再抹掉 Web 历史;一个被压缩多次的会话按日志顺序显示每次落地压缩一个标记,而同一窗口在实时与冷恢复之后渲染完全相同。分页缺口是被构造性闭合而非被防御,`ConversationSnapshot` 少了一个已发布字段,这触及十三个文件。 @@ -65,8 +71,8 @@ const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact' 性能契约未变,且现在更易表述:一次追加物化一个节点,不改变任何节点的事件保持上一次的数组引用——因此分片风暴零成本、`nodes()` 甚至不会重算——未变化的节点保持其对象标识。窗口仍随会话长度而非随 surface 增长,这正是本修复存在所要做的交换;一次压缩过去恰好为压缩所服务的长会话限制了投影规模。 -Web e2e 场景现在在它录制的那一轮之上播种一次真实的压缩事务,因此 aria 基准经真实宿主与真实浏览器钉住修复的两半:录制的提问与完整工具输出仍在屏幕上,其后坐着一个标记。录制本身未被触碰、保持模型真实——回放从录制自身的 surface 派生出被压缩的那一轮。 +Web e2e 场景现在围绕它录制的那一轮上的压缩事务播种一次真实的手动命令生命周期,因此 aria 基准经真实宿主与真实浏览器钉住完整行为:录制的提问与完整工具输出仍在屏幕上,其后恰好一条 `compact` 行报告规模,展开后会显示确切摘要。录制本身未被触碰、保持模型真实——回放从录制自身的 surface 派生出手动压缩。 ## Deferred -终端的[已归档压缩进度决策](../../archived/feature/2026-07-30-compaction-progress-visibility.md)使用实时独立标记对驱动单格指示器,并不改变此浏览器投影。标记仍不携带**规模**信息:检查点的 `sourceEventSeqs` 保存被遮蔽的数量,因此可以另行论证后添加计数或区间,而无须将其与进度耦合。 +终端的[已归档压缩进度决策](../../archived/feature/2026-07-30-compaction-progress-visibility.md)使用实时独立标记对驱动单格指示器,并不改变此浏览器投影。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.i18n.yaml new file mode 100644 index 0000000000..39bf79e628 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.md +2026-08-01-ask-user-delegated-caller-guard.md: 0350a7383f7ace6ecd742c98db18f8b977720cf9 +2026-08-01-ask-user-delegated-caller-guard.zh.md: 9c19722feb4586e07845fd20279574301267542d diff --git a/.agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.md b/.agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.md new file mode 100644 index 0000000000..0350a7383f --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.md @@ -0,0 +1,39 @@ +# Agent Note: Reject human interaction from runtime-owned subagents + +Status: implemented + +English | [中文](2026-08-01-ask-user-delegated-caller-guard.zh.md) + +## Problem + +A one-shot subagent that calls `ask_user_question` can block indefinitely. The call waits for a human answer, but the child has no independently owned human channel, so the child's completion and the parent waiting on that completion both stall. + +Durable session lineage cannot decide whether an answerer exists. A child session may later be resumed as a new top-level runtime root, while a live runtime-owned child may carry a zero or absent durable delegation depth. Error guidance at the shared seam must also fit every consumer: `exit_plan_mode` uses `ctx.userInteraction.ask()` without calling `ask_user_question`. + +## Decision + +When `AskUserQuestionRequest.agent` is present, `UserInteractionService.ask()` authenticates the exact live agent through `ctx.agents` and admits it only when `ctx.agents.roots()` contains that instance. A missing registry or stale same-id object fails with `CALLER_NOT_LIVE`; a live agent owned by another live agent fails with `DELEGATED_CALLER`. The check runs after the existing aborted and empty-batch guards and before intent validation or provider dispatch, so an owned child never creates a UI wait. + +Runtime ownership is the authority. A lineage-bearing session resumed without an owner is a runtime root and may ask; a live child remains ineligible even when its durable `delegationDepth` is zero. Agentless programmatic calls retain the existing provider path. + +The shared failure text is consumer-neutral and actionable: the child includes the unresolved question or decision in its final result. The parent already receives that result through the delegation contract and can decide whether to ask the human. Neither the service nor a child claims an upward messaging or answer-forwarding capability that does not exist. + +This safety boundary is independent of the browser's composer election. The proposed [semantic composer phases](../../proposed/architecture/2026-08-08-semantic-composer-chain-phases.md) address how an already-pending interaction and a read-only subagent surface should be ordered; they do not weaken this runtime guard. + +## Alternatives considered + +**Use `session.header.delegationDepth > 0`.** Rejected because durable lineage survives resume and does not attest the current process-local owner. It rejects valid resumed roots and can admit a live child whose durable header is incomplete. + +**Reject only inside `dsh-tool-ask-user`.** Rejected because `exit_plan_mode` and direct callers share `ctx.userInteraction.ask()`. The service is the narrow operation boundary common to every human-interaction consumer. + +**Tell the child to delegate upward or wait for forwarding.** Rejected because one-shot delegation exposes no child-to-parent request channel and no answer-forwarding protocol. The only guaranteed return path is the child's final result. + +**Rely on the browser composer fix.** Rejected because presentation cannot make an ownerless human channel exist, and non-browser deployments still need the call to terminate. + +## Consequences + +Runtime-owned child calls fail fast with a stable structured error instead of hanging. Exact live roots and agentless programmatic calls remain eligible, including resumed sessions with historical child lineage. `ask_user_question` and `exit_plan_mode` receive the same neutral corrective guidance, while their model-visible schemas and system-prompt prefixes remain unchanged; only the appended error result differs, so existing KV-cache prefixes remain reusable. + +## Testing + +Service tests cover a zero-depth live child, a depth-one resumed runtime root, a missing registry, a stale same-id object, and provider non-invocation on every rejection. Tool and plan-mode tests prove both consumers surface the neutral `DELEGATED_CALLER` result and never reach the provider. The keyless assembled snapshot delegates to a child that attempts `ask_user_question`, pins the child's error tool result and final handoff, and proves the parent completes instead of waiting for an answer. diff --git a/.agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.zh.md b/.agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.zh.md new file mode 100644 index 0000000000..9c19722feb --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.zh.md @@ -0,0 +1,39 @@ +# Agent Note: 拒绝运行时中归属于其他 agent 的 subagent 向人类发起交互 + +Status: implemented + +[English](2026-08-01-ask-user-delegated-caller-guard.md) | 中文 + +## 问题 + +一次性 subagent 调用 `ask_user_question` 时可能无限阻塞。该调用会等待人类回答,但子级没有由自身独立拥有的人类交互通道,因此子级无法完成,等待其完成的父级也会随之停滞。 + +持久化会话谱系无法判断应答者是否存在。子会话之后可能恢复为新的顶层运行时根,而运行时中归属于其他 agent(智能体)的存活子级,其持久化委托深度却可能为零或缺失。共享 seam 上的错误指引还必须适用于每个消费方:`exit_plan_mode` 会使用 `ctx.userInteraction.ask()`,但不会调用 `ask_user_question`。 + +## 决策 + +如果存在 `AskUserQuestionRequest.agent`,`UserInteractionService.ask()` 会通过 `ctx.agents` 验证该 agent 就是注册表中的存活实例,并且只在 `ctx.agents.roots()` 包含该实例时才允许调用。缺失注册表或传入仅 id 相同的陈旧对象时,以 `CALLER_NOT_LIVE` 失败;存活 agent 归属于另一个存活 agent 时,以 `DELEGATED_CALLER` 失败。该检查位于现有的已中止和空批次守卫之后、意图校验或提供方分派之前,因此归属于其他 agent 的子级绝不会触发 UI 等待。 + +以运行时所有权为权限依据。携带谱系的会话在无所有者的情况下恢复时就是运行时根,可以提问;存活子级即使持久化 `delegationDepth` 为零,仍无资格提问。不带 agent 的程序化调用继续沿用现有提供方路径。 + +共享失败文本与具体消费方无关,并给出可执行指引:子级把尚未解决的问题或决策写入最终结果。委托契约本就会把该结果传给父级,父级可据此决定是否询问人类。服务和子级都不会宣称存在实际上并不存在的向上消息传递或回答转发能力。 + +该安全边界与浏览器的 composer 选举相互独立。提议的[语义 composer 阶段](../../proposed/architecture/2026-08-08-semantic-composer-chain-phases.md)解决已有待处理交互与只读 subagent 界面的排序方式;它不会削弱此运行时守卫。 + +## 备选方案 + +**使用 `session.header.delegationDepth > 0`。** 不予采用:持久化谱系会在恢复后继续存在,却不能证明当前进程内所有者。该方案会拒绝有效的已恢复根,也可能放行持久化 header 不完整的存活子级。 + +**仅在 `dsh-tool-ask-user` 内拒绝。** 不予采用:`exit_plan_mode` 与直接调用方共用 `ctx.userInteraction.ask()`。服务是所有人机交互消费方共同经过的最窄操作边界。 + +**让子级向上委托或等待转发。** 不予采用:一次性委托没有公开从子级向父级请求的通道,也没有回答转发协议。唯一有保证的返回路径是子级的最终结果。 + +**依赖浏览器的 composer 修复。** 不予采用:呈现方式无法凭空产生由所有者负责的人类通道,非浏览器部署仍然需要该调用能够终止。 + +## 影响 + +运行时中归属于其他 agent 的子级调用会以稳定的结构化错误快速失败,而不是挂起。注册表中的确切存活根和不带 agent 的程序化调用仍有资格提问,包括带有历史子级谱系的已恢复会话。`ask_user_question` 与 `exit_plan_mode` 会收到相同的中性纠正指引,而其模型可见 schema 和系统提示词前缀保持不变;只有追加的错误结果发生变化,因此现有 KV Cache 前缀仍可复用。 + +## Testing + +服务测试覆盖持久化深度为零的存活子级、深度为一的已恢复运行时根、缺失注册表、仅 id 相同的陈旧对象,以及每次拒绝都不调用提供方。工具与 plan-mode 测试证明两个消费方都会呈现中性的 `DELEGATED_CALLER` 结果,且绝不触达提供方。无密钥组装快照委托一个尝试调用 `ask_user_question` 的子级,固定其错误工具结果和最终交接,并证明父级可以完成,而不是一直等待回答。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.i18n.yaml index 59e98bc061..4752010ef6 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md -2026-08-03-cli-signal-shutdown-escalation.md: 2746b5784baad0f3b14258280cd56a621db07c15 -2026-08-03-cli-signal-shutdown-escalation.zh.md: 0bda83327d4cc8fe2edb61f8145a89138610901e +2026-08-03-cli-signal-shutdown-escalation.md: 7c9715c37ee57be9fa0f67af0c19f0bfa84845da +2026-08-03-cli-signal-shutdown-escalation.zh.md: f3485edc9e453c0b774f442bfce6d678d63f2224 diff --git a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md index 2746b5784b..7c9715c37e 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md +++ b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md @@ -6,9 +6,9 @@ English | [中文](2026-08-03-cli-signal-shutdown-escalation.zh.md) ## Problem -The default telemetry mount added SIGINT/SIGTERM handlers to `dsh web` and `dsh -p` so process exit could drain the Cordis tree instead of dropping queued telemetry. Each handler used a one-way boolean latch and exited only after `ctx.fiber.dispose()` settled. Headless normal completion also awaited that disposal without a bound. +The default telemetry mount added SIGINT/SIGTERM handlers to `dsh web` and the headless command (now `dsh run`) so process exit could drain the Cordis tree instead of dropping queued telemetry. Each handler used a one-way boolean latch and exited only after `ctx.fiber.dispose()` settled. Headless normal completion also awaited that disposal without a bound. -A user then reproduced `dsh -p` hanging immediately after the observation URL and ignoring repeated `Ctrl+C`; `DSH_TELEMETRY_DISABLED=1` removed the hang, while a standalone Node handler in the same Linux sandbox received SIGINT. This isolated the pending disposer to telemetry rather than terminal signal forwarding. OTel's `BatchLogRecordProcessor.shutdown()` awaits `exporter.forceFlush()` before the `exportTimeoutMillis`-bounded completion promise, and the OTLP exporter's `forceFlush()` waits directly on its in-flight HTTP Promise. A proxy/sandbox connection that never obtains a socket can therefore leave provider shutdown pending despite both configured SDK timeouts. +A user then reproduced the headless command hanging immediately after the observation URL and ignoring repeated `Ctrl+C`; `DSH_TELEMETRY_DISABLED=1` removed the hang, while a standalone Node handler in the same Linux sandbox received SIGINT. This isolated the pending disposer to telemetry rather than terminal signal forwarding. OTel's `BatchLogRecordProcessor.shutdown()` awaits `exporter.forceFlush()` before the `exportTimeoutMillis`-bounded completion promise, and the OTLP exporter's `forceFlush()` waits directly on its in-flight HTTP Promise. A proxy/sandbox connection that never obtains a socket can therefore leave provider shutdown pending despite both configured SDK timeouts. The latch then turned that telemetry defect into an unkillable CLI: normal completion was already awaiting the single-shot root disposal; the first SIGINT joined the same pending disposal and set the signal latch; later SIGINTs returned at the latch, so the process had no remaining escape. A signal received before normal completion had the same unbounded wait. Web used the same latch shape. diff --git a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md index 0bda83327d..f3485edc9e 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md @@ -6,9 +6,9 @@ ## 问题 -默认挂载遥测后,`dsh web` 与 `dsh -p` 新增了 SIGINT/SIGTERM 处理器,使进程退出时可以排空 Cordis 插件树,而不是丢弃排队中的遥测数据。每个处理器都使用单向布尔闩锁(latch),并且只有在 `ctx.fiber.dispose()` 结算后才退出。headless 正常完成时同样会无界等待整棵树执行 dispose(资源释放)。 +默认挂载遥测后,`dsh web` 与 headless 命令(现为 `dsh run`)新增了 SIGINT/SIGTERM 处理器,使进程退出时可以排空 Cordis 插件树,而不是丢弃排队中的遥测数据。每个处理器都使用单向布尔闩锁(latch),并且只有在 `ctx.fiber.dispose()` 结算后才退出。headless 正常完成时同样会无界等待整棵树执行 dispose(资源释放)。 -随后有用户复现,`dsh -p` 在打印观察 URL 后立即卡死,重复按 `Ctrl+C` 也没有反应;设置 `DSH_TELEMETRY_DISABLED=1` 后不再卡死,而同一 Linux 沙箱中的独立 Node 信号处理器能够收到 SIGINT。这将待结算的 disposer 定位到遥测,而非终端信号转发。OTel 的 `BatchLogRecordProcessor.shutdown()` 会先等待 `exporter.forceFlush()`,再进入受 `exportTimeoutMillis` 限制的完成 promise;OTLP 导出器的 `forceFlush()` 则直接等待正在进行的 HTTP Promise。因此,代理/沙箱连接始终无法取得 socket 时,即使已经配置两项 SDK 超时,也会让提供方关闭一直待结算。 +随后有用户复现,headless 命令在打印观察 URL 后立即卡死,重复按 `Ctrl+C` 也没有反应;设置 `DSH_TELEMETRY_DISABLED=1` 后不再卡死,而同一 Linux 沙箱中的独立 Node 信号处理器能够收到 SIGINT。这将待结算的 disposer 定位到遥测,而非终端信号转发。OTel 的 `BatchLogRecordProcessor.shutdown()` 会先等待 `exporter.forceFlush()`,再进入受 `exportTimeoutMillis` 限制的完成 promise;OTLP 导出器的 `forceFlush()` 则直接等待正在进行的 HTTP Promise。因此,代理/沙箱连接始终无法取得 socket 时,即使已经配置两项 SDK 超时,也会让提供方关闭一直待结算。 闩锁随后把这个遥测缺陷变成无法终止的 CLI(命令行界面):正常完成流程已经在等待单次根级 dispose;第一次 SIGINT 会加入同一个待结算的 dispose,并设置信号闩锁;后续 SIGINT 在闩锁处直接返回,因此进程再无退出途径。正常完成之前收到信号时,同样会陷入无界等待。Web 使用的闩锁结构与此相同。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml index 123058ac7e..f4f105e124 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md -2026-08-06-api-key-format-validation.md: d1f6d31d362b76392514704be780f553b45d36ad -2026-08-06-api-key-format-validation.zh.md: 75b3fa247bdf449964a874e909e6e3bc9e0694fa +2026-08-06-api-key-format-validation.md: 2174cb466c6af72f15005ce1ba3dec8100de6f2f +2026-08-06-api-key-format-validation.zh.md: f9b7d6518fedc42e5264c46beaa1a78619139c58 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md index d1f6d31d36..2174cb466c 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md @@ -12,15 +12,13 @@ Pasting a key containing an emoji, CJK text, or a full-width punctuation mark in `llm-pi-ai` was worse on the same input. Its discovery probe builds the same header with a bare `fetch` in [discovery.ts](../../../../packages/llm/llm-pi-ai/src/discovery.ts) and wrapped every failure as `could not reach `, so a local key fault was reported as an unreachable network. The probe is reachable from the unsaved draft: `ProviderEditor` puts the typed `keyDraft` into its probe request, so the model-listing button sent an illegal key before anything was stored. -Whitespace passed every check. `ProviderEditor` tested `keyDraft.length` and `resolveAdapterOptions` tested `config.apiKey.length`, so a key of three spaces stored and then authenticated as `Bearer` plus blanks. `llm-pi-ai` rejected an empty literal `apiKey` in `resolveProfiles`, but applied no check whatsoever to a credential- or environment-sourced key — the path the Models page writes, and therefore the path users actually take. - -Sources: deepseek-harness#1594 and #1595; dsh-external#247, #249, #266, and #210. +Whitespace passed every check. `ProviderEditor` tested `keyDraft.length`, so a key of three spaces was stored and then authenticated as `Bearer` plus blanks. Neither adapter checked a credential- or environment-sourced key — the path the Models page writes, and therefore the path users actually take. ## Decision One rule defines a legal key: **after trimming, non-empty, and every character within `[\x21-\x7E]`** — printable ASCII, space excluded. -This single predicate covers every input the sources list: empty, leading and trailing whitespace, interior whitespace, C0 control characters, emoji, CJK text, and full-width punctuation. It is also exactly the constraint that produced the ByteString failure, so the two issues close on one definition rather than on two coincidentally related fixes. +This single predicate covers every reported input: empty, leading and trailing whitespace, interior whitespace, C0 control characters, emoji, CJK text, and full-width punctuation. It is also exactly the constraint that produced the ByteString failure, so the failures share one definition rather than two coincidentally related fixes. A second, narrower rule catches a pasted environment line: input matching `^[A-Z][A-Z0-9_]*=[^=]` or wrapped in matching quotes is refused. Restricting the prefix to upper-case keeps real keys clear of it — `sk-` forms break the identifier match at the hyphen — and requiring a non-`=` character after the separator keeps base64 padding clear of it too. It reports the same format failure as an illegal character rather than its own message: the reader's next move is identical either way, so a separate line would name a cause without changing what to do. @@ -32,13 +30,13 @@ The shape rule is a guess about how people paste, so it runs **only in the brows ### Absence is a configuration state, not a missing key -"No API key" means three different things here, and only one of them is an error. The rule applies to a value that was *provided*; deciding whether one was provided at all stays with each caller. +The rule applies to a value that was *provided*; deciding whether one was provided at all stays with each caller. -**Omitted.** A profile naming neither `apiKey` nor `apiKeyEnv` is authenticated by something other than a harness-held key. `routeAuth` in [provider.ts](../../../../packages/llm/llm-pi-ai/src/provider.ts) keeps the installed catalog provider's own auth precisely so provider-native ambient discovery survives, and `openai-codex` — shipped in that catalog — authenticates through OAuth and refuses an explicit key outright. `namesCredential` carries this distinction. In `llm-deepseek`, an absent `apiKey` likewise falls through to `apiKeyEnv`. Omission is never validated. +**No named credential.** A pi-ai profile omitting `apiKeyEnv` may authenticate outside the harness-held credential path. `routeAuth` in [provider.ts](../../../../packages/llm/llm-pi-ai/src/provider.ts) keeps the installed catalog provider's own auth precisely so provider-native ambient discovery survives, and `openai-codex` — shipped in that catalog — authenticates through OAuth. `namesCredential` carries this distinction; omission is not a value to validate. **A blank field in the web UI.** The key input opens empty even for a provider whose key is already stored — the `keyStored` copy reads "Configured — enter a new value to replace" — so blank means *keep what is stored*. `ProviderEditor` skips `credentials.set` entirely when the draft is empty, and that stays a no-op: a blank field never blocks submit, or editing a base URL would demand re-entering the key. -**Provided, but empty or whitespace-only.** What this means depends on what absence selects for that surface, and the two adapters differ for a reason. In `llm-pi-ai` it is an error, because absence there switches authentication mode — to the installed provider's ambient discovery or OAuth — so a blank key leaves genuine ambiguity about which was meant; its wording names the legitimate alternative rather than just refusing (*has an empty apiKey; omit it to use ambient authentication*). In `llm-deepseek` absence merely selects a different *source* for the same key, `apiKeyEnv`, so a blank literal resolves through that fallback exactly as an omitted one does. In the browser it is always a failure, on both cards: the field is where a person just typed, and silently discarding what they typed is never the right answer. +**A resolved value that is whitespace-only.** This is invalid at both adapters because it cannot authenticate a request. In the browser it is also a field-level failure: the field is where a person just typed, and silently discarding what they typed is never the right answer. `normalizeApiKey` therefore takes `string`, never `string | undefined`. @@ -55,9 +53,7 @@ The client cannot import any of this: client packages reference only client pack | Surface | Behavior | |---|---| | `dsh-llm` | Owns `normalizeApiKey`, `assertUsableApiKey`, and `INVALID_CREDENTIAL_CODE`, which is deliberately outside `DEFAULT_RETRYABLE_CODES`. | -| `llm-deepseek` `resolveAdapterOptions` | Refuses a literal `apiKey` no header can carry, beside the other beyond-schema bounds; uses the trimmed value. An absent or blank one falls through to `apiKeyEnv`. | | `llm-deepseek` `resolveApiKey` | Normalizes what the credentials seam or environment returns, rejecting with `INVALID_CREDENTIAL` naming the Models page and never echoing the key. | -| `llm-pi-ai` `resolveProfiles` | Applies the shared rule, keeping its "omit it to use ambient authentication" wording, and writes the trimmed value into the resolved profile. | | `llm-pi-ai` `resolveApiKey` | Normalizes the credential and environment paths. A profile naming no credential still returns `undefined`, so ambient and OAuth routes are unaffected. | | `llm-pi-ai` `discoverModels` | Normalizes before building the header, so an illegal key is a credential fault rather than an unreachable endpoint. A probe carrying no key stays unauthenticated. | | `ui-models` | Mirrors the charset rule, adds the shape heuristic, trims `keyDraft` before probe and `credentials.set`, and fixes the `stringAt` emptiness test. A blank field remains a no-op that submits; a field holding only whitespace is a field-level failure. Submit **and the endpoint interrogation** are both gated, so a refused key never spends a round trip to be told what the field already says, and the failure renders on the field, matching the existing `modelFailure` pattern. | @@ -68,8 +64,6 @@ The client cannot import any of this: client packages reference only client pack ## Alternatives considered -**A `.pattern()` on the `apiKey` schema field.** Vendored schemastery supports it, and the pattern would serialize to the browser with the rest of the namespace schema — one rule, delivered rather than mirrored. It lost because a pattern cannot trim first: `cordis.yml` would then reject a padded key while `.env` tolerated one, and the resolver would disagree with the schema about the same string. Validating in `resolveAdapterOptions` keeps every surface trim-then-validate, and that function is already where this package re-judges bounds the schema cannot express. - **A validation module shared by client and host.** Rejected by the source-plane layout: client packages reference only client packages plus `vendor/cordis` and `support/invariants`, and widening that to reach a host package would collide the two `Context` merges the split exists to keep apart. Mirroring a one-line predicate with a test on each side is the established shape here. **A per-adapter thrower in each of `llm-deepseek` and `llm-pi-ai`.** The first plan gave each adapter its own, differing only by the package prefix in the message, with a duplication-gate exemption to excuse the pair. Rejected before implementation: `LlmError` is declared in the seam, so the seam can own the diagnosis outright, and an exemption there would have hidden exactly the duplication it was covering for. @@ -80,7 +74,7 @@ The client cannot import any of this: client packages reference only client pack **Running the shape heuristic in the resolvers too.** Symmetric, and it would stop a pasted environment line written directly into `.env`. Rejected for the lockout described above: a false positive in a resolver leaves the user no working path, while a false positive in the browser leaves the environment open. -**Probing the provider at save time to prove the key works.** It would close the complaint the sources actually open with — a save that reports success and fails at the first turn. Rejected as out of scope and, on the code as it stood, unbuildable: `discoverModels` short-circuits to the installed catalog before any network call for exactly the providers pi-ai ships catalogs for, so it verified nothing about the key, and the DeepSeek card has no probe at all. A verifier's value is distinguishing "key rejected" from "cannot reach", which is the distinction this change makes reliable; building it first would have produced a verifier unable to tell its own outcomes apart. Comparable products also do not verify on save, so a blocking network call there would be an unexpected behavior rather than a missing one. +**Probing the provider at save time to prove the key works.** It would close the original complaint — a save that reports success and fails at the first turn. Rejected as out of scope and, on the code as it stood, unbuildable: `discoverModels` short-circuits to the installed catalog before any network call for exactly the providers pi-ai ships catalogs for, so it verified nothing about the key, and the DeepSeek card has no probe at all. A verifier's value is distinguishing "key rejected" from "cannot reach", which is the distinction this change makes reliable; building it first would have produced a verifier unable to tell its own outcomes apart. Comparable products also do not verify on save, so a blocking network call there would be an unexpected behavior rather than a missing one. ## Consequences @@ -100,7 +94,7 @@ The costliest way to get this wrong would have been to treat absence as invalidi `packages/llm/llm/tests/api-key.spec.ts` drives `normalizeApiKey` and `assertUsableApiKey` over the whole input table — empty, whitespace-only, padded, interior-space, C0 control, emoji, CJK, full-width, latin-1, and the printable-ASCII boundary — and pins that a refusal carries `INVALID_CREDENTIAL` and no part of the key. -`packages/llm/llm-deepseek/tests/` covers the literal-config path in `adapter.spec.ts` and the stored-credential path end to end in `dynamic-config.spec.ts`, through the real credentials seam rather than a stub. `packages/llm/llm-pi-ai/tests/` covers `resolveProfiles` — including that the trimmed value reaches the resolved profile, which the `...rest` spread would otherwise discard — and the discovery probe, including that a probe with no key sends no `authorization` header. +`packages/llm/llm-deepseek/tests/` covers the stored-credential path end to end in `dynamic-config.spec.ts`, through the real credentials seam rather than a stub. `packages/llm/llm-pi-ai/tests/` covers the discovery probe, including that a probe with no key sends no `authorization` header. `packages/client/ui-models/tests/` pins `apiKeyFailure` over the same table plus the paste-shape cases, and drives both cards: a blank field submits without writing a credential, a whitespace-only field fails on the field, an illegal or wrapped key blocks submit and the interrogation alike, a padded key is trimmed before `credentials.set` and before an interrogation, and a hand-declared route can be created with no key at all. diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md index 75b3fa247b..f9b7d6518f 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md @@ -12,15 +12,13 @@ Status: implemented 同样的输入在 `llm-pi-ai` 上更糟。它的探测路径在 [discovery.ts](../../../../packages/llm/llm-pi-ai/src/discovery.ts) 里用裸 `fetch` 构造同一个 header,并把一切失败包装成 `could not reach `,于是一个本地的 Key 故障被报成网络不可达。这条探测在保存之前就够得着:`ProviderEditor` 把用户输入的 `keyDraft` 直接放进探测请求,所以「获取模型列表」按钮会在任何东西落盘之前就把非法 Key 发出去。 -空白字符能通过每一道检查。`ProviderEditor` 判的是 `keyDraft.length`,`resolveAdapterOptions` 判的是 `config.apiKey.length`,于是三个空格构成的 Key 会被存下,随后以 `Bearer` 加若干空格去认证。`llm-pi-ai` 在 `resolveProfiles` 中拒绝空的字面量 `apiKey`,却对来自凭据或环境的 Key 完全不做检查——而那正是模型设置页写入的路径,也就是用户真正走的路径。 - -来源:deepseek-harness#1594 与 #1595;dsh-external#247、#249、#266、#210。 +空白字符能通过每一道检查。`ProviderEditor` 判的是 `keyDraft.length`,于是三个空格构成的 Key 会被存下,随后以 `Bearer` 加若干空格去认证。两个适配器都不检查来自凭据或环境的 Key——而那正是 Models 页写入的路径,也就是用户真正走的路径。 ## Decision 一条规则定义什么是合法 Key:**trim 之后非空,且每个字符都落在 `[\x21-\x7E]`**——可打印 ASCII,不含空格。 -这一个断言覆盖了来源列出的全部输入:空值、首尾空白、中间空白、C0 控制字符、emoji、中文、全角标点。它同时正是造成 ByteString 失败的那条约束,所以两个 issue 收敛于同一个定义,而不是两个恰好相关的修复。 +这一个断言覆盖了所有已报告的输入:空值、首尾空白、中间空白、C0 控制字符、emoji、中文、全角标点。它同时正是造成 ByteString 失败的那条约束,所以这些故障收敛于同一个定义,而不是两个恰好相关的修复。 第二条更窄的规则用于识别整行粘贴的环境变量:匹配 `^[A-Z][A-Z0-9_]*=[^=]` 或首尾成对引号的输入会被拒绝。把前缀限定为全大写可以让真实 Key 与之绝缘——`sk-` 这类形态会在连字符处中断标识符匹配——而要求分隔符之后必须是非 `=` 字符,则让 base64 的 padding 也与之绝缘。它报出的是与非法字符相同的那条格式失败,而不是自己的一句:读到它的人下一步动作完全一样,因此单列一句只会点出一个原因,却不改变该怎么做。 @@ -32,13 +30,13 @@ Status: implemented ### 「没有 Key」是一种配置状态,不是缺失 -在这里,「没有 API Key」意味着三件完全不同的事,其中只有一件是错误。规则作用于**已提供**的值;至于究竟有没有提供,由各个调用方自行判断。 +规则作用于*已提供*的值;至于究竟有没有提供,由各个调用方自行判断。 -**未指定。** 既不写 `apiKey` 也不写 `apiKeyEnv` 的 profile,是由 harness 所持有的 Key 之外的东西来鉴权的。[provider.ts](../../../../packages/llm/llm-pi-ai/src/provider.ts) 中的 `routeAuth` 保留内置 catalog provider 自身的鉴权,正是为了让 provider 原生的 ambient 发现得以存活;而该 catalog 附带的 `openai-codex` 通过 OAuth 鉴权,并会直接拒绝一个显式的 Key。`namesCredential` 承载着这一区分。在 `llm-deepseek` 中,缺省的 `apiKey` 同样会回落到 `apiKeyEnv`。未指定的情形永不参与校验。 +**未点名凭据。** 省略 `apiKeyEnv` 的 pi-ai profile 可以在 harness 持有的凭据路径之外鉴权。[provider.ts](../../../../packages/llm/llm-pi-ai/src/provider.ts) 中的 `routeAuth` 保留内置 catalog provider 自身的鉴权,正是为了让 provider 原生的 ambient 发现继续工作;而该 catalog 附带的 `openai-codex` 通过 OAuth 鉴权。`namesCredential` 承载这一区分;省略不是需要校验的值。 **Web UI 中留空的输入框。** 即便某个 provider 的 Key 已经存好,该输入框也是空着打开的——`keyStored` 的文案写的是「已配置——输入新值以替换」——所以留空意味着*保持已存储的值*。`ProviderEditor` 在草稿为空时完全跳过 `credentials.set`,这一点保持不变:留空绝不拦截提交,否则改一个 base URL 都得重新输一遍 Key。 -**已提供,但为空或纯空白。** 它意味着什么,取决于「缺失」在该界面上选中了什么,而两个适配器的差异是有依据的。在 `llm-pi-ai` 中它是错误,因为那里的缺失切换的是**鉴权方式**——转向内置 provider 的 ambient 发现或 OAuth——因此一个空 Key 究竟想选哪一种是真有歧义;它的措辞指明了合法替代路径而非单纯拒绝(*has an empty apiKey; omit it to use ambient authentication*)。在 `llm-deepseek` 中,缺失只是为同一把 Key 选择了另一个**来源** `apiKeyEnv`,因此空白字面量会像缺省一样经该回落解析。在浏览器中它始终是失败,两张卡片皆然:字段是人刚刚敲过字的地方,静默丢弃他敲进去的内容永远不是正确答案。 +**解析得到的值只含空白。** 两个适配器都将其视为非法,因为它无法为请求鉴权。在浏览器中,这同样是字段级失败:字段是人刚刚敲过字的地方,静默丢弃他敲进去的内容永远不是正确答案。 因此 `normalizeApiKey` 接受 `string`,而绝非 `string | undefined`。 @@ -55,9 +53,7 @@ Status: implemented | 界面 | 行为 | |---|---| | `dsh-llm` | 拥有 `normalizeApiKey`、`assertUsableApiKey` 与 `INVALID_CREDENTIAL_CODE`,后者刻意不进 `DEFAULT_RETRYABLE_CODES`。 | -| `llm-deepseek` `resolveAdapterOptions` | 拒绝标头无法承载的字面量 `apiKey`,与其他超出 schema 的边界检查并排;使用 trim 后的值。缺省或空白的 `apiKey` 回落到 `apiKeyEnv`。 | | `llm-deepseek` `resolveApiKey` | 归一化凭据 seam 或环境返回的值,以 `INVALID_CREDENTIAL` 拒绝,消息指明模型设置页,绝不回显 Key。 | -| `llm-pi-ai` `resolveProfiles` | 施加这条共享规则,保留其「omit it to use ambient authentication」的措辞,并把 trim 后的值写进解析后的 profile。 | | `llm-pi-ai` `resolveApiKey` | 归一化凭据与环境路径。不指定任何凭据的 profile 仍返回 `undefined`,ambient 与 OAuth 路由不受影响。 | | `llm-pi-ai` `discoverModels` | 在构造 header 之前归一化,使非法 Key 成为凭据故障而非端点不可达。不带 Key 的探测保持未鉴权。 | | `ui-models` | 镜像字符集规则,加入形状启发式,在探测与 `credentials.set` 之前 trim `keyDraft`,并修正 `stringAt` 的空值判断。留空的输入框仍是可以提交的空操作;只含空白的输入框则是字段级失败。提交**与端点探测**同时受拦截,因此被拒绝的密钥不会白花一次往返去换取字段上已经写明的答案;失败呈现在字段上,与既有的 `modelFailure` 模式一致。 | @@ -68,8 +64,6 @@ Status: implemented ## Alternatives considered -**在 `apiKey` schema 字段上加 `.pattern()`。** vendor 中的 schemastery 支持它,且该 pattern 会随命名空间 schema 一同序列化到浏览器——一条规则,投递而非镜像。它落败于 pattern 无法先行 trim:那样 `cordis.yml` 会拒绝带首尾空白的 Key 而 `.env` 却容忍,resolver 与 schema 会对同一个字符串给出分歧。在 `resolveAdapterOptions` 中校验可以让每一层都是 trim-then-validate,而该函数本就是本包重新裁定 schema 无法表达的边界之处。 - **由 client 与 host 共享一个校验模块。** 被 source plane 布局否决:client 包只 reference client 包外加 `vendor/cordis` 与 `support/invariants`,把它放宽到够得着 host 包会撞上这一分割本就要隔开的两份 `Context` 合并。在两侧各镜像一行断言并各配一份测试,是此处的既定形态。 **在 `llm-deepseek` 与 `llm-pi-ai` 中各留一个抛错 helper。** 最初的计划正是各留一份,差别仅在消息中的包名前缀,并配一个重复检测豁免来放行这一对。在实现之前即被否决:`LlmError` 声明在 seam 中,因此 seam 完全可以自己拥有这句诊断,而那里的一个豁免恰恰会掩盖它本要遮掩的重复。 @@ -80,7 +74,7 @@ Status: implemented **让形状启发式也在 resolver 中运行。** 更对称,且能拦住直接写进 `.env` 的整行环境变量。因上文所述的锁死风险而否决:resolver 中的一次误判会让用户无路可走,浏览器中的一次误判则仍留有环境变量这条路。 -**在保存时探测 provider 以证明 Key 可用。** 它能关掉来源真正开篇抱怨的那件事——保存报成功、第一轮才失败。因超出范围而否决,且在当时的代码上无法建成:对 pi-ai 恰好自带 catalog 的那些 provider,`discoverModels` 会在任何网络调用之前短路到内置 catalog,因而对 Key 什么都验证不了;而 DeepSeek 卡片根本没有探测。验证器的价值在于分清「Key 被拒」与「无法连通」,而这正是本次改动让其变得可靠的区分;先建验证器只会得到一个分不清自身结果的验证器。同类产品也不在保存时验证,因此保存时的阻断式网络调用会是一个意外行为,而非一处缺失。 +**在保存时探测 provider 以证明 Key 可用。** 它能关掉最初报告的那件事——保存报成功、第一轮才失败。因超出范围而否决,且在当时的代码上无法建成:对 pi-ai 恰好自带 catalog 的那些 provider,`discoverModels` 会在任何网络调用之前短路到内置 catalog,因而对 Key 什么都验证不了;而 DeepSeek 卡片根本没有探测。验证器的价值在于分清「Key 被拒」与「无法连通」,而这正是本次改动让其变得可靠的区分;先建验证器只会得到一个分不清自身结果的验证器。同类产品也不在保存时验证,因此保存时的阻断式网络调用会是一个意外行为,而非一处缺失。 ## Consequences @@ -100,7 +94,7 @@ Status: implemented `packages/llm/llm/tests/api-key.spec.ts` 以整张输入表驱动 `normalizeApiKey` 与 `assertUsableApiKey`——空值、纯空白、带首尾空白、含中间空格、C0 控制字符、emoji、中文、全角、latin-1,以及可打印 ASCII 的边界字符——并钉住一次拒绝携带 `INVALID_CREDENTIAL` 且不含 Key 的任何部分。 -`packages/llm/llm-deepseek/tests/` 在 `adapter.spec.ts` 中覆盖字面量配置路径,在 `dynamic-config.spec.ts` 中经真实凭据 seam(而非 stub)端到端覆盖已存储凭据路径。`packages/llm/llm-pi-ai/tests/` 覆盖 `resolveProfiles`——包括 trim 后的值确实到达解析后的 profile,否则会被 `...rest` 展开丢弃——以及探测路径,包括不带 Key 的探测不会发出 `authorization` 标头。 +`packages/llm/llm-deepseek/tests/` 在 `dynamic-config.spec.ts` 中经真实凭据 seam(而非 stub)端到端覆盖已存储凭据路径。`packages/llm/llm-pi-ai/tests/` 覆盖探测路径,包括不带 Key 的探测不会发出 `authorization` 标头。 `packages/client/ui-models/tests/` 以同一张表加上形状用例钉住 `apiKeyFailure`,并驱动两张卡片:留空的输入框可提交且不写入凭据、只含空白的输入框在字段上失败、非法或被包裹的 Key 同时拦截提交与探测、带首尾空白的 Key 在 `credentials.set` 与探测之前被 trim,以及手工声明的路由可以完全不带 Key 创建。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.i18n.yaml index 9f16a183b9..de0b20d867 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.md -2026-08-06-provider-credential-lifecycle.md: ce45207e7ac7224f44e34945e36ba85db0971f09 -2026-08-06-provider-credential-lifecycle.zh.md: c476417517b8ed72036344a13720a8ba378775e6 +2026-08-06-provider-credential-lifecycle.md: c28788921e8f1b233b44e19b29ad4d4acaa25022 +2026-08-06-provider-credential-lifecycle.zh.md: 2ea3b21fb6ceb4fa38a0cad0daf47c3b6a98a664 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.md b/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.md index ce45207e7a..c28788921e 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.md @@ -12,7 +12,7 @@ The Models editor spans independent settings and credential RPC domains. It prev Provider save remains a two-stage settings-then-credentials operation over the existing wire domains, but the card treats the successful settings response as a commit checkpoint. It replaces its comparison subtree and expected revision with the returned redacted descriptor before attempting `credentials.set`; if that second stage fails, the draft key and card stay visible, and retry produces no settings ops and repeats only the credential write. Genuine concurrent changes before the first settings commit still fail with `settings-conflict`. Typed keys are trimmed at the UI and direct DeepSeek resolver boundaries, and pi-ai records a derived reference only when the normalized key is non-empty; saving a blank key materializes an empty, reference-free profile for provider-native discovery. -Deletion removes a credential only when the joined row identifies the exact `_API_KEY` reference derived by this page and reports it configured and writable. It unsets that credential before the user-layer profile so a settings-stage failure leaves the row and its frozen target visible for retry; both unsets are idempotent. Custom references, environment credentials, missing credentials, and targets the join cannot identify are retained. The row's accessible Edit/Delete names and the destructive dialog title, description, and final action all use the same stable `Display Name (route-id)` identity, collapsing to the route id when both strings match. The dialog states whether the stored key will be removed and owns operation failures instead of replacing the whole page with a load-error banner. Rows expose API-key state only from the value-free join: a confirmed literal or referenced credential is a green solid dot, a confirmed missing named reference is a red solid dot, and reference-free provider-native authentication or unavailable credential enrichment has no dot. Each dot has accessible copy and a tooltip, while successful Apply uses the same provider identity in a local status message and never echoes secret material. +Deletion removes a credential only when the joined row identifies the exact `_API_KEY` reference derived by this page and reports it configured and writable. It unsets that credential before the user-layer profile so a settings-stage failure leaves the row and its frozen target visible for retry; both unsets are idempotent. Custom references, environment credentials, missing credentials, and targets the join cannot identify are retained. The row's accessible Edit/Delete names and the destructive dialog title, description, and final action all use the same stable `Display Name (route-id)` identity, collapsing to the route id when both strings match. The dialog states whether the stored key will be removed and owns operation failures instead of replacing the whole page with a load-error banner. Rows expose API-key state only from the value-free join: a confirmed referenced credential is a green solid dot, a confirmed missing named reference is a red solid dot, and reference-free provider-native authentication or unavailable credential enrichment has no dot. Each dot has accessible copy and a tooltip, while successful Apply uses the same provider identity in a local status message and never echoes secret material. ## Alternatives considered @@ -20,8 +20,8 @@ Deletion removes a credential only when the joined row identifies the exact `_API_KEY` 引用,并将其报告为已配置且可写时,删除操作才会清除该凭据。它会先取消设置该凭据,再取消设置用户层 profile;如果 settings 阶段失败,该行及其已冻结的目标仍可见,便于重试。两项 unset 都具备幂等性。自定义引用、环境凭据、缺失的凭据,以及联接无法识别目标的凭据均会保留。行的无障碍 Edit/Delete 名称以及破坏性对话框的标题、说明和最终操作都使用同一个稳定的 `Display Name (route-id)` 标识;当两个字符串相同时,标识会简化为路由 id。对话框会说明是否一并删除已存密钥,并在自身内显示操作失败,而不是用加载错误横幅替换整个页面。行只根据不含值的联接结果展示 API 密钥状态:确认已配置的字面密钥或引用凭据显示为绿色实心点,确认缺失的具名引用显示为红色实心点,无引用的提供方原生认证或无法取得凭据补充信息时则不显示状态点。每个状态点都有无障碍文案和工具提示;「应用」成功后的本地状态消息会使用同一个提供方标识,且绝不回显任何机密内容。 +只有当联接所得的行识别出该页面派生的精确 `_API_KEY` 引用,并将其报告为已配置且可写时,删除操作才会清除该凭据。它会先取消设置该凭据,再取消设置用户层 profile;如果 settings 阶段失败,该行及其已冻结的目标仍可见,便于重试。两项 unset 都具备幂等性。自定义引用、环境凭据、缺失的凭据,以及联接无法识别目标的凭据均会保留。行的无障碍 Edit/Delete 名称以及破坏性对话框的标题、说明和最终操作都使用同一个稳定的 `Display Name (route-id)` 标识;当两个字符串相同时,标识会简化为路由 id。对话框会说明是否一并删除已存密钥,并在自身内显示操作失败,而不是用加载错误横幅替换整个页面。行只根据不含值的联接结果展示 API 密钥状态:确认已配置的引用凭据显示为绿色实心点,确认缺失的具名引用显示为红色实心点,无引用的提供方原生认证或无法取得凭据补充信息时则不显示状态点。每个状态点都有无障碍文案和工具提示;「应用」成功后的本地状态消息会使用同一个提供方标识,且绝不回显任何机密内容。 ## 曾考虑的替代方案 @@ -20,8 +20,8 @@ Models 编辑器横跨互相独立的 settings 与凭据 RPC 领域。之前它 **删除被移除 profile 所指定的每一个凭据引用。**自定义引用可能被共享、由外部管理,或有意在 profile 反复增删时存留。与该页面派生目标精确相等,再加上已配置且可写的状态,是页面所能获得的最小范围证据;比这更弱的判定都有可能删除不属于它的凭据。 -**先删除 settings,再重建 profile 以作补偿。**浏览器只持有脱敏后的子树,无法忠实重建已存的字面机密或并发编辑。先删除凭据可以让权威 profile 在部分失败时仍然可见,并且无需合成配置就能安全重试。 +**先删除 settings,再重建 profile 以作补偿。**浏览器只持有脱敏后的子树,无法忠实重建并发编辑。先删除凭据可以让权威 profile 在部分失败时仍然可见,并且无需合成配置就能安全重试。 ## 后果 -Models 页可以从任一第二阶段失败中恢复,无需重新加载,也不会泄露机密或产生虚假的并发冲突;空密钥的 pi-ai profile 会保留 Bedrock、Vertex 与其他提供方原生认证。已确认的状态清晰可见,同时不会把路由存活状态、原生认证或凭据查询失败误报为错误;即使该行继续显示绿色,密钥替换成功也仍然可观察。删除由页面管理的提供方不再遗留可重用的本地密钥,而存在歧义的凭据会有意保留,交由手动管理。保存与删除在跨持久存储时仍非原子操作:进程可能在两个阶段之间崩溃,但它们的顺序与幂等性会留下可观察、可重试的状态。组件测试固定了部分成功后的重试、空密钥原生认证、标准化字面值、状态可见性、目标标识、清理所有权,以及凭据/settings 拒绝顺序;无密钥的浏览器场景固定了双语无障碍文案,并验证确认删除会同时清除 `settings.yaml` profile 与 `.env` 凭据。此决策细化了 [web 配置平面 note](../architecture/2026-07-30-web-config-plane.md) 中记录的 Models 应用语义。 +Models 页可以从任一第二阶段失败中恢复,无需重新加载,也不会泄露机密或产生虚假的并发冲突;空密钥的 pi-ai profile 会保留 Bedrock、Vertex 与其他提供方原生认证。已确认的状态清晰可见,同时不会把路由存活状态、原生认证或凭据查询失败误报为错误;即使该行继续显示绿色,密钥替换成功也仍然可观察。删除由页面管理的提供方不再遗留可重用的本地密钥,而存在歧义的凭据会有意保留,交由手动管理。保存与删除在跨持久存储时仍非原子操作:进程可能在两个阶段之间崩溃,但它们的顺序与幂等性会留下可观察、可重试的状态。组件测试固定了部分成功后的重试、空密钥原生认证、密钥首尾空白处理、状态可见性、目标标识、清理所有权,以及凭据/settings 拒绝顺序;无密钥的浏览器场景固定了双语无障碍文案,并验证确认删除会同时清除 `settings.yaml` profile 与 `.credentials.yaml` 条目。此决策细化了 [web 配置平面 note](../architecture/2026-07-30-web-config-plane.md) 中记录的 Models 应用语义。 diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml index c981d84400..1917b118da 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md -2026-06-18-compaction-capability-seam.md: 26e6e2468c7bea661d85c8fb994adf8b109105ee -2026-06-18-compaction-capability-seam.zh.md: 8f9cd1f6bc31e648a5b923e816cad75ebc1a0bd8 +2026-06-18-compaction-capability-seam.md: 390d091fd6e6f7fa1694ba78b90522b70948fe92 +2026-06-18-compaction-capability-seam.zh.md: d72793e6adde911a8623ac1c6b65df899381923e diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md index 26e6e2468c..390d091fd6 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md @@ -33,7 +33,7 @@ This is not a coupling smell — it is the contract's domain. The "only cordis" An earlier draft put the full algorithm (the retention walk, token-summing, text extraction) as concrete methods on the interface. That recouples the contract to one strategy: a backend that wants a different retention policy or event sequence would have to fight inherited concrete code. Making all three operations abstract puts every *how* decision in the backend and keeps the interface a statement of *what*. Token measurement is not a compaction hook at all; the singleton service lets multiple consumers share one per-session replay fold. -`compactIfNeeded(agent, trigger, signal)` takes an explicit `'pressure' | 'context-overflow'` trigger and cancellation. It reads only the latest durable routed request; no header means no work, while any routed provider/model target uses the singleton estimator. `compactNow(agent, signal)` requires an idle agent and performs one useful balanced reduction even below pressure, returning `null` without writes when none exists. `compactRegion(start, end, agent, signal?)` uses `agent.session` as its single session identity and keeps an optional signal for explicit callers. The default summarizer resolves its target from explicit config, the latest logged routed target, then agent options, and records the provider/model pair after any `llm/stream` routing. It replays the routed request's prefix and appends the compaction directive as a trailing user message so the provider's warm KV cache is reused — see the [summary prefix-cache Agent Note](../bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md). The call sets the provider-neutral `GenerateOptions.purpose` to `compaction`; adapters may map that purpose to model-hidden transport metadata, and the DeepSeek adapter sends `x-deepseek-harness-compact: 1`. +`compactIfNeeded(agent, trigger, signal)` takes an explicit `'pressure' | 'context-overflow'` trigger and cancellation. It reads only the latest durable routed request; no header means no work, while any routed provider/model target uses the singleton estimator. `compactNow(agent, signal)` requires an idle agent and performs one useful balanced reduction even below pressure, returning `null` without writes when none exists. `compactRegion(start, end, agent, signal?)` uses `agent.session` as its single session identity and keeps an optional signal for explicit callers. The default summarizer resolves its target from explicit config, the latest logged routed target, then agent options, and records the provider/model pair after any `llm/stream` routing. It replays the routed request's prefix and appends the compaction directive as a trailing user message so the provider's warm KV cache is reused — see the [summary prefix-cache Agent Note](../bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md). The result carries `llmStreamCall: true` because it consumed exactly one call through this context's LLM service; a subclass sets that marker only under the same condition, since retained `rawOutput` alone does not identify the call path. The call sets the provider-neutral `GenerateOptions.purpose` to `compaction`; adapters may map that purpose to model-hidden transport metadata, and the DeepSeek adapter sends `x-deepseek-harness-compact: 1`. ### Automatic pressure runs after successful durable step work @@ -76,7 +76,7 @@ Because `SurfaceEventType` is closed, the summary cannot ride on a `compact/*` e ``` compact/start → log-only. Acquires the lock. [summarize older range via the backend] -compact/summary → log-only. Provenance: raw summary, range, shadowed seqs, token count. +compact/summary → log-only. Provenance: raw summary, local-call marker, range, shadowed seqs, token count. user/message → canonical checkpoint source + surfaceOp { op:'replace', start, end }. THE surface mutation (framed summary). deriveMessages() renders it as a user-role message. @@ -131,4 +131,4 @@ The lifecycle boundary makes crash state unambiguous: - **Loop:** Tests pin pre-step after the preceding `step/end` and before the next `step/start`, actual `agent/request` routing, closed failed steps, fresh retry numbering, and complete thrown/in-band overflow → compaction → reconstructed retry composition. - **Manual:** Maintenance serialization, marker ordering, injection retention, live/stale orphan classification, cancellation, close/flush failures, command mapping, and the queued TUI journey are pinned without a model key. - **With-key e2e:** A real model and bash session with lowered limits triggers compaction, records a complete `compact/start…end` pair, shrinks the surface, and finishes the task. -- **Snapshot gap:** Runaway-turn compaction cannot yet replay because the summarization call records no `assistant/chunk` events or `sessionId`; interleaved summarization-call replay remains follow-up work. +- **Snapshot:** The assembled context-overflow scenario derives the auxiliary call from `compact/summary` only when `llmStreamCall: true` proves that the local LLM service consumed it; canonical reconstructed blocks pin the complete recovery without provider delta partitioning. diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md index 8f9cd1f6bc..d72793e6ad 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md @@ -33,7 +33,7 @@ Status: implemented 早期草案将完整算法(保留遍历、token 求和、文本提取)作为接口上的具体方法。这会将契约重新耦合到一种策略:想要不同保留策略或事件排序的后端必须与继承来的具体代码对抗。将三个操作都设为抽象,把所有*怎么做*的决策放在后端,并让接口保持为*做什么*的声明。token 测量根本不是压缩钩子;单例服务使多个消费方能够共享逐会话的回放折叠。 -`compactIfNeeded(agent, trigger, signal)` 接受显式的 `'pressure' | 'context-overflow'` 触发原因与取消信号。它只读取最新的持久化已路由请求;没有 header 就不执行工作,任何已路由的提供方/模型目标都使用单例估算器。`compactNow(agent, signal)` 要求 agent 处于 idle,即使未达到压力也进行一次有效的平衡缩减;不存在这种范围时返回 `null`,且不写入任何内容。`compactRegion(start, end, agent, signal?)` 将 `agent.session` 作为唯一会话身份,并为显式调用方保留可选 signal。默认摘要器依次从显式配置、最新记录的已路由目标和 agent 选项解析目标,并在任何 `llm/stream` 路由后记录提供方/模型对。它回放已路由请求的前缀,并将压缩指令追加为尾部 user 消息,从而复用提供方的热 KV cache;见[摘要前缀缓存 Agent Note](../bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md)。该调用将提供方无关的 `GenerateOptions.purpose` 设为 `compaction`;适配器可以将此用途映射为对模型隐藏的传输元数据,DeepSeek 适配器会发送 `x-deepseek-harness-compact: 1`。 +`compactIfNeeded(agent, trigger, signal)` 接受显式的 `'pressure' | 'context-overflow'` 触发原因与取消信号。它只读取最新的持久化已路由请求;没有 header 就不执行工作,任何已路由的提供方/模型目标都使用单例估算器。`compactNow(agent, signal)` 要求 agent 处于 idle,即使未达到压力也进行一次有效的平衡缩减;不存在这种范围时返回 `null`,且不写入任何内容。`compactRegion(start, end, agent, signal?)` 将 `agent.session` 作为唯一会话身份,并为显式调用方保留可选 signal。默认摘要器依次从显式配置、最新记录的已路由目标和 agent 选项解析目标,并在任何 `llm/stream` 路由后记录提供方/模型对。它回放已路由请求的前缀,并将压缩指令追加为尾部 user 消息,从而复用提供方的热 KV cache;见[摘要前缀缓存 Agent Note](../bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md)。该结果携带 `llmStreamCall: true`,因为生成它时恰好通过此上下文的 LLM 服务发起了一次调用;只有满足相同条件时,子类才设置该标记,因为单有保留的 `rawOutput` 并不能判定调用路径。该调用将提供方无关的 `GenerateOptions.purpose` 设为 `compaction`;适配器可以将此用途映射为对模型隐藏的传输元数据,DeepSeek 适配器会发送 `x-deepseek-harness-compact: 1`。 ### 成功的持久步骤工作完成后运行自动压力检查 @@ -76,7 +76,7 @@ retry → next numbered step/start ⟵ derives from the replacement surface ``` compact/start → log-only. Acquires the lock. [summarize older range via the backend] -compact/summary → log-only. Provenance: raw summary, range, shadowed seqs, token count. +compact/summary → log-only. Provenance: raw summary, local-call marker, range, shadowed seqs, token count. user/message → canonical checkpoint source + surfaceOp { op:'replace', start, end }. THE surface mutation (framed summary). deriveMessages() renders it as a user-role message. @@ -131,4 +131,4 @@ compact/end → log-only. Releases the lock (carries `error` on a recoverab - **循环测试:** 测试固定 pre-step 发生在前一个 `step/end` 之后、下一个 `step/start` 之前,使用实际 `agent/request` 路由,关闭失败步骤,分配新的重试编号,并覆盖完整的抛出/带内溢出 → 压缩 → 重建重试组合。 - **手动测试:** 无需模型密钥即可固定 maintenance 串行化、标记顺序、注入保留、活动/陈旧未匹配标记分类、取消、闭合/flush 失败、命令映射以及排队 TUI 流程。 - **带密钥 e2e:** 真实模型和 bash 会话在降低的限制下触发压缩,记录完整的 `compact/start…end` 对,缩小 surface,并完成任务。 -- **快照缺口:** 失控轮次压缩尚无法回放,因为摘要调用未记录 `assistant/chunk` 事件或 `sessionId`;交错摘要调用的回放仍是后续工作。 +- **快照:** 组装后的上下文溢出场景仅在 `llmStreamCall: true` 证明本地 LLM 服务执行了辅助调用时,才从 `compact/summary` 派生该调用;规范重建的块在不固定提供方增量切分的情况下固定完整恢复过程。 diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml b/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml index 170631b6a4..0369670aea 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-24-workspace-context.md -2026-06-24-workspace-context.md: 3c21b7599e6bb2dd759e4a040c1b999f1b0516cd -2026-06-24-workspace-context.zh.md: e913ecb22b4692e1ee7b96d4f5effe2606775cf8 +2026-06-24-workspace-context.md: 53a580aee50752b7c6daeff5caa42ba6409c8885 +2026-06-24-workspace-context.zh.md: dc1d6b5dc2c9f576e7298c891a3a1145f123a8db diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md index 3c21b7599e..53a580aee5 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md @@ -14,7 +14,7 @@ The lifecycle has two distinct classes of content. The initial applicable chain ## Decision -The implementation lives in `packages/context/workspace-context` as `@deepseek-ai/dsh-workspace-context`. It is a request-context extension, not a core service or a filesystem backend. The shared demo spine and Host Runtime mount it from an explicit `{ maxBytes } | false` deployment choice; `dsh web` enables a 65,536-byte budget while the Host Runtime's headless consumer disables it. The plugin consumes `agent/pre-step`, `tools/post-execute`, and the optional `ctx.fs` capability. +The implementation lives in `packages/context/workspace-context` as `@deepseek-ai/dsh-workspace-context`. It is a request-context extension, not a core service or a filesystem backend. The shared demo spine and Host Runtime mount it from an explicit `{ maxBytes } | false` deployment choice; `dsh web` enables a 65,536-byte budget while the Host Runtime's headless consumer disables it. The plugin consumes `agent/pre-step`, immutable `tools/result` outcomes, `session/event` boundaries, and the optional `ctx.fs` capability. The plugin does not statically inject `fs`. Providerless product trees therefore boot normally and the plugin no-ops until a filesystem provider exists. All production reads go through that provider. Candidate probes resolve each path and stat the result, so a final-component symlink is followed to its target: a link to a regular file loads, while a missing path or a non-file target is a confirmed absence. Following repository-owned links across the trust boundary is a deliberate reversal of the original no-follow probe; the [instruction-symlink follow note](2026-07-21-follow-instruction-symlinks.md) owns that decision and its residual risk. The step signal and dynamic tool execution signal propagate through resolution, metadata probes, and streaming reads, so cancellation does not wait for an unrelated filesystem scan. A resolve or stat exception is classified as unavailable: it skips only that candidate and is never interpreted as the deletion of an already-loaded scope. @@ -38,7 +38,7 @@ The baseline is a user-role `` with `Instructions from: ` ### Dynamic Discovery And Refresh -After a successful first-party `read`, `write`, or `edit` call, the `tools/post-execute` listener reconciles the touched descendant chain and every scope already known to the session. A newly reached scope is returned through `additionalContexts` for the next request using an `Additional instructions from: ` system-reminder. Under Code Mode, `run_code` defers sub-dispatch contexts onto its outer result, so the same update is appended only after the parent result rather than being injected mid-call. +After a successful first-party `read`, `write`, or `edit` call, the immutable `tools/result` observer reconciles the touched descendant chain and every scope already known to the session, then queues an `Additional instructions from: ` system-reminder in the agent inbox for the next request. Under Code Mode, successful sub-dispatch touches bubble through opaque parent execution tokens until the top-level result settles. A touch produced inside an agent-loop step does not begin its asynchronous projection until the durable `step/end`; a direct tool execution outside an open step projects immediately. The two boundaries keep result and step adjacency deterministic without making the tool pipeline await filesystem discovery. A content edit appends `Updated instructions from: `, states that the new content replaces the previous content, and includes the complete current file. If precedence changes from one candidate to another, the message also names the previous path and says it no longer applies. If no candidate remains, the plugin appends `Instructions removed: ` and states that the previously loaded instructions no longer apply. @@ -50,7 +50,7 @@ Shell commands are not discovery triggers. Local bash calls start fresh shells, Every workspace context event stores versioned metadata with `{ action, scope, path, digest? }`, where `digest` is SHA-1 over the loaded content. Complete baselines additionally carry `baseline: true` and `baselineIdentity`. The model-facing prompt has no HTML comments, hidden markers, or headings that are parsed back into state. -At reconciliation time the plugin scans workspace-sourced `user/message` events and derives the latest state for each visible scope. A short per-session pending map begins only after the immutable top-level `tools/result` proves an `additionalContexts` entry survived every post-execute listener, then covers the interval before the loop appends that context to the log. Each entry records the open `{ turn, step }`: an equal durable `user/message` at or after its sequence boundary confirms and removes it, while a matching `step/end` arriving first means the loop discarded its context buffer, so the plugin removes both the pending entry and its version-cache fast path. A nested Code Mode result stages its changes under the parent's opaque execution token so repeated sub-dispatches in one run do not duplicate them; the parent result rolls that provisional state back and commits only contexts retained by outer policy. +At reconciliation time the plugin scans workspace-sourced `user/message` events and derives the latest state for each visible scope. Successful nested touches aggregate under their parent execution token, including when a later composite result is blocked; the top-level result transfers them either to the open session step or directly to a per-agent projection queue. A `step/end` releases its staged touches only after that boundary is durable, and the next `agent/pre-step` waits for the serialized projections. Each projection composes against visible history plus the current inbox and replaces the single pending workspace context instead of accumulating intermediate renderings. An unchanged path and digest is suppressed. A logged removal is a tombstone, so a reappearing candidate becomes a new `set`. Resume works from persisted metadata: a compatible visible baseline supplies comparison state rather than causing another complete baseline to be appended. If compaction removes an instruction event from the visible surface, that state no longer suppresses a later load, matching the fact that the model can no longer see it. Only changes actually included under the byte budget enter metadata or pending state, so an omitted file remains eligible on a later touch. @@ -62,7 +62,7 @@ There is intentionally no watcher. Detection occurs at the next successful struc `maxBytes` is required and applies separately to a rendered baseline or one dynamic reconciliation batch; there is no implicit or unbounded render budget. Non-positive and non-finite values disable loading. When content exceeds the budget, broader files are omitted before the most-specific file is truncated. A visible `Workspace instruction budget ...` notice names omitted and truncated paths and byte counts, and output never exceeds the configured bytes. -`maxSourceBytes` is a positive per-file cap with a 1 MiB default. The loader checks reported size before reading and still consumes content through `streamText()` with a running UTF-8 byte count, so missing/stale metadata cannot force an unbounded allocation. An oversized winning candidate is unavailable rather than a reason to fall through to another same-directory name. The plugin deliberately keeps no process-wide cache and never retains instruction prose. It keeps only `{ path, version, digest }` per effective scope in a `WeakMap>`: a matching provider `FsVersion` plus matching effective prompt state skips the read, while a changed version triggers a bounded read and SHA-1 confirmation. SHA-1 remains the cross-provider content identity persisted in visible structured metadata; provider versions are only an in-memory invalidation fast path. Cache transitions for model-visible changes commit only when the corresponding context survives the complete tool-result policy chain, and are invalidated if that accepted context is later dropped with its aborted step before reaching the log. +`maxSourceBytes` is a positive per-file cap with a 1 MiB default. The loader checks reported size before reading and still consumes content through `streamText()` with a running UTF-8 byte count, so missing/stale metadata cannot force an unbounded allocation. An oversized winning candidate is unavailable rather than a reason to fall through to another same-directory name. The plugin deliberately keeps no process-wide cache and never retains instruction prose. It keeps only `{ path, version, digest }` per effective scope in a `WeakMap>`: a matching provider `FsVersion` plus matching effective prompt state skips the read, while a changed version triggers a bounded read and SHA-1 confirmation. SHA-1 remains the cross-provider content identity persisted in visible structured metadata; provider versions are only an in-memory invalidation fast path. Dynamic cache transitions occur while a serialized projection composes the one desired inbox context, and the next pre-step waits for that projection before deciding what enters the request. ## Alternatives considered @@ -78,7 +78,7 @@ There is intentionally no watcher. Detection occurs at the next successful struc ## Consequences -Workspace guidance is isolated per session and shared by the demo front doors, Web Host, and every tool presentation mode. Initial, nested, and changed instructions are durable and replayable. The generic session/agent context contract carries typed source data through injected messages and post-tool `additionalContexts` arrays without flattening entries. +Workspace guidance is isolated per session and shared by the demo front doors, Web Host, and every tool presentation mode. Initial, nested, and changed instructions are durable and replayable. The generic session/agent context contract carries typed source data through inbox-staged and durably entered user messages without flattening entries. Repository text remains untrusted input. Lower-authority user-role framing, explicit precedence language, and delimiter escaping reduce risk but do not eliminate prompt injection. Following a candidate symlink to its target widens that surface to off-tree content, so the permission and sandbox layers that confine `ctx.fs` to trusted roots are the boundary that treats workspace files as data rather than authority (the [instruction-symlink follow note](2026-07-21-follow-instruction-symlinks.md) owns the residual risk). diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md index e913ecb22b..dc1d6b5dc2 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md @@ -14,7 +14,7 @@ Status: implemented ## 决策 -该实现在 `packages/context/workspace-context` 中,包(package)名为 `@deepseek-ai/dsh-workspace-context`。它是请求上下文扩展,不是核心服务或文件系统后端。共享 demo 主干与 Host Runtime 根据显式的 `{ maxBytes } | false` 部署选择挂载它;`dsh web` 启用 65,536 字节预算,Host Runtime 的 headless 消费方则禁用它。该插件使用 `agent/pre-step`、`tools/post-execute` 和可选的 `ctx.fs` 功能。 +该实现在 `packages/context/workspace-context` 中,包(package)名为 `@deepseek-ai/dsh-workspace-context`。它是请求上下文扩展,不是核心服务或文件系统后端。共享 demo 主干与 Host Runtime 根据显式的 `{ maxBytes } | false` 部署选择挂载它;`dsh web` 启用 65,536 字节预算,Host Runtime 的 headless 消费方则禁用它。该插件使用 `agent/pre-step`、不可变的 `tools/result` 结果、`session/event` 边界和可选的 `ctx.fs` 功能。 插件不会静态注入 `fs`。因此,不带提供方的产品树仍能正常启动;在文件系统提供方出现之前,插件保持无操作。所有生产读取都通过该提供方完成。候选项探测会解析每个路径并对结果执行 stat,因此会跟随最终路径组件的符号链接至其目标:指向普通文件的链接会被加载,缺失路径或非文件目标则确认为不存在。允许仓库拥有的链接跨越信任边界,是对最初不跟随探测方式的刻意反转;[跟随指令符号链接记录](2026-07-21-follow-instruction-symlinks.md)负责说明该决策及其残余风险。步骤信号与动态工具执行信号会贯穿解析、元数据探测和流式读取,因此取消不会等待无关的文件系统扫描。解析或 stat 异常归类为不可用:它只跳过该候选项,绝不被解释为已经加载的作用域被删除。 @@ -38,7 +38,7 @@ Status: implemented ### 动态发现与刷新 -第一方 `read`、`write` 或 `edit` 调用成功后,`tools/post-execute` 监听器会协调被触及的后代路径链,以及该会话已经知道的每个作用域。新到达的作用域通过 `additionalContexts` 返回,并在下一次请求中使用 `Additional instructions from: ` system-reminder。在 Code Mode 下,`run_code` 会把子分发上下文延后至其外层结果,因此同一更新只会在父结果之后追加,而不会在调用中途注入。 +第一方 `read`、`write` 或 `edit` 调用成功后,不可变的 `tools/result` 观察器会协调被触及的后代路径链,以及该会话已经知道的每个作用域,然后在 agent inbox 中排入一条 `Additional instructions from: ` system-reminder,供下一次请求使用。在 Code Mode 下,成功的子分派 touch 会沿不透明的父级执行 token 逐层上浮,直到顶层结果落定。在 agent loop 步骤内产生的 touch,须等持久 `step/end` 后才开始异步投影;打开的步骤之外直接执行工具时,则立即投影。这两个边界在不让工具流水线等待文件系统发现的前提下,保证结果/步骤的相邻关系具有确定性。 内容编辑会追加 `Updated instructions from: `,说明新内容取代先前内容,并包含当前的完整文件。如果优先级从一个候选项变为另一个,消息还会指出先前路径并说明它不再适用。如果没有候选项保留,插件会追加 `Instructions removed: `,并说明先前加载的指令不再适用。 @@ -50,7 +50,7 @@ shell 命令不会触发发现。本地 bash 调用会启动全新的 shell, 每个工作区上下文事件都会存储带版本的元数据,其形态为 `{ action, scope, path, digest? }`;`digest` 是对已加载内容计算的 SHA-1。完整基线还会额外携带 `baseline: true` 和 `baselineIdentity`。模型可见提示词中没有 HTML 注释、隐藏标记,也没有会被解析回状态的标题。 -协调时,插件扫描带工作区来源的 `user/message` 事件,并派生每个可见作用域的最新状态。一个简短的逐会话待处理映射只会在不可变的顶层 `tools/result` 证明某个 `additionalContexts` 条目经过所有 post-execute 监听器后仍然保留时开始记录;随后,它覆盖循环将该上下文追加到日志之前的间隔。每个条目记录开启状态的 `{ turn, step }`:如果相同的持久 `user/message` 出现在其序列边界或之后,该条目得到确认并被移除;如果匹配的 `step/end` 先到达,则说明循环丢弃了上下文缓冲区,插件会同时移除待处理条目及其版本缓存快速路径。嵌套的 Code Mode 结果会把变更暂存在父级的不透明执行 token 下,确保一次运行中的重复子分发不会产生重复项;父级结果会回滚这份临时状态,并且只提交外层策略保留的上下文。 +协调时,插件扫描带工作区来源的 `user/message` 事件,并派生每个可见作用域的最新状态。即使后续复合结果被拦截,成功的嵌套 touch 也会聚合到父级执行 token 下;顶层结果会将它们交给打开的会话步骤,或直接交给逐 agent 投影队列。`step/end` 只会在自身边界持久化后释放其暂存的 touch;下一次 `agent/pre-step` 会等待串行投影完成。每次投影都会根据可见历史和当前 inbox 进行组合,并替换唯一一条待处理工作区上下文,而不会累积中间渲染结果。 路径和 digest 均未变化时会被抑制。日志中的移除操作是一条墓碑记录,因此重新出现的候选项会成为新的 `set`。恢复操作从持久化元数据继续工作:兼容的可见基线会提供比较状态,而不会导致再次追加完整基线。如果压缩从可见表面移除某条指令事件,该状态不再抑制后续加载,这与模型已经无法看见它的事实一致。只有真正纳入字节预算的变更才会进入元数据或待处理状态,因此被省略的文件在之后的触碰中仍有资格加载。 @@ -62,7 +62,7 @@ shell 命令不会触发发现。本地 bash 调用会启动全新的 shell, `maxBytes` 是必填项,分别作用于渲染后的基线或单个动态协调批次;系统不存在隐式或无界的渲染预算。非正数或非有限值会禁用加载。内容超过预算时,系统会先省略较宽泛的文件,再截断最具体的文件。可见的 `Workspace instruction budget ...` 提示会指出被省略和截断的路径与字节数,并且输出绝不超过配置字节数。 -`maxSourceBytes` 是正数的逐文件上限,默认为 1 MiB。loader 会在读取前检查报告的大小,同时仍通过 `streamText()` 消费内容并持续统计 UTF-8 字节数,因此缺失/陈旧的元数据无法迫使其进行无界分配。过大的胜出候选项会被视为不可用,而不是改为同目录中的下一个名称。插件刻意不保留进程级缓存,也绝不保留指令正文。它只为每个有效作用域保存 `{ path, version, digest }`,并将这些状态放在 `WeakMap>` 中:提供方 `FsVersion` 与有效提示词状态同时匹配时跳过读取;版本变化则触发有界读取和 SHA-1 确认。SHA-1 仍是持久化在可见结构化元数据中的跨提供方内容标识;提供方版本只作为内存中的失效快速路径。模型可见变更的缓存转换只有在相应上下文通过完整的工具结果策略链后才会提交;如果该已接受上下文随后与中止步骤一起被丢弃、未能进入日志,缓存转换就会失效。 +`maxSourceBytes` 是正数的逐文件上限,默认为 1 MiB。loader 会在读取前检查报告的大小,同时仍通过 `streamText()` 消费内容并持续统计 UTF-8 字节数,因此缺失/陈旧的元数据无法迫使其进行无界分配。过大的胜出候选项会被视为不可用,而不是改为同目录中的下一个名称。插件刻意不保留进程级缓存,也绝不保留指令正文。它只为每个有效作用域保存 `{ path, version, digest }`,并将这些状态放在 `WeakMap>` 中:提供方 `FsVersion` 与有效提示词状态同时匹配时跳过读取;版本变化则触发有界读取和 SHA-1 确认。SHA-1 仍是持久化在可见结构化元数据中的跨提供方内容标识;提供方版本只作为内存中的失效快速路径。动态缓存转换发生在串行投影组合唯一一条目标 inbox 上下文时;下一次 pre-step 会等待该投影完成,再决定哪些内容进入请求。 ## 考虑过的替代方案 @@ -78,7 +78,7 @@ shell 命令不会触发发现。本地 bash 调用会启动全新的 shell, ## 后果 -工作区指引按会话隔离,并由 demo 入口、Web Host 与每一种工具展示模式共享。初始、嵌套与变更指令都保持持久且可回放。通用的会话/agent 上下文契约通过注入消息与工具执行后的 `additionalContexts` 数组携带带类型的来源数据,而不会把条目展平。 +工作区指引按会话隔离,并由 demo 入口、Web Host 与每一种工具展示模式共享。初始、嵌套与变更指令都保持持久且可回放。通用的会话/agent 上下文契约通过先在 inbox 中暂存、再持久进入的 user 消息携带带类型的来源数据,而不会把条目展平。 仓库文本仍是不受信任的输入。低权威 user 角色框架、显式优先级说明和分隔符转义可以降低风险,但无法消除提示词注入。跟随候选符号链接到目标,会把该攻击面扩大至树外内容;因此,把 `ctx.fs` 限制在可信根目录内的权限与沙箱层才是真正的边界,它们让系统把工作区文件当作数据而不是权威([跟随指令符号链接记录](2026-07-21-follow-instruction-symlinks.md)负责说明残余风险)。 diff --git a/.agents/notes/implemented/feature/2026-07-05-skill-system.i18n.yaml b/.agents/notes/implemented/feature/2026-07-05-skill-system.i18n.yaml index 6bb4aac193..a98beff699 100644 --- a/.agents/notes/implemented/feature/2026-07-05-skill-system.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-05-skill-system.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-05-skill-system.md -2026-07-05-skill-system.md: dd2fb1d22949f55ea7cb2c9f280e7cfbfcbbb226 -2026-07-05-skill-system.zh.md: 96656a8e1dfc2ae1ce7301ba29e7739349b6aab6 +2026-07-05-skill-system.md: a998d70ec934aed4bf7ce32aa711abd47b508a1d +2026-07-05-skill-system.zh.md: 4fa7c4fd657c2f41f16b30679ec95e61a75f8a0c diff --git a/.agents/notes/implemented/feature/2026-07-05-skill-system.md b/.agents/notes/implemented/feature/2026-07-05-skill-system.md index dd2fb1d229..a998d70ec9 100644 --- a/.agents/notes/implemented/feature/2026-07-05-skill-system.md +++ b/.agents/notes/implemented/feature/2026-07-05-skill-system.md @@ -14,9 +14,11 @@ DeepSeek Harness uses the same primitive so project-specific review, plugin-auth `@deepseek-ai/dsh-skill` is the pure provider registry (`ctx.skills`), `@deepseek-ai/dsh-skill-local` is the shipped local filesystem provider, and `@deepseek-ai/dsh-tool-skill` owns the durable session catalog and model-facing loader tool. `dsh-agent-spine-demo` loads the registry, local provider, and consumer by default so TUI, headless, and ACP apps get the same behavior while embedded or remote providers contribute skills without changing the registry or consumer. Its `skills` config forwards `registry`, `local`, and `tool` branches to those owners. +Dedicated packaged providers can contribute immutable skills without filesystem discovery. The shipped CLI declares `@deepseek-ai/dsh-skill-badge` disabled by default; enabling its composition row contributes the official badge instructions through the same registry and consumer ([decision](2026-08-06-bundled-dsh-badge-skill.md)). + Provider plugins register synchronously during `apply()`. Provider membership is direct effect-owned state: registration and disposal invalidate completed catalogs synchronously, and discovery reads the current provider map on demand rather than observing registry-change events. Provider catalogs return ranked candidates from awaited `list()` calls, where remote providers perform initialization, authentication, and discovery while honoring the lookup abort signal. The registry validates each candidate, resolves same-name skills first-wins by rank, provider registration order, and provider-local order, then sorts summaries by skill name for deterministic consumers. It caches only completed catalog snapshots and retries when a provider/runtime revision changes during discovery, so an unload cannot freeze a stale, unresolvable skill into a session catalog. Runtime `ctx.skills.register(...)` remains a convenience for embedded in-process skills and uses project-over-user priority; `runtime` is reserved as the registry-owned provider name. -The local provider scans cwd-sensitive project roots, custom roots, and user roots in first-wins rank order: project `.dsh`, project `.agents`, `customSkillDirs`, user `.dsh`, then user `.agents`. The user `.dsh/skills` scan skips `.system` so a system-owned directory is not treated as normal user content. DeepSeek Harness does not ship built-in system skills; embedded or remote providers supply additional skills when configured. +The local provider scans cwd-sensitive project roots, custom roots, and user roots in first-wins rank order: project `.dsh`, project `.agents`, `customSkillDirs`, user `.dsh`, then user `.agents`. The user `.dsh/skills` scan skips `.system` so a system-owned directory is not treated as normal user content. The local provider does not synthesize built-in system skills; configured bundled roots and dedicated providers supply additional skills. Each skill is either `/SKILL.md` or `.md` with YAML frontmatter. `name` and `description` are required; `whenToUse`, `metadata`, `disable-model-invocation`, and `user-invocable` are optional. Names are kebab-case. The invocation fields project into a typed nested policy as defined by the [independent model and user invocation decision](2026-07-28-skill-invocation-policy.md); the parser rejects the old camel-case spellings. YAML frontmatter is parsed with the `yaml` package instead of `js-yaml` or a hand-written parser: `yaml` is the already-declared modern parser for this package's limited frontmatter needs, and a narrow parser would either reject valid YAML users expect to work or grow into an unreviewed YAML subset. @@ -48,7 +50,7 @@ The data structures and catalog/tool contract are documented in [skills.md](../. The agent-core spine includes one catalog contributor, one local provider, and one model-facing tool. Skill discovery is cwd-sensitive, so callers that create agents with different session cwd values can observe different project skill overrides by design. -The catalog is deterministic for a fixed root set and runtime registration revision, but disk changes are not watched; discovery is memoized until runtime registration invalidates the cache or the process restarts. +The catalog is deterministic for a fixed root set and runtime registration revision. The local provider watches configured roots and invalidates completed catalogs after relevant disk changes; runtime registration and provider disposal also invalidate them. ## Deferred diff --git a/.agents/notes/implemented/feature/2026-07-05-skill-system.zh.md b/.agents/notes/implemented/feature/2026-07-05-skill-system.zh.md index 96656a8e1d..4fa7c4fd65 100644 --- a/.agents/notes/implemented/feature/2026-07-05-skill-system.zh.md +++ b/.agents/notes/implemented/feature/2026-07-05-skill-system.zh.md @@ -14,9 +14,11 @@ DeepSeek Harness 使用同一原语,使项目特定的评审、插件编写和 `@deepseek-ai/dsh-skill` 是纯提供方注册表(`ctx.skills`),`@deepseek-ai/dsh-skill-local` 是随附的本地文件系统提供方,`@deepseek-ai/dsh-tool-skill` 负责持久化会话目录与面向模型的 loader 工具。`dsh-agent-spine-demo` 默认加载注册表、本地提供方和消费方,使 TUI、headless 与 ACP(Agent Client Protocol)应用获得相同行为,同时嵌入式或远程提供方可在不修改注册表或消费方的前提下贡献 skill。其 `skills` 配置将 `registry`、`local` 和 `tool` 分支分别转发给对应的所有者。 +专用的随包提供方可以贡献不可变的 skill,无需文件系统发现。交付的 CLI(命令行界面)默认将 `@deepseek-ai/dsh-skill-badge` 声明为禁用;启用其组合配置行,就会通过同一个注册表和消费方贡献官方徽章指令(见[决策](2026-08-06-bundled-dsh-badge-skill.md))。 + 提供方插件在 `apply()` 期间同步注册。提供方成员资格是由直接 effect 持有的状态:注册与 dispose(资源释放)同步地使已完成的目录失效,发现操作按需读取当前提供方映射而非监听注册表变更事件。提供方目录从等待的 `list()` 调用返回排序后的候选项,远程提供方在此过程中执行初始化、认证和发现,同时遵守查找的 abort 信号。注册表校验每个候选项,按排名、提供方注册顺序和提供方内部顺序以先到先得方式解决同名 skill 冲突,然后按 skill 名称排序摘要以保证消费方获得确定性结果。它仅缓存已完成的目录快照,并在发现过程中提供方/运行时修订版本发生变化时重试,因此卸载操作不会将一个陈旧且不可解析的 skill 冻结到会话目录中。运行时 `ctx.skills.register(...)` 仍作为嵌入式进程内 skill 的便捷方式保留,使用 project 优先于 user 的优先级;`runtime` 保留为注册表拥有的提供方名称。 -本地提供方按先到先得的排名顺序扫描 cwd 敏感的项目根目录、自定义根目录和用户根目录:项目 `.dsh`、项目 `.agents`、`customSkillDirs`、用户 `.dsh`,然后是用户 `.agents`。用户 `.dsh/skills` 扫描跳过 `.system`,以免系统拥有的目录被当作普通用户内容处理。DeepSeek Harness 不随附内置系统 skill;嵌入式或远程提供方在配置后提供额外 skill。 +本地提供方按先到先得的排名顺序扫描 cwd 敏感的项目根目录、自定义根目录和用户根目录:项目 `.dsh`、项目 `.agents`、`customSkillDirs`、用户 `.dsh`,然后是用户 `.agents`。用户 `.dsh/skills` 扫描跳过 `.system`,以免系统拥有的目录被当作普通用户内容处理。本地提供方不会合成内置系统 skill;已配置的 bundled 根目录和专用提供方会提供额外 skill。 每个 skill 是 `/SKILL.md` 或带 YAML frontmatter 的 `.md`。`name` 和 `description` 为必填;`whenToUse`、`metadata`、`disable-model-invocation` 和 `user-invocable` 为可选。名称采用 kebab-case。调用字段会投影到类型化的嵌套策略中,具体由[模型与用户独立调用决策](2026-07-28-skill-invocation-policy.md)定义;解析器会拒绝旧的驼峰拼写。YAML frontmatter 使用 `yaml` 包(package)解析,而非 `js-yaml` 或手写解析器:`yaml` 是本包有限 frontmatter 需求已声明的现代解析器,窄解析器要么拒绝用户预期可用的合法 YAML,要么膨胀为一个未经评审的 YAML 子集。 @@ -48,7 +50,7 @@ DeepSeek Harness 使用同一原语,使项目特定的评审、插件编写和 agent-core 主干包含一个目录贡献者、一个本地提供方和一个面向模型的工具。Skill 发现是 cwd 敏感的,因此以不同会话 cwd 值创建 agent 的调用方可以按设计观察到不同的项目 skill 覆盖。 -目录对于固定的根目录集合和运行时注册修订版本是确定性的,但不监视磁盘变化;发现结果被缓存,直到运行时注册使缓存失效或进程重启。 +目录对于固定的根目录集合和运行时注册修订版本是确定性的。本地提供方会监视已配置的根目录,并在发生相关磁盘变化后使已完成的目录失效;运行时注册和提供方释放也会使其失效。 ## 延后 diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml index 7f15a55333..7294f47357 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-06-sandbox.md -2026-07-06-sandbox.md: aed5ac1ceb02130ce97a8c83c0f77869fdc32146 -2026-07-06-sandbox.zh.md: db95b1a5b7a7cae1e0fcdd8deba9dcb6ad020a67 +2026-07-06-sandbox.md: 583b388815cd9b2b9cf94ce393839169ce3ffac3 +2026-07-06-sandbox.zh.md: e435b671a42ca5c3ea4f6800bf91d6e006da35d3 diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.md index aed5ac1ceb..583b388815 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.md @@ -64,7 +64,7 @@ Left open, for the phase that needs them: whether network restriction arrives as The launcher is a ~300-line C program (plain C11 over the raw Landlock UAPI — no libraries beyond a statically linked musl, so the audit surface is that one file plus the kernel's stable syscall contract): `--ro ` / `--rw ` grants, `--`, the wrapped argv; it installs the ruleset on itself and `exec`s (rulesets are inherited across `execve`, and it sets `no_new_privs` before restricting); `--probe` enforces a maximal ruleset in a short-lived child and exits 0 only when the kernel actually enforces; every launcher failure exits 125 without running the child and prints a fatal `landlock-run:` line. A successfully exec'd child may also return 125, so status alone is not launcher evidence. An older ABI prints the exact `landlock-run: partial enforcement (older Landlock ABI)` notice before it executes the child, so that line is not fatal evidence. -The Landlock launcher source and package workspace live at `native/landlock-run`, next to the harness consumers. The standalone [`node-addon-landlock-run`](https://github.com/deepseek-harness/node-addon-landlock-run) repository is the release mirror used to pack and publish the npm package family; `native/README.md` owns the export procedure. Platform binaries are selected by npm, and the entry package owns path resolution, probing, CLI flags, the fatal prefix, and the partial-enforcement notice while the harness maps sandbox modes to grants. Versioning the entry point with its binaries keeps probe parsing and launch syntax aligned. +The Landlock launcher source and package family live at `native/landlock-run`, next to the harness consumers and inside the root pnpm workspace. The [in-repository Landlock release decision](../process/2026-08-06-in-repository-landlock-release.md) owns the shared lockfile, native build, pack rehearsal, and npm publication boundary. Platform binaries are selected by npm, and the entry package owns path resolution, probing, CLI flags, the fatal prefix, and the partial-enforcement notice while the harness maps sandbox modes to grants. Versioning the entry point with its binaries keeps probe parsing and launch syntax aligned. Backend profiles share the mode contract but differ in necessary host grants. Landlock and Seatbelt allow only `/dev/null` in read-only mode; workspace-write also permits their required host temp roots. Each wrap carries backend-specific denial signatures. Landlock reports partial enforcement on older ABIs that cannot govern every operation, while successful bwrap and Seatbelt profiles report full enforcement. @@ -118,7 +118,7 @@ fs/web/todo execute in-process, so their sandbox semantics are policy at their s ### Testing - **Unit:** pin platform selection and profiles, direct provider-argv handoff, spawn-level failures with invalid-workdir controls, missing/non-executable/missing-interpreter evidence, malformed-runner negative controls, confined `BASH_ENV` ordering, structured runner classification (including partial-Landlock notice-only child outcomes, gated fatal evidence, child exits 126/127, and foreground/background parity), per-call mode/root resolution, per-process facts, escalation validation and outcomes, permission preset folding and write-through, and runtime-context ordering and materialization. -- **Keyless real-runner:** exercise bwrap, Landlock, and Seatbelt against real filesystem effects at provider and bash-consumer layers; one real Cordis context concurrently drives two project sessions through shipped bash and fs tools, proving own-root success and sibling-root denial. Packed-install coverage proves the registry launcher remains executable. CI rejects a silent all-skip. +- **Keyless real-runner:** exercise bwrap, Landlock, and Seatbelt against real filesystem effects at provider and bash-consumer layers; one real Cordis context concurrently drives two project sessions through shipped bash and fs tools, proving own-root success and sibling-root denial. Packed-install coverage installs the current checkout's native tarballs and proves the launcher remains executable and byte-identical. CI rejects a silent all-skip. - **With-key:** start the real ACP composition in read-only mode, let a model-driven bash write hit the runner's denial marker, then drive the bridge answerer and disk effect through granted and rejected workspace-write retries; unavailable credentials or runners self-skip. - **Snapshot:** pin the atomic current-policy context and both scripted approval branches. A real ACP example scenario places its session under the user home while the deployment fallback points at `/tmp`, then pins both the workspace-write runtime-context message and a successful deployment-selected mutation; this distinguishes session-root resolution from the process fallback without depending on runner-specific denial text. A POSIX fake partial-Landlock provider pins direct bash `false` as an ordinary child result and a missing provider executable as foreground/background infrastructure failure through the assembled app. Other snapshots start unconfined so unrelated fixtures remain platform-independent. @@ -128,15 +128,15 @@ Each phase gets its full design when picked up, validated against the code at th - **Second consumer** — `subagent-acp` optionally confines child agents (per-call policy; unconfined default — a child agent must write its own persistence). - **More environments** — an environment-coherent capability group example (e.g. bash+fs against one container). -- **Windows chain** — `PLATFORM_CHAINS.win32` is reserved and empty (fail-closed); filling it means a confinement runner from the AppContainer/restricted-token family, shipped from its own repository on the `node-addon-landlock-run` template, plus its profile dialect, denial signatures, and runner-failure rules. Wrapping the third-party landstrip runner instead was [considered and rejected](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md) — not battle-tested enough for a security invariant. +- **Windows chain** — `PLATFORM_CHAINS.win32` is reserved and empty (fail-closed); filling it means a confinement runner from the AppContainer/restricted-token family, shipped from the main repository under `native/` following the `@deepseek-ai/node-addon-landlock-run` template, plus its profile dialect, denial signatures, and runner-failure rules. Wrapping the third-party landstrip runner instead was [considered and rejected](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md) — not battle-tested enough for a security invariant. ## Alternatives considered - **Command-string heuristic preflight** — rejected: cannot understand expansion/subprocesses/symlinks; the strict attempt (run it, let the kernel decide) is the only trustworthy denial signal. - **Functionally probe even a platform's sole backend** — rejected: probing arbitrates between candidates; with one there is nothing to decide, and probe cost taxes the first confined command of every session (prohibitive for heavy future backends). The runner's own exec-time fail-closed refusal plus structured `runnerFailureRules` classification carries the safety property instead. -- **Commit the built launcher binaries** — rejected: a binary in a diff is unreviewable and churns history; reviewed source + native CI builds + the launcher repo's byte-pinned publish rehearsal keep bytes out of every tree. +- **Commit the built launcher binaries** — rejected: a binary in a diff is unreviewable and churns history; reviewed source + native CI builds + the main repository's byte-pinned publish rehearsal keep bytes out of every tree. - **Compile the launcher on install** — rejected: pushes a C toolchain onto every consumer; a fallback that exists only where a compiler happens to be is not a fallback. -- **Cross-compile both architectures from one builder** — rejected: requires carrying a pinned cross toolchain (rustup targets, zig, or a container image) solely to rebuild two ~70 KB binaries; per-architecture native runners already exist and each builds its own platform package (the `node-addon-require-builtin` model, the launcher repo's own pipeline). +- **Cross-compile both architectures from one builder** — rejected: requires carrying a pinned cross toolchain (rustup targets, zig, or a container image) solely to rebuild two ~70 KB binaries; per-architecture native runners already exist and each builds its own platform package (the `node-addon-require-builtin` model, retained by the main repository's native pipeline). - **No fallback (bwrap or fail closed)** — rejected: concentrates failure on the hosts a sandbox matters most, degrading to `danger-full-access` by resignation. - **Keep the mechanism inside `dsh-bash-sandbox`** — rejected: blocks the existing second consumer, makes future phases read mode out of a bash plugin's config, and cannot express escalation. - **Config-fixed mode on the provider** — rejected: one mode per process; cannot serve concurrent consumers with different policies nor the one-shot widened retry. @@ -175,7 +175,7 @@ Costs and accepted limits: - **The Seatbelt rung leans on Apple's deprecated-but-shipped `sandbox-exec` CLI.** As darwin's sole candidate it is selected without probing, so a future removal under a usable workdir surfaces as a runner-attributable spawn failure and an executable refusal through its fatal signature — both become `SANDBOX_UNAVAILABLE`, and the command never runs; fail closed, never open. - **Landlock confinement is only as complete as the running kernel's ABI.** Reported as `enforcement: 'partial'` rather than refused — the deliberate trade that keeps the fallback available on older-kernel hosts. - **Runner attribution uses an in-band protocol.** Exit status plus stderr cannot cryptographically identify the writer, so a confined child can mimic a fatal runner line and status to cause an availability/diagnostic false attribution. The conjunction and exact notice exclusion reduce accidental matches; this is not a sandbox bypass because the child is already confined. -- **The launcher arrives as a registry dependency.** Trusted through its own repository's release pipeline (reviewed C source, native CI builders, byte-pinned publish rehearsal) plus this repo's version pin — the real-kernel e2e legs are what vouch for behavior through the installed bytes. +- **The launcher is a workspace dependency in source and an npm dependency after publication.** The main repository tests reviewed C source, native CI builds, and byte-pinned local tarballs together before publishing the same package family; the real-kernel e2e legs vouch for behavior through those installed bytes. - **The model may over-ask.** Escalating without denial grounding, or picking `danger-full-access` where `workspace-write` suffices: the description steers and the enum forces the ladder, but the human prompt is the actual gate; the `approval/asked` reasons make over-asking auditable, and a `prepend` policy answerer can auto-reject patterns a deployment never wants. - **The advertised target set is static while the effective mode is per-session** (schemas are registry-global) — a session already at the widest mode is still offered the fields. Harmless by construction: the strict-wider check at execution, not the enum, is the safety boundary — a non-widening request fails with its own text and never prompts anyone. - **A granted escalation is not a working sandbox.** An unavailable backend still fails closed even for a granted escalation to a confining mode — at `confine()` when the platform has no chain or every probe fails, through the spawn channel when the selected executable cannot start, or through a structured rule when a started runner refuses — while a granted `danger-full-access` run never touches the provider at all: there the grant, not the probe, is the authority. @@ -187,7 +187,7 @@ Costs and accepted limits: - **A command came back with `[sandbox: file access denied under read-only mode]` — did it fail?** It RAN, and the kernel refused a file effect: the denial is a result fact orthogonal to exit code. The teaching forbids retrying around it; the one sanctioned move is the same command retried once with an escalation request. - **How is a BROKEN sandbox told apart from a failing command?** Any provider-argv spawn rejection proves the confined launch never started, but it identifies a broken runner only when the caller-owned workdir is usable and Node reports attributable `ENOENT` or `EACCES` for that argv[0]. A bare `syscall: 'spawn'` without an exact error path and all other rejections remain ordinary command-start errors. After a process starts, runner failure outranks denial only when one `runnerFailureRules` entry matches both its optional exit-code gate and a fatal stderr line after exact informational exclusions. Foreground failures throw structured `SANDBOX_UNAVAILABLE` with spawn or matched-line detail; an asynchronously rejected or settled background task stamps `sandbox.runnerFailed` and renders its own marker. A `SubprocessService` that synchronously throws the same provenanced `ENOENT`/`EACCES` shape makes background start throw the structured error; other synchronous errors propagate unchanged. A Landlock partial-enforcement notice plus an ordinary child failure remains a command result. - **What happens on a platform with no backend — Windows today?** `confine()` throws the fail-closed `SANDBOX_UNAVAILABLE` and the command never spawns; `win32` is a reserved EMPTY chain, pinned by test to fail closed identically until a Windows runner fills it (§ Deferred phases). -- **`bwrap` is installed on my host but unusable (disabled unprivileged userns, an LSM denying `mount`) — what happens?** The chain probe is functional — it builds and enforces a real profile rather than checking `--version` — so a present-but-unusable `bwrap` fails its probe, selection falls to the registry-installed Landlock launcher, and the verdict is cached for the provider's lifetime. +- **`bwrap` is installed on my host but unusable (disabled unprivileged userns, an LSM denying `mount`) — what happens?** The chain probe is functional — it builds and enforces a real profile rather than checking `--version` — so a present-but-unusable `bwrap` fails its probe, selection falls to the packaged Landlock launcher, and the verdict is cached for the provider's lifetime. - **Does the sandbox restrict network or process visibility?** No — `SandboxMode` claims FILE effects only; the bwrap profile deliberately does not unshare pid, and no backend claims network. Whether network restriction becomes its own knob is left open in § The seam. - **Which tools actually run confined?** OS subprocesses through `ctx.bash` — the bash tools, and hook commands transitively — plus the filesystem tools (`read`/`write`/`edit`) through the sandboxed `ctx.fs` provider (the [cross-family fs sandbox RFC](2026-07-14-cross-family-fs-sandbox.md)): bash confines via the OS runner, fs via an in-process path fence, both keying off the same `ctx.sandboxPolicy` mode. web/todo stay in-process and unfenced (web's only effect is network, outside the file-effect mode vocabulary). - **Does a granted escalation persist?** No. The grant is consumed by the exact foreground or background call that asked; every neighboring call keeps its own effective mode. A later background denial surfaces through `task_output` and may ground a new exact-command retry. diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md index db95b1a5b7..e435b671a4 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md @@ -64,7 +64,7 @@ OS 子进程约束适用于 bash 执行器(包括钩子命令),后续还 launcher 是一个约 300 行的 C 程序(纯 C11,直接使用 Landlock UAPI——除静态链接的 musl 外无其他库,因此审计面仅为该文件加内核的稳定 syscall 契约):`--ro ` / `--rw ` 授权,`--`,被包装的 argv;它为自身安装规则集并执行 `exec`(规则集跨 `execve` 继承,且它在限制前设置 `no_new_privs`);`--probe` 在一个短生命周期子进程中强制最大规则集,仅当内核确实强制时才以 0 退出;所有 launcher 失败都会以 125 退出且不运行子进程,并打印一行致命的 `landlock-run:` 诊断。成功完成 exec 的子进程也可能返回 125,因此仅凭退出状态不能作为 launcher 失败的证据。较旧的 ABI 会在执行子进程之前打印精确的 `landlock-run: partial enforcement (older Landlock ABI)` 通知,因此该行不是致命证据。 -Landlock launcher 源码和包工作区位于 `native/landlock-run`,与 harness 消费方同仓。独立的 [`node-addon-landlock-run`](https://github.com/deepseek-harness/node-addon-landlock-run) 仓库是用于打包并发布 npm 包族的发布镜像;导出流程归 `native/README.md` 所有。平台二进制由 npm 选择,入口包拥有路径解析、探测、CLI 参数、致命前缀和部分强制执行通知,而 harness 将沙箱模式映射为授权。将入口点与其二进制一起版本化,使探测解析和启动语法保持对齐。 +Landlock launcher 源码和包家族位于 `native/landlock-run`,与 harness 消费方同仓,并属于根 pnpm workspace。[仓库内 Landlock 发布决策](../process/2026-08-06-in-repository-landlock-release.md)负责共享锁文件、原生构建、打包演练和 npm 发布边界。平台二进制由 npm 选择,入口包拥有路径解析、探测、CLI 参数、致命前缀和部分强制执行通知,而 harness 将沙箱模式映射为授权。将入口点与其二进制一起版本化,使探测解析和启动语法保持对齐。 后端 profile 共享模式契约但在必要的主机授权上有所不同。Landlock 和 Seatbelt 在 read-only 模式下仅允许 `/dev/null`;workspace-write 还允许各自所需的主机临时目录根。每次包装携带后端特定的拒绝签名。Landlock 在较旧的 ABI 无法管控所有操作时报告 partial enforcement,而成功的 bwrap 和 Seatbelt profile 报告 full enforcement。 @@ -118,7 +118,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层 ### 测试 - **单元测试:** 固定平台选择和 profile、直接交接提供方返回的 argv、带有无效 workdir 对照的 spawn 层失败、runner 缺失/不可执行/解释器缺失证据、格式错误 runner 阴性对照、受约束的 `BASH_ENV` 求值顺序、结构化 runner 分类(包括只有部分强制执行通知的子进程结果、带门控的致命证据、子进程退出码 126/127,以及前台/后台一致性)、按调用的模式/根目录解析、按进程事实、升级验证和结果、权限 preset fold 和写入透传,以及运行时上下文排序与具体化。 -- **Keyless 真实 runner:** 在提供方和 bash 消费方层面对 bwrap、Landlock 和 Seatbelt 执行真实文件系统效果测试;一个真实 Cordis 上下文通过已交付的 bash 和 fs 工具并发驱动两个项目会话,证明在自身根目录写入成功、在兄弟根目录写入被拒绝。打包安装测试证明注册表 launcher 保持可执行。CI 拒绝静默全跳过。 +- **Keyless 真实 runner:** 在提供方和 bash 消费方层面对 bwrap、Landlock 和 Seatbelt 执行真实文件系统效果测试;一个真实 Cordis 上下文通过已交付的 bash 和 fs 工具并发驱动两个项目会话,证明在自身根目录写入成功、在兄弟根目录写入被拒绝。打包安装测试会安装当前 checkout 的原生 tarball,并证明 launcher 保持可执行且字节完全一致。CI 拒绝静默全跳过。 - **With-key:** 以只读模式启动真实 ACP 组合,让模型驱动的 bash 写入命中 runner 的拒绝标记,再通过已授权与被拒绝的 workspace-write 重试驱动 bridge 应答器和磁盘效果;不可用的凭证或 runner 自动跳过。 - **快照:** 固定原子化的当前策略上下文和两个脚本化的 approval 分支。一个真实 ACP 示例场景把会话放在用户主目录下,同时让部署后备根目录指向 `/tmp`,然后固定 workspace-write 运行时上下文消息与一次成功的、由部署选定的变更;这能区分会话根目录解析与进程后备值,而不依赖 runner 特定的拒绝文本。一个模拟 Landlock 部分强制执行行为的 POSIX 提供方会在组装后的应用中固定直接执行 bash `false` 时仍得到普通子进程结果,并固定提供方可执行文件缺失时在前台/后台均为基础设施失败。其他快照以无约束启动,使无关 fixture(测试前置数据)保持平台无关。 @@ -128,15 +128,15 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层 - **第二个消费方**——`subagent-acp` 可选地约束子 agent(按调用策略;默认无约束——子 agent 必须写入自己的持久化)。 - **更多环境**——环境一致的能力组示例(如 bash+fs 对一个容器)。 -- **Windows 链**——`PLATFORM_CHAINS.win32` 保留为空(失败关闭);填充它意味着来自 AppContainer/restricted-token 家族的约束 runner,从其自己的仓库按 `node-addon-landlock-run` 模板交付,加上其 profile 方言、拒绝签名和 runner 失败规则。改为包装第三方 landstrip runner 的方案[经考虑后已驳回](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md)——它所经受的实战检验还不足以承载安全不变式。 +- **Windows 链**——`PLATFORM_CHAINS.win32` 保留为空(失败关闭);填充它意味着来自 AppContainer/restricted-token 家族的约束 runner,由主仓库在 `native/` 下按 `@deepseek-ai/node-addon-landlock-run` 模板交付,加上其 profile 方言、拒绝签名和 runner 失败规则。改为包装第三方 landstrip runner 的方案[经考虑后已驳回](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md)——它所经受的实战检验还不足以承载安全不变式。 ## 曾考虑的替代方案 - **命令字符串启发式预检**:否决。无法理解展开/子进程/符号链接;严格尝试(运行它,让内核决定)是唯一可信的拒绝信号。 - **即使平台仅有一个后端也功能性探测**:否决。探测用于在候选者之间仲裁;只有一个时无需决策,且探测开销对每个会话的首次约束命令征税(对未来重量级后端而言代价过高)。runner 自身执行时的失败关闭拒绝加结构化 `runnerFailureRules` 分类承载了安全属性。 -- **提交构建好的 launcher 二进制**:否决。diff 中的二进制不可审查且膨胀历史;经审查的源码 + 原生 CI 构建 + launcher 仓库的字节固定发布演练使二进制远离所有代码树。 +- **提交构建好的 launcher 二进制**:否决。diff 中的二进制不可审查且膨胀历史;经审查的源码 + 原生 CI 构建 + 主仓库的字节固定发布演练使二进制远离所有代码树。 - **安装时编译 launcher**:否决。将 C 工具链强加给每个消费方;仅在碰巧有编译器时才存在的备选不是备选。 -- **从一个构建器交叉编译两种架构**:否决。仅为重建两个约 70 KB 的二进制就需要携带一个固定的交叉工具链(rustup targets、zig 或容器镜像);每架构的原生 runner 已存在,各自构建自己的平台包(`node-addon-require-builtin` 模式,launcher 仓库自己的流水线)。 +- **从一个构建器交叉编译两种架构**:否决。仅为重建两个约 70 KB 的二进制就需要携带一个固定的交叉工具链(rustup targets、zig 或容器镜像);每架构的原生 runner 已存在,各自构建自己的平台包(`node-addon-require-builtin` 模式,由主仓库的原生流水线保留)。 - **无备选(bwrap 或失败关闭)**:否决。将失败集中在沙箱最重要的主机上,最终因放弃而降级到 `danger-full-access`。 - **将机制保留在 `dsh-bash-sandbox` 内部**:否决。阻塞既有的第二个消费方,使未来阶段从一个 bash 插件的配置中读取模式,且无法表达升级。 - **提供方上的配置固定模式**:否决。每进程一个模式;无法服务具有不同策略的并发消费方,也无法表达一次性放宽重试。 @@ -175,7 +175,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层 - **Seatbelt 层级依赖 Apple 已弃用但仍交付的 `sandbox-exec` CLI。** 作为 darwin 的唯一候选,它无需探测即被选中,因此在 workdir 可用时,未来移除会表现为可归因于 runner 的 spawn 失败,可执行文件拒绝则通过其致命签名体现——两者都会变为 `SANDBOX_UNAVAILABLE`,且命令绝不会运行;失败关闭,绝不开放。 - **Landlock 约束的完整度取决于运行内核的 ABI。** 报告为 `enforcement: 'partial'` 而非拒绝——这是有意的权衡,使备选在旧内核主机上仍可用。 - **Runner 归因使用带内协议。** 退出状态与 stderr 无法以密码学方式识别写入者,因此受限子进程可以模仿 runner 的致命诊断行和状态,造成可用性或诊断误归因。多项证据的合取与精确通知排除减少了意外匹配;这不是沙箱绕过,因为子进程已经受到限制。 -- **launcher 作为注册表依赖到达。** 通过其自身仓库的发布流水线(经审查的 C 源码、原生 CI 构建器、字节固定的发布演练)加上本仓库的版本固定获得信任——真实内核 e2e 测试环节会验证安装产物的实际行为。 +- **launcher 在源码中是 workspace 依赖,发布后是 npm 依赖。** 主仓库会在发布同一个包家族之前,一起测试经审查的 C 源码、原生 CI 构建和字节固定的本地 tarball;真实内核 e2e 测试环节会验证这些安装字节的实际行为。 - **模型可能过度请求。** 在没有拒绝依据的情况下升级,或在 `workspace-write` 足够时选择 `danger-full-access`:描述引导且枚举强制阶梯,但人的提示词是实际门控;`approval/asked` 原因使过度请求可审计,且 `prepend` 策略应答器可以自动拒绝部署永远不想要的模式。 - **公布的目标集是静态的,而有效模式是按会话的**(schema 是注册表全局的)——已处于最宽模式的会话仍被提供这些字段。构造上无害:执行时的严格放宽检查(而非枚举)是安全边界——非放宽请求以自身文本失败且不提示任何人。 - **授权的升级不等于可工作的沙箱。** 不可用的后端即使对授权升级到约束模式也仍然失败关闭——平台没有链或所有探测失败时在 `confine()` 阶段失败,所选可执行文件无法启动时通过 spawn 通道失败,已启动的 runner 拒绝时则通过结构化规则失败——而授权的 `danger-full-access` 运行根本不触及提供方:此时授权(而非探测)是权威。 @@ -187,7 +187,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层 - **一个命令返回了 `[sandbox: file access denied under read-only mode]`——它失败了吗?** 它运行了,内核拒绝了一个文件操作:拒绝是与退出码正交的结果事实。相关指令禁止通过绕过限制来重试;唯一被认可的动作是以升级请求重试同一命令一次。 - **如何区分损坏的沙箱与失败的命令?** 提供方 argv 的任何 spawn 拒绝都能证明受限启动从未开始,但只有在调用方拥有的 workdir 可用,且 Node 为该 argv[0] 报告可归因的 `ENOENT` 或 `EACCES` 时,才能据此判定 runner 损坏。没有精确错误路径的裸 `syscall: 'spawn'` 和其他所有拒绝仍是普通的命令启动错误。进程启动后,只有当 `runnerFailureRules` 中某一条目同时匹配其可选退出码门控,以及排除整行精确信息性行后的一行致命 stderr 诊断时,runner 失败才会优先于拒绝。前台失败会抛出结构化的 `SANDBOX_UNAVAILABLE`,并附带 spawn 错误或匹配行作为详细信息;遭异步拒绝或已结算的后台任务则盖章 `sandbox.runnerFailed` 并渲染自己的标记。如果 `SubprocessService` 同步抛出同样带有来源信息的 `ENOENT`/`EACCES` 形态,后台启动会抛出该结构化错误;其他同步错误原样传播。Landlock 部分强制执行通知加上普通子进程失败时,仍返回命令结果。 - **在没有后端的平台上会发生什么——今天的 Windows?** `confine()` 抛出失败关闭的 `SANDBOX_UNAVAILABLE`,命令永不 spawn;`win32` 是保留的空链,由测试固定为同样失败关闭,直到 Windows runner 填充它(§ 延迟阶段)。 -- **`bwrap` 已安装在我的主机上但不可用(禁用了非特权 userns、LSM 拒绝 `mount`)——会发生什么?** 链探测是功能性的——它构建并强制一个真实 profile 而非检查 `--version`——因此存在但不可用的 `bwrap` 探测失败,选择落到注册表安装的 Landlock launcher,结论在提供方生命周期内缓存。 +- **`bwrap` 已安装在我的主机上但不可用(禁用了非特权 userns、LSM 拒绝 `mount`)——会发生什么?** 链探测是功能性的——它构建并强制一个真实 profile 而非检查 `--version`——因此存在但不可用的 `bwrap` 探测失败,选择落到已打包的 Landlock launcher,结论在提供方生命周期内缓存。 - **沙箱限制网络或进程可见性吗?** 不——`SandboxMode` 仅声称文件操作;bwrap profile 刻意不 unshare pid,没有后端声称网络。网络限制是否成为自己的旋钮留在 § seam 中开放。 - **哪些工具实际在约束下运行?** 通过 `ctx.bash` 的 OS 子进程——bash 工具及传递性的钩子命令——再加上通过沙箱化 `ctx.fs` 提供方运行的文件系统工具(`read`/`write`/`edit`,见[跨工具族 fs 沙箱 Agent Note](2026-07-14-cross-family-fs-sandbox.md)):bash 通过 OS runner 约束,fs 通过进程内路径围栏约束,二者都以同一个 `ctx.sandboxPolicy` 模式为键。web/todo 仍在进程内且不受限制(web 的唯一效果是网络,不在文件效果模式词汇内)。 - **授权的升级会持久化吗?** 不会。授权由发起请求的确切前台或后台调用消费;每个相邻调用保留自己的有效模式。后续的后台拒绝通过 `task_output` 呈现,并且可以作为一次新的精确命令重试的依据。 diff --git a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml index 36468994e1..5c5054b0c9 100644 --- a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md -2026-07-21-log-backed-session-titles.md: 81ac687c6f55dd0ca1eaeb9d84c811edcfe17b5c -2026-07-21-log-backed-session-titles.zh.md: a3a8cb6fa4b766657176f2827a3641d4874b834b +2026-07-21-log-backed-session-titles.md: 5f4c53a5fb866663bbfaae6b3fdafb4660424804 +2026-07-21-log-backed-session-titles.zh.md: f9629c1109c8428c74270de9475423d29b99e8f1 diff --git a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md index 81ac687c6f..5f4c53a5fb 100644 --- a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md +++ b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md @@ -18,7 +18,7 @@ The [`session-title` capability family](../../../../packages/session-title/READM Every accepted revision is a log-only `session/title` event. Its payload contains normalized non-empty text, the exact eligible human `user/message` seqs used to derive it, and either fallback provenance or the registered provider id plus optional provider/model route. Before an auxiliary title-model dispatch, the shared helper appends a log-only `session/title-llm-request` event containing the title-provider id, exact source seqs, route, system prompt, messages, and output-token cap; a later generation failure leaves the request auditable. The dispatched envelope is deep-frozen to preserve exact agreement with that record but carries no process-local agent-loop request identity, so loop-only reconstruction checks do not compare it with the main conversation header. Validation failures that never reach dispatch create no request event. `foldSessionTitle()` selects the latest title event and adds that event's seq and timestamp as `SessionTitleSnapshot`. Neither event enters `session.surface` or `deriveMessages()`. -The title service appends `session/title` directly after checking its current revision and exact live session; the bundled model helper likewise appends its literal `session/title-llm-request` record before dispatch. Both records may sit between turns without inventing an execution boundary. Persistence observes them eagerly and drains through ordinary checkpoints and lifecycle teardown; title publication does not force a per-event flush. No generic marker, cast, or settlement queue sits between the event owner and `Session.append()`. This is the domain-specific application of the [standalone log-only event decision](../simplification/2026-07-28-remove-synthetic-log-only-turns.md). +The title service appends `session/title` directly after checking its current revision and exact live session; the bundled model helper likewise appends its literal `session/title-llm-request` record before dispatch. Both records may sit between turns without inventing an execution boundary. Persistence admits them to bounded background batches and drains through ordinary checkpoints and lifecycle teardown; title publication does not force a per-event flush. No generic marker, cast, or settlement queue sits between the event owner and `Session.append()`. This is the domain-specific application of the [standalone log-only event decision](../simplification/2026-07-28-remove-synthetic-log-only-turns.md). ### Input and asynchronous timing diff --git a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md index a3a8cb6fa4..f9629c1109 100644 --- a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md @@ -18,7 +18,7 @@ Status: implemented 每个已接受的修订都是纯日志 `session/title` 事件。其载荷包含规范化后的非空文本、用于派生标题的所有合格且来源为人类的 `user/message` 的准确 seq,以及回退来源信息,或已注册的提供方 id 加可选的提供方和模型路由。辅助标题模型发起调用前,共享辅助组件会追加一个纯日志 `session/title-llm-request` 事件,其载荷包含标题提供方 id、准确的源 seq、路由、系统提示词、消息和输出 token 上限;即使后续生成失败,这次请求仍可审计。发送的请求信封经过深度冻结,以确保其与该记录精确一致,但它有意不携带进程本地的 agent loop(智能体循环)请求身份,因此仅针对 agent loop 的重建检查不会将它与主对话请求头进行比较。未进入调用阶段的验证失败不会创建请求事件。`foldSessionTitle()` 选择最新的标题事件,并将该事件的 seq 和时间戳加入 `SessionTitleSnapshot`。这两类事件都不会进入 `session.surface` 或 `deriveMessages()`。 -标题服务会在检查当前修订和确切的实时会话后,直接追加 `session/title`;随附模型辅助函数同样会在发起调用前追加其字面量 `session/title-llm-request` 记录。两类记录都可以位于轮次之间,而无需虚构执行边界。持久化会尽快观察它们,并通过常规检查点和生命周期 teardown 排空;标题发布不会强制逐事件 flush。事件所有方与 `Session.append()` 之间不存在通用标记、类型断言或结算队列。这是[独立纯日志事件决策](../simplification/2026-07-28-remove-synthetic-log-only-turns.md)在特定领域中的应用。 +标题服务会在检查当前修订和确切的实时会话后,直接追加 `session/title`;随附模型辅助函数同样会在发起调用前追加其字面量 `session/title-llm-request` 记录。两类记录都可以位于轮次之间,而无需虚构执行边界。持久化会将它们接纳到有界后台批次中,并通过常规检查点和生命周期 teardown 排空;标题发布不会强制逐事件 flush。事件所有方与 `Session.append()` 之间不存在通用标记、类型断言或结算队列。这是[独立纯日志事件决策](../simplification/2026-07-28-remove-synthetic-log-only-turns.md)在特定领域中的应用。 ### 输入与异步时序 diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml index 6b0ce3f378..8a92dd2fd1 100644 --- a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md -2026-07-26-todo-parallel-in-progress.md: 2805ef894050d1b1cffe06fce59a4984d463f8d1 -2026-07-26-todo-parallel-in-progress.zh.md: 16b32daa05b10f24eacde4cec9622b2159cdef09 +2026-07-26-todo-parallel-in-progress.md: 8d047f33ab1aebd8c0de2a0a5e90e7efb1b28154 +2026-07-26-todo-parallel-in-progress.zh.md: 26b7081e7b4e3688a46a2857da91ba45328532a9 diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md index 2805ef8940..8d047f33ab 100644 --- a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md @@ -37,7 +37,7 @@ The durable-log invariant deliberately does NOT follow the flag. A log written w ## The display surfaces are part of the change -Lifting the cap makes a list shape reachable that no renderer had ever received, so this branch stacks on the [web todo display](2026-07-23-web-todo-display.md) rather than landing beside it: both change `tool-todo`, and the GUI is where a parallel plan becomes visible. Two web sites derived their one-line summary with `todos.find(t => t.status === 'in_progress')` — the collapsed plan-strip header and the `todo_write` row — and under the old cap that `find` was total, since at most one item could match. With several active it silently dropped every active item but the first: a four-item plan with three running tasks collapsed to the name of one, and the row read `1/4 已完成 · ` while two others were in flight. The expanded list was always correct (it maps every item), which is why neither PR's tests caught it — only the collapsed header and the row lost information. The panel redesign in [#740](https://github.com/deepseek-harness/deepseek-harness/pull/740) has since replaced the collapsed header's named hint with `·`-joined per-status counts (localized, `1 completed · 2 in progress · 1 pending`, zero-count segments omitted), which reports parallel work correctly and needs no name to truncate; the row is the one site this branch still had to fix. +Lifting the cap makes a list shape reachable that no renderer had ever received, so this branch stacks on the [web todo display](2026-07-23-web-todo-display.md) rather than landing beside it: both change `tool-todo`, and the GUI is where a parallel plan becomes visible. Two web sites derived their one-line summary with `todos.find(t => t.status === 'in_progress')` — the collapsed plan-strip header and the `todo_write` row — and under the old cap that `find` was total, since at most one item could match. With several active it silently dropped every active item but the first: a four-item plan with three running tasks collapsed to the name of one, and the row read `1/4 已完成 · ` while two others were in flight. The expanded list was always correct (it maps every item), which is why neither PR's tests caught it — only the collapsed header and the row lost information. The panel redesign replaced the collapsed header's named hint with `·`-joined per-status counts (localized, `1 completed · 2 in progress · 1 pending`, zero-count segments omitted), which reports parallel work correctly and needs no name to truncate; the row is the one site this branch still had to fix. The row takes `planSummary` in `toolviews/plan-summary.ts`. It names the first active item and counts the rest, so the row reports how many tasks are running instead of implying one. Naming every active item was rejected: the row is a single line, and an unbounded join would overflow it — the count degrades predictably where a list does not. The derivation sits inside the toolviews domain rather than in `contract/`, the inter-domain face: the panel computes its own counts inline and shares nothing with the row, so a contract module would declare a sharing relationship that no longer exists. diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md index 16b32daa05..26b7081e7b 100644 --- a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md @@ -37,7 +37,7 @@ Status: implemented ## 展示面是本次改动的一部分 -解除上限使一种此前任何渲染器都不曾收到的列表形状变得可达,因此本分支 stack(栈叠)在 [web todo 展示](2026-07-23-web-todo-display.md)之上,而不是与之并行落地:两者都改 `tool-todo`,而 GUI 正是并行计划变得可见的地方。web 有两处用 `todos.find(t => t.status === 'in_progress')` 推导单行摘要——折叠态的计划横条表头与 `todo_write` 工具行——在旧上限下这个 `find` 是完备的,因为最多只能有一个条目匹配。一旦有多个活跃项,它会静默丢掉除第一个之外的全部活跃条目:一个四条目、三个任务在跑的计划折叠后只显示其中一个的名字,工具行读作 `1/4 已完成 · <一个任务>`,而另外两个仍在进行。展开态的列表始终正确(它遍历每个条目),这也是两个 PR 的测试都没抓到它的原因——只有折叠表头与工具行丢失了信息。其后 [#740](https://github.com/deepseek-harness/deepseek-harness/pull/740) 的面板重做已把折叠表头的具名提示换成以 `·` 连接的各状态计数(本地化后形如 `1 已完成 · 2 进行中 · 1 待处理`,计数为零的段落省略),它能正确报告并行工作,且不需要任何可被截断的名字;工具行才是本分支仍需修的那一处。 +解除上限使一种此前任何渲染器都不曾收到的列表形状变得可达,因此本分支 stack(栈叠)在 [web todo 展示](2026-07-23-web-todo-display.md)之上,而不是与之并行落地:两者都改 `tool-todo`,而 GUI 正是并行计划变得可见的地方。web 有两处用 `todos.find(t => t.status === 'in_progress')` 推导单行摘要——折叠态的计划横条表头与 `todo_write` 工具行——在旧上限下这个 `find` 是完备的,因为最多只能有一个条目匹配。一旦有多个活跃项,它会静默丢掉除第一个之外的全部活跃条目:一个四条目、三个任务在跑的计划折叠后只显示其中一个的名字,工具行读作 `1/4 已完成 · <一个任务>`,而另外两个仍在进行。展开态的列表始终正确(它遍历每个条目),这也是两个 PR 的测试都没抓到它的原因——只有折叠表头与工具行丢失了信息。面板重做把折叠表头的具名提示换成以 `·` 连接的各状态计数(本地化后形如 `1 已完成 · 2 进行中 · 1 待处理`,计数为零的段落省略),它能正确报告并行工作,且不需要任何可被截断的名字;工具行才是本分支仍需修的那一处。 工具行改用 `toolviews/plan-summary.ts` 中的 `planSummary`。它给出第一个活跃条目,并计数其余活跃项,因此工具行报告的是有多少任务在跑,而不是暗示只有一个。列出全部活跃条目被否决了:工具行是单行,无上界的拼接会溢出——在列表做不到的地方,计数能够可预测地降级。该推导放在 toolviews 域内而非 `contract/`(域间共享面):面板自行内联计算其计数,与工具行不共享任何东西,因此放进 contract 会声明一种已不存在的共享关系。 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml index abb388a410..56952f8b69 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md -2026-07-27-web-subagent-conversations.md: 03c805d30dbdbbdb33d0b06ba8036ec181035ac6 -2026-07-27-web-subagent-conversations.zh.md: e83f07fb21f58ad175ab3e5638981648fa4cef05 +2026-07-27-web-subagent-conversations.md: edaaa97bbfcf0ce1751db84d8fc2d3ab07af8b03 +2026-07-27-web-subagent-conversations.zh.md: 4c5e670cc46621b889d8463daa90afbf43bde30f diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md index 03c805d30d..edaaa97bbf 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md @@ -37,7 +37,7 @@ The Figma [subagent list](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Ha ## Product contract -The header action is absent only when a complete empty direct-catalog response agrees with the session-summary projection that no subagent descendants are known. Its trigger counts every known session-summary descendant reached through an uninterrupted `origin: 'subagent'` lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. Every healthy direct-catalog row carries a read-time `hasChildren` hint derived only from direct lineage headers with durable `origin: 'subagent'`; normal healthy and diagnostic subagent candidates carry that marker, while ordinary forks do not. This lookahead reads no descendant event log, and the descriptor-backed catalog loaded after disclosure remains authoritative. When summaries establish descendants before that catalog exists or after a stale empty response, the action stays visible and exposes only disabled loading rows until opening it refreshes the catalog; summary-only rows never grant navigation. The UI omits disclosure for a known leaf before interaction; the hint does not promise that the child will remain a leaf. While an expanded direct catalog is loading, known lineage reserves one disabled loading row per direct descendant without recursively fetching descendant catalogs. The tree then presents continuable and one-shot rows, falling back to the session id when an optional one-shot label is absent. Corrupt, unsupported, and unavailable candidates remain visible as disabled diagnostic rows. +The header action is absent only when a complete empty direct-catalog response agrees with the session-summary projection that no subagent descendants are known. Its trigger counts every known session-summary descendant reached through an uninterrupted `origin: 'subagent'` lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. Because ordinary sidebar rows hide subagent-origin sessions, the Workspace browser indexes the same uninterrupted lineage onto each visible ordinary row: any running descendant supplies its blue activity indicator and exact count in hover and assistive text without describing an idle parent as running. An ordinary fork starts a separate aggregation subtree. Pending interaction outranks parent running; either remains primary while descendant activity becomes a second hover and assistive status. With neither present, descendant activity outranks an unviewed completion reminder, which returns after the last running descendant stops. Every healthy direct-catalog row carries a read-time `hasChildren` hint derived only from direct lineage headers with durable `origin: 'subagent'`; normal healthy and diagnostic subagent candidates carry that marker, while ordinary forks do not. This lookahead reads no descendant event log, and the descriptor-backed catalog loaded after disclosure remains authoritative. When summaries establish descendants before that catalog exists or after a stale empty response, the action stays visible and exposes only disabled loading rows until opening it refreshes the catalog; summary-only rows never grant navigation. The UI omits disclosure for a known leaf before interaction; the hint does not promise that the child will remain a leaf. While an expanded direct catalog is loading, known lineage reserves one disabled loading row per direct descendant without recursively fetching descendant catalogs. The tree then presents continuable and one-shot rows, falling back to the session id when an optional one-shot label is absent. Corrupt, unsupported, and unavailable candidates remain visible as disabled diagnostic rows. `running` means the exact child Agent driver is draining work at the Host sampling boundary; `inactive` means that driver is idle or absent. The UI does not translate either value into success, failure, cancellation, completeness, or resumability. `subagent.list` supplies the current driver-status baseline, `host/session-status` updates known activity in place, request-local replay prevents an older in-flight list response from overwriting a newer transition, and `host/session-removed` returns a known row to `inactive`; reconnect reads a fresh baseline. A `host/session-added` frame for a direct subagent immediately flips any loaded parent row to `hasChildren: true`, and that positive hint survives an older in-flight catalog response; membership, labels, mode, diagnostics, and the authoritative snapshot still require a debounced `subagent.list` refresh while the affected branch is open. A prompt response remains delivery-time authority. @@ -104,8 +104,8 @@ The shipped Web composition mounts SQLite session query beside JSONL persistence - Host protocol tests pin schemas including required boolean expandability, id echoing, mode verification, non-activating history, exact-parent enforcement, FIFO admission receipts, cancellation, and sanitized failure mapping. - Generic Host tests pin attached and cold history and forks without Agent publication, cold projection folding, descriptor/origin/runtime-owner denial, explicit-id adoption denial, and the direct queue-control fence. - Client object tests pin retained and restored addresses, one-shot read-only rejection, history routing, continuable prompt routing, no addressed cancellation, suppression of Agent-bound model controls, live activity flips including in-flight response replay and detach fallback, subagent-parent expandability flips, and membership refresh. -- jsdom tests pin the aggregate descendant count and activity, token totals, second-precision running and frozen inactive durations, adaptive long-duration units with exact accessible text, the summary-backed root action across absent and stale-empty catalogs, known loading-row shape, mixed-mode rows, pre-click leaf disclosure, diagnostics, lazy descendant disclosure, direct-parent addresses, keyboard behavior, and both read-only reasons. -- The keyless assembled Web snapshot contains an inactive continuable child with durable usage, an inactive one-shot sibling with a deterministic long duration, and a persisted grandchild; it pins the three-descendant trigger across a stale empty catalog response, usage and timing rows, adaptive long-duration presentation, and aggregate running transition, expands without activation, opens persisted history, admits a human FIFO follow-up, reconciles child mux events, and proves one-shot history remains read-only. +- jsdom tests pin the aggregate descendant count and activity, sidebar propagation across nested lineage and ordinary-fork boundaries, row-status precedence, token totals, second-precision running and frozen inactive durations, adaptive long-duration units with exact accessible text, the summary-backed root action across absent and stale-empty catalogs, known loading-row shape, mixed-mode rows, pre-click leaf disclosure, diagnostics, lazy descendant disclosure, direct-parent addresses, keyboard behavior, and both read-only reasons. +- The keyless assembled Web snapshot contains an inactive continuable child with durable usage, an inactive one-shot sibling with a deterministic long duration, and a persisted grandchild; it pins the three-descendant trigger across a stale empty catalog response, usage and timing rows, and adaptive long-duration presentation, expands without activation, opens persisted history, admits a human FIFO follow-up, reconciles child mux events, and proves one-shot history remains read-only. A separate assembled scenario holds a real child Agent turn at the model seam while it pins the aggregate running state in both the header and visible idle owner row, then cancels the turn during teardown. - Navigation tests pin subagent-only breadcrumbs, workspace placement for forks created from subagents, and `origin: 'subagent'` sidebar filtering without hiding ordinary forks. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md index e83f07fb21..4c5e670cc4 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md @@ -37,7 +37,7 @@ Figma 中的 [subagent 列表](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5 ## 产品契约 -只有当完整的直接目录空响应与会话摘要投影相符,二者均表明没有已知的 subagent 后代时,才不显示页头操作。其触发器会统计经不间断的 `origin: 'subagent'` 谱系可达的每个已知会话摘要后代,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。每个健康的直接目录行都携带读取时的 `hasChildren` 提示,该值只根据持久化 `origin: 'subagent'` 的直接谱系 header 派生;正常的健康与 diagnostic subagent 候选都会携带该标记,而普通 fork 不会。该预查不读取任何后代事件日志,展开后仍以描述符支撑的目录为权威依据。当摘要在该目录尚不存在时或在一次陈旧的空响应后确认已有后代时,该操作会保持可见,并且在打开它以刷新目录之前仅显示禁用的加载行;仅由摘要支撑的行绝不会提供导航能力。UI 会在交互前就省略已知叶子节点的展开控件;该提示不承诺 child 会一直是叶子。已展开的直接目录加载期间,已知谱系会为每个直接后代预留一行禁用的加载行,而不会递归获取后代目录。随后树会呈现可继续与 one-shot 行;one-shot 的可选 label 缺失时,回退到其会话 id。损坏、不受支持或不可用的候选仍以禁用的 diagnostic 行显示。 +只有当完整的直接目录空响应与会话摘要投影相符,二者均表明没有已知的 subagent 后代时,才不显示页头操作。其触发器会统计经不间断的 `origin: 'subagent'` 谱系可达的每个已知会话摘要后代,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。由于普通侧边栏行会隐藏 origin 为 subagent 的会话,Workspace 浏览器会在每个可见的普通行上索引同一条不间断谱系:任何运行中的后代都会让该行显示蓝色活动指示器,并在悬停与无障碍文本中给出确切数量,同时不会把空闲 parent 描述为正在运行。普通 fork 会开启单独的聚合子树。待处理交互优先于 parent 的运行中状态;二者无论哪一项存在都会保持为主要状态,而后代活动则成为悬停与无障碍状态中的第二项。两者均不存在时,后代活动优先于未查看的完成提醒;最后一个运行中的后代停止后,该提醒会恢复。每个健康的直接目录行都携带读取时的 `hasChildren` 提示,该值只根据持久化 `origin: 'subagent'` 的直接谱系 header 派生;正常的健康与 diagnostic subagent 候选都会携带该标记,而普通 fork 不会。该预查不读取任何后代事件日志,展开后仍以描述符支撑的目录为权威依据。当摘要在该目录尚不存在时或在一次陈旧的空响应后确认已有后代时,该操作会保持可见,并且在打开它以刷新目录之前仅显示禁用的加载行;仅由摘要支撑的行绝不会提供导航能力。UI 会在交互前就省略已知叶子节点的展开控件;该提示不承诺 child 会一直是叶子。已展开的直接目录加载期间,已知谱系会为每个直接后代预留一行禁用的加载行,而不会递归获取后代目录。随后树会呈现可继续与 one-shot 行;one-shot 的可选 label 缺失时,回退到其会话 id。损坏、不受支持或不可用的候选仍以禁用的 diagnostic 行显示。 `running` 表示在 Host 采样边界,确切 child Agent driver 正在处理工作;`inactive` 表示该 driver 空闲或不存在。UI 不会把任一值解释为成功、失败、取消、完成状态或可恢复性。`subagent.list` 提供当前 driver 状态基线,`host/session-status` 会就地更新已知活动状态,请求内回放会阻止更早发起但尚未完成的列表响应覆盖较新的状态转换,`host/session-removed` 则会使已知行恢复为 `inactive`;重连时会读取新的基线。直接 subagent 的 `host/session-added` 帧会立即把任何已加载的 parent 行翻转为 `hasChildren: true`,并使这项正向提示不被更早发起但尚未完成的目录响应覆盖;受影响分支打开期间,成员、label、mode、diagnostic 与权威快照仍需要通过去抖动的 `subagent.list` 刷新来更新。消息投递时仍以提示词响应为权威依据。 @@ -104,8 +104,8 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。 - 宿主协议测试固定 schema(包括必需的布尔可展开性)、id 回显、mode 校验、非激活式历史、确切 parent 强制要求、FIFO 准入回执、取消与脱敏后的失败映射。 - 通用 Host 测试固定在不发布 Agent 的情况下读取已附加与冷态历史及执行 fork、冷态投影归并、按描述符/origin/运行时 owner 拒绝、拒绝显式 id 接纳,以及直接队列控制栅栏。 - 客户端对象测试固定已保留与已恢复的地址、one-shot 只读拒绝、历史路由、可继续提示词路由、已寻址对话不提供取消、屏蔽绑定到 agent 的模型控件、实时活动状态翻转(包括在途响应回放与 detach 回退)、subagent parent 可展开性翻转与成员刷新。 -- jsdom 测试固定后代聚合计数与活动状态、token 用量总计、精确到秒的运行中耗时与冻结后 inactive 耗时、采用自适应单位的长耗时及其精确无障碍文本、目录缺失或为陈旧空目录时由摘要支撑的根操作、已知加载行的形态、混合 mode 行、点击前的叶子展开控件、diagnostic、后代懒加载展开、直接 parent 地址、键盘行为与两种只读原因。 -- 无密钥的组装 Web 快照包含一个具有持久化 token 用量的 inactive 可继续 child、一个具有确定性长耗时的 inactive one-shot sibling 和一个持久化 grandchild;它会固定触发器在一次陈旧的空目录响应后仍显示三个后代,并固定 token 用量与计时行、自适应长耗时呈现以及聚合 `running` 状态转换,在不激活的情况下展开、打开持久化历史、准入一条用户 FIFO 后续消息、归并 child mux 事件,并证明 one-shot 历史仍然只读。 +- jsdom 测试固定后代聚合计数与活动状态、侧边栏活动在嵌套谱系中的传播与普通 fork 边界、行状态优先级、token 用量总计、精确到秒的运行中耗时与冻结后 inactive 耗时、采用自适应单位的长耗时及其精确无障碍文本、目录缺失或为陈旧空目录时由摘要支撑的根操作、已知加载行的形态、混合 mode 行、点击前的叶子展开控件、diagnostic、后代懒加载展开、直接 parent 地址、键盘行为与两种只读原因。 +- 无密钥的组装 Web 快照包含一个具有持久化 token 用量的 inactive 可继续 child、一个具有确定性长耗时的 inactive one-shot sibling 和一个持久化 grandchild;它会固定触发器在一次陈旧的空目录响应后仍显示三个后代,并固定 token 用量与计时行及自适应长耗时呈现,在不激活的情况下展开、打开持久化历史、准入一条用户 FIFO 后续消息、归并 child mux 事件,并证明 one-shot 历史仍然只读。另一个独立的组装场景会在 model seam 处保持一个真实的 child Agent 轮次进行中,同时固定页头和可见空闲 owner 行中的聚合运行状态,随后在 teardown 期间取消该轮次。 - 导航测试固定仅含 subagent 的面包屑导航、从 subagent 创建 fork 时的 Workspace 归属,以及 `origin: 'subagent'` 侧边栏过滤,同时不隐藏普通 fork。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-07-28-feedback-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-feedback-command.i18n.yaml index b1dceca3b2..8374a59b63 100644 --- a/.agents/notes/implemented/feature/2026-07-28-feedback-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-feedback-command.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-feedback-command.md -2026-07-28-feedback-command.md: 770c72954d66c9e26e03c47f99c261626e6ed09b -2026-07-28-feedback-command.zh.md: c8bd57ddb5e94bfb826ec40b771547ca9625c8e3 +2026-07-28-feedback-command.md: 56660b3796c44d3d510dd2901e6a155498673d88 +2026-07-28-feedback-command.zh.md: ec03a5c4a0e96d5350085c3d8d26f2523962f1b5 diff --git a/.agents/notes/implemented/feature/2026-07-28-feedback-command.md b/.agents/notes/implemented/feature/2026-07-28-feedback-command.md index 770c72954d..56660b3796 100644 --- a/.agents/notes/implemented/feature/2026-07-28-feedback-command.md +++ b/.agents/notes/implemented/feature/2026-07-28-feedback-command.md @@ -16,7 +16,7 @@ The capture surface has to be usable at the moment of annoyance, which rules out The package declares the log-only `feedback/record { text }` session event and exports `recordFeedback(session, text)` as its command-independent producer. The producer discards surrounding whitespace, rejects an empty result, and appends exactly one event. `/feedback` delegates to it, so another UI, hook, or host integration can record the same domain fact without constructing a slash command. -`dsh-commands` still writes its `command/run` / `command/done` lifecycle pair around `/feedback`, but this command sets `recordInput: false`. Its `command/run` therefore carries the command identity and source without `args`; the feedback text exists only in `feedback/record`, while `command/done` carries the acknowledgement outcome. All three records are log-only and non-surface. Their appends start persistence's ordinary eager drain; nothing forces a flush, so acknowledgement reports that the feedback is in the log rather than already on disk. +`dsh-commands` still writes its `command/run` / `command/done` lifecycle pair around `/feedback`, but this command sets `recordInput: false`. Its `command/run` therefore carries the command identity and source without `args`; the feedback text exists only in `feedback/record`, while `command/done` carries the acknowledgement outcome. All three records are log-only and non-surface. Their appends enter persistence's ordinary bounded write path; nothing forces a flush, so acknowledgement reports that the feedback is in the log rather than already on disk. Capture remains inert for the running agent and model. The optional OTel telemetry package later adds one infrastructure consumer: it observes `feedback/record` as a release trigger in `FEEDBACK_ONLY` mode and as the local-only warning trigger in `DISABLED` mode, without changing the feedback event or command path. See [Feedback-gated session telemetry](2026-08-05-feedback-gated-session-telemetry.md). diff --git a/.agents/notes/implemented/feature/2026-07-28-feedback-command.zh.md b/.agents/notes/implemented/feature/2026-07-28-feedback-command.zh.md index c8bd57ddb5..ec03a5c4a0 100644 --- a/.agents/notes/implemented/feature/2026-07-28-feedback-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-feedback-command.zh.md @@ -16,7 +16,7 @@ Status: implemented 本包(package)声明仅写入日志的 `feedback/record { text }` 会话事件,并导出 `recordFeedback(session, text)`,作为不依赖命令的生产方。该生产方丢弃前后空白,拒绝空结果,并且恰好追加一个事件。`/feedback` 委托给它,因此其他 UI、钩子或 host 集成无需构造斜杠命令也能记录同一个领域事实。 -`dsh-commands` 仍会围绕 `/feedback` 写入 `command/run` / `command/done` 生命周期配对,但该命令设置了 `recordInput: false`。因此,它的 `command/run` 携带命令标识与来源,但不携带 `args`;反馈文本只存在于 `feedback/record` 中,而 `command/done` 携带确认结果。三个记录都仅写入日志且非 surface。它们的追加会启动持久化的常规即时排空;没有任何环节强制 flush,因此确认文本报告的是反馈已进入日志,而非已经落盘。 +`dsh-commands` 仍会围绕 `/feedback` 写入 `command/run` / `command/done` 生命周期配对,但该命令设置了 `recordInput: false`。因此,它的 `command/run` 携带命令标识与来源,但不携带 `args`;反馈文本只存在于 `feedback/record` 中,而 `command/done` 携带确认结果。三个记录都仅写入日志且非 surface。它们的追加会进入持久化的常规有界写入路径;没有任何环节强制 flush,因此确认文本报告的是反馈已进入日志,而非已经落盘。 采集对正在运行的 agent 与模型仍不产生后续动作。可选的 OTel 遥测包后续增加了一个基础设施消费方:它在 `FEEDBACK_ONLY` 模式下将 `feedback/record` 作为释放触发器,在 `DISABLED` 模式下将其作为本地警告触发器,且不改变反馈事件或命令路径。见[反馈门控的会话遥测](2026-08-05-feedback-gated-session-telemetry.md)。 diff --git a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.i18n.yaml index 34baccb6f5..418c81f17d 100644 --- a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md -2026-07-30-deepseek-onboarding-credential-setup.md: c732758bc567376a0be4ac348aa9129aa8126ac4 -2026-07-30-deepseek-onboarding-credential-setup.zh.md: 2dc7e0ccf5f9a99ad35c859a7ecfb9f98d93d530 +2026-07-30-deepseek-onboarding-credential-setup.md: 419ea0aea56e82e301189d90d5ca78495da2da71 +2026-07-30-deepseek-onboarding-credential-setup.zh.md: 936402c9ed83eccc3d5e78a57247f06347522c0f diff --git a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md index c732758bc5..419ea0aea5 100644 --- a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md +++ b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md @@ -10,7 +10,7 @@ The [web configuration plane](../architecture/2026-07-30-web-config-plane.md) ma ## Decision -**One readiness projection owns both Models and onboarding facts.** `ui-models` keeps a single store that joins `llm.providers({})`, redacted `settings.describe({})`, and batched `credentials.describe({refs})`. The onboarding projection selects the `deepseek-official` configurable-provider entry owned by the `llm-deepseek` namespace and empty settings path, reads the effective `apiKeyEnv`, and evaluates the matching credential descriptor. A live route with the same provider id but no matching configurable-provider declaration is adapter-absent for onboarding. A configured literal `apiKey` secret sidecar is also ready, so compatibility configuration does not trigger a false prompt; a configured process-environment credential is ready and remains read-only. +**One readiness projection owns both Models and onboarding facts.** `ui-models` keeps a single store that joins `llm.providers({})`, redacted `settings.describe({})`, and batched `credentials.describe({refs})`. The onboarding projection selects the `deepseek-official` configurable-provider entry owned by the `llm-deepseek` namespace and empty settings path, reads the effective `apiKeyEnv`, and evaluates the matching credential descriptor. A live route with the same provider id but no matching configurable-provider declaration is adapter-absent for onboarding. A configured process-environment credential is ready and remains read-only. **The settings shell contributes ordering and navigation, not provider policy.** `ui-settings` declares a root-scoped `settings.onboarding` list slot and mounts one ordered step at a time while the current surface is the empty Hero. The active registrant receives `complete()` and a private `openSection(id)` callback; completion transfers ownership to the next entry. `ui-models` registers the DeepSeek step and its Models section through `slots.inject()`, so each contribution follows its declaration lifetime without making plugin load order a contract, and independently contributed dialogs cannot stack. The product-wide welcome step that precedes it is owned separately by [the versioned welcome decision](2026-07-30-versioned-gui-welcome-onboarding.md). @@ -30,4 +30,4 @@ The [web configuration plane](../architecture/2026-07-30-web-config-plane.md) ma ## Consequences -The ordered flow leads from the product notice to the shipped adapter's existing editor without restarting: a keyless browser test boots the real Web composition under an isolated harness home, acknowledges the notice, follows the DeepSeek page to Models, stores a generated key through that page into the home's `.env`, verifies no key reaches DOM, ARIA, or browser console output, and confirms the running page reports configured. The full keyless Web replay lane also pins that a non-configurable replay route with the same provider id does not block unrelated journeys. Pure readiness and React tests pin literal, file, process-environment, missing-provider, missing-capability, navigation, cancellation, external-invalidation, and coordinator-transfer behavior. The flow deliberately inherits the configuration plane's documented base limitations rather than adding local secret storage, redaction, or settings replacement workarounds. +The ordered flow leads from the product notice to the shipped adapter's existing editor without restarting: a keyless browser test boots the real Web composition under an isolated harness home, acknowledges the notice, follows the DeepSeek page to Models, stores a generated key through that page into the home's `.credentials.yaml`, verifies no key reaches DOM, ARIA, or browser console output, and confirms the running page reports configured. The full keyless Web replay lane also pins that a non-configurable replay route with the same provider id does not block unrelated journeys. Pure readiness and React tests pin managed-file and process-environment credentials, missing providers and capabilities, navigation, cancellation, external invalidation, and coordinator transfer. The flow deliberately inherits the configuration plane's documented base limitations rather than adding local secret storage, redaction, or settings replacement workarounds. diff --git a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md index 2dc7e0ccf5..936402c9ed 100644 --- a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -**Models 与首次使用引导共享同一个就绪状态投影。**`ui-models` 维护一个 store,把 `llm.providers({})`、脱敏后的 `settings.describe({})` 和批量调用的 `credentials.describe({refs})` 联接为同一份状态。首次使用投影选取由 `llm-deepseek` namespace 与空 settings path 持有的 `deepseek-official` 可配置提供方条目,读取生效的 `apiKeyEnv`,并检查对应的凭据描述符。同 provider id 但没有匹配可配置提供方声明的存活路由,在首次使用引导中视为适配器缺失。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,也会判定为就绪,兼容配置因此不会误触发页面;通过进程环境提供的凭据若已配置,同样判定为就绪并保持只读。 +**Models 与首次使用引导共享同一个就绪状态投影。**`ui-models` 维护一个 store,把 `llm.providers({})`、脱敏后的 `settings.describe({})` 和批量调用的 `credentials.describe({refs})` 联接为同一份状态。首次使用投影选取由 `llm-deepseek` namespace 与空 settings path 持有的 `deepseek-official` 可配置提供方条目,读取生效的 `apiKeyEnv`,并检查对应的凭据描述符。同 provider id 但没有匹配可配置提供方声明的存活路由,在首次使用引导中视为适配器缺失。通过进程环境提供的凭据若已配置,则判定为就绪并保持只读。 **设置外壳只贡献排序与导航,不持有提供方策略。** `ui-settings` 声明一个根作用域的 `settings.onboarding` list slot,并在当前界面为空白 Hero 时,每次只挂载一个有序步骤。当前注册方会收到 `complete()` 和私有 `openSection(id)` 回调;完成当前步骤后,所有权转交给下一项。`ui-models` 通过 `slots.inject()` 注册 DeepSeek 步骤及其 Models 分区,使每项贡献都跟随自身的声明生命周期,不让插件加载顺序成为契约;独立贡献的对话框也无法堆叠。排在它之前的产品级欢迎步骤由[版本化欢迎决策](2026-07-30-versioned-gui-welcome-onboarding.md)单独持有。 @@ -30,4 +30,4 @@ Status: implemented ## 后果 -有序流程从产品声明页开始,无需重启即可引导用户前往随产品提供的适配器已有的编辑器:无密钥浏览器测试在隔离的 harness 家目录下启动真实 Web 组合,确认声明后依照 DeepSeek 页面前往 Models,通过该页面把生成的密钥存入该目录的 `.env`,验证密钥未进入 DOM、ARIA 或浏览器控制台输出,并确认运行中的页面报告已配置。完整的无密钥 Web 回放也固定了同 id 的不可配置回放路由不会阻塞无关流程。纯就绪状态测试与 React 测试固化了字面量凭据、文件凭据、进程环境凭据、提供方缺失、能力缺失、导航、取消、外部失效和协调器移交行为。该流程直接继承配置平面已记录的基础限制,不会另加局部的机密存储、脱敏或设置替换变通方案。 +有序流程从产品声明页开始,无需重启即可引导用户前往随产品提供的适配器已有的编辑器:无密钥浏览器测试在隔离的 harness 家目录下启动真实 Web 组合,确认声明后依照 DeepSeek 页面前往 Models,通过该页面把生成的密钥存入该目录的 `.credentials.yaml`,验证密钥未进入 DOM、ARIA 或浏览器控制台输出,并确认运行中的页面报告已配置。完整的无密钥 Web 回放也固定了同 id 的不可配置回放路由不会阻塞无关流程。纯就绪状态测试与 React 测试固化了受管文件凭据与进程环境凭据、提供方与能力缺失、导航、取消、外部失效和协调器移交。该流程直接继承配置平面已记录的基础限制,不会另加局部的机密存储、脱敏或设置替换变通方案。 diff --git a/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.i18n.yaml index f06403d181..1ceb5a5213 100644 --- a/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.md -2026-07-30-queued-manual-compaction.md: 4b7a905712a01948146b8830dfc037185162eefc -2026-07-30-queued-manual-compaction.zh.md: 15a42de3536f2da5304e77ce3cb282029856ba6d +2026-07-30-queued-manual-compaction.md: 5100808ada4b7b284228113584e577d46ff91101 +2026-07-30-queued-manual-compaction.zh.md: 29c3b921d0d59527aa1d8af1c91d085b52045377 diff --git a/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.md b/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.md index 4b7a905712..5100808ada 100644 --- a/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.md +++ b/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.md @@ -75,7 +75,7 @@ Once a transaction has appended its start, every later failure makes one closing ### Reference implementation boundaries -[PR #835](https://github.com/deepseek-harness/deepseek-harness/pull/835) was used as a reference implementation for the command, reservation, tests, and snapshot shape, but was not merged. Its process-local `WeakSet` lock and locked/unlocked method splits were considered and not adopted because the durable bracket is the single reachable lock. +An unmerged reference implementation informed the command, reservation, tests, and snapshot shape. Its process-local `WeakSet` lock and locked/unlocked method splits were considered and not adopted because the durable bracket is the single reachable lock. That reference also carried client-side replacement-anchor machinery to preserve transcript placement. The log-ordered transcript projection already consumes compaction from event order and does not consult mutable surface positions, so those anchors were considered and not adopted. diff --git a/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.zh.md b/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.zh.md index 15a42de353..29c3b921d0 100644 --- a/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.zh.md @@ -75,7 +75,7 @@ DSH 有意在调用摘要器前记录 `compact/start`。缓慢或崩溃的尝试 ### 参考实现边界 -[PR #835](https://github.com/deepseek-harness/deepseek-harness/pull/835) 用作命令、预留、测试与快照结构的参考实现,但未被合并。它的进程本地 `WeakSet` 锁与 locked/unlocked 方法拆分经过评估后未被采用,因为持久标记对是唯一可达的锁。 +一个未合并的参考实现为命令、预留、测试与快照结构提供了参考。它的进程本地 `WeakSet` 锁与 locked/unlocked 方法拆分经过评估后未被采用,因为持久标记对是唯一可达的锁。 该参考实现还包含客户端侧替换锚点机制,用于保留 transcript(文本记录)位置。按日志顺序排列的 transcript 投影已经从事件顺序消费压缩,并且不会查询可变 surface 位置,因此这些锚点经过评估后未被采用。 diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml index eea7ced3d2..40e24e9ac2 100644 --- a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md -2026-08-02-pwsh-tool-bash-parity.md: 945d2d5243162fe8e7fb3f76cbc3bcf0b5c2fdee -2026-08-02-pwsh-tool-bash-parity.zh.md: f537e313a0c895927c6e2319b11b98619a70d461 +2026-08-02-pwsh-tool-bash-parity.md: e35a903892d5d50a0d3ca12b23daa53f26d6aade +2026-08-02-pwsh-tool-bash-parity.zh.md: d7a68f0cab5281b25c3321e7ffff61c358b21a0a diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md index 945d2d5243..e35a903892 100644 --- a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md @@ -14,7 +14,7 @@ The first Windows-native foundation shipped `dsh-tool-pwsh` as a deliberately mi - **Rendering adopts the bash story verbatim**: stdout, a marked `[stderr]` section, truncation notices with spill paths, `(no output)` for an empty body, and exit markers only for non-zero exits — a clean exit produces no marker. The description and the `tool:pwsh` prompt section state this precisely ("Non-zero exits are reported as `[exit code: N]` markers"), deliberately not copying the bash prompt's "every result" phrasing, which its own renderer contradicts. - **`run_in_background` is wired through the generic task runtime** exactly like the bash tool: preflight, owner registration, `task_output`/`task_kill` control, and the same outcome mapping. `pwsh-local`'s already-mirrored `start()` handle backs it. -- **The `DSH_*` environment is shared, not duplicated**: `BashEnvRegistry` moved out of `dsh-tool-bash` into a new tool-independent `@deepseek-ai/dsh-bash-env` package (`ctx.bashEnv` + built-ins + the session-persistence contributor), and both shell tools inject it. Contributors apply to pwsh calls exactly as they do to bash calls, resolving the bash tool's `FIXME(bash-env-ownership)`. +- **The `DSH_*` environment is shared, not duplicated**: `BashEnvRegistry` moved out of `dsh-tool-bash` into a new tool-independent `@deepseek-ai/dsh-bash-env` package (`ctx.bashEnv` + built-ins + the session-persistence contributor), and both shell tools inject it. Contributors apply to pwsh calls exactly as they do to bash calls; shared environment ownership therefore sits outside either model-facing shell tool. - **Windows reality is pinned where bash has no analog**: every command runs under a UTF-8 output preamble so the Windows PowerShell 5.1 fallback cannot garble non-ASCII output through the UTF-8-decoding collector, and the prompts teach that Windows forced termination settles as exit 1 without a signal marker. - **Out of scope, unchanged**: sandbox escalation (waits for a Windows-confining executor) and persistent PTY shells (backends are Linux/macOS-only; ConPTY is roadmap work). The pwsh-specific terminal card with an exit pill shipped separately in the [pwsh UI presentation matches bash](2026-08-05-pwsh-ui-bash-parity.md) decision. diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md index f537e313a0..d7a68f0cab 100644 --- a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md @@ -14,7 +14,7 @@ Status: implemented - **渲染完全采用 bash 故事**:stdout、带标记的 `[stderr]` 段、带 spill 路径的截断通知、空体渲染 `(no output)`、退出 marker 仅限非零退出——干净退出不产生 marker。描述与 `tool:pwsh` prompt section 精确陈述这一点("Non-zero exits are reported as `[exit code: N]` markers"),刻意不复制 bash prompt 中与其自身渲染矛盾的 "every result" 措辞。 - **`run_in_background` 经通用任务运行时接线**,与 bash 工具完全一致:预检、owner 注册、`task_output`/`task_kill` 控制与相同的结果映射。其背后是 `pwsh-local` 早已镜像好的 `start()` 句柄。 -- **`DSH_*` 环境共享而非复制**:`BashEnvRegistry` 从 `dsh-tool-bash` 迁入新的工具无关包 `@deepseek-ai/dsh-bash-env`(`ctx.bashEnv` + 内置事实 + session-persistence contributor),两个 shell 工具都注入它。contributor 对 pwsh 调用与 bash 调用一视同仁,并消化了 bash 工具的 `FIXME(bash-env-ownership)`。 +- **`DSH_*` 环境共享而非复制**:`BashEnvRegistry` 从 `dsh-tool-bash` 迁入新的工具无关包 `@deepseek-ai/dsh-bash-env`(`ctx.bashEnv` + 内置事实 + session-persistence contributor),两个 shell 工具都注入它。contributor 对 pwsh 调用与 bash 调用一视同仁;因此,共享环境的所有权不属于任何一个面向模型的 shell 工具。 - **Windows 现实在 bash 无对应处钉死**:每条命令都在 UTF-8 输出 preamble 下运行,使 Windows PowerShell 5.1 兜底无法经 UTF-8 解码的 collector 破坏非 ASCII 输出;prompt 教授 Windows 强制终止以无 signal 的 exit 1 结算。 - **范围外,不变**:sandbox 升级(等待 Windows-confining 执行器)与持久 PTY shell(后端仅限 Linux/macOS;ConPTY 属路线图)。带退出 pill 的 pwsh 专属 terminal 卡已随 [pwsh UI 呈现与 bash 对齐](2026-08-05-pwsh-ui-bash-parity.md) 决策另行交付。 diff --git a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.i18n.yaml b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.i18n.yaml index 2ec7925a3e..b98c2bee56 100644 --- a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md -2026-08-02-win32-in-process-folder-dialog.md: 91a1ed0d7b1c1938a5e038ce36f1ca90bf3c9e82 -2026-08-02-win32-in-process-folder-dialog.zh.md: 6b90dc1c5fa0042b3e2bcbea8ed554f1f0ea2acf +2026-08-02-win32-in-process-folder-dialog.md: 5389293605169ce5ca269a127de5f609b8b7dd11 +2026-08-02-win32-in-process-folder-dialog.zh.md: ef81bc1f65b2859de1eb60f74f79e4869da4746b diff --git a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md index 91a1ed0d7b..5389293605 100644 --- a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md +++ b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md @@ -14,7 +14,7 @@ The Windows directory picker's primary tier was a spawned PowerShell script arou ## Alternatives considered -- **A prebuilt native helper (`native/` family like `node-addon-landlock-run`).** Rejected: a mirror repository, an npm package family, MSVC provisioning, and a release handoff — all to ship ~150 lines of C the repository cannot exercise on CI (no real-Windows lane); koffi delivers the same COM surface with zero new supply chain. +- **A prebuilt native helper (`native/` family like `@deepseek-ai/node-addon-landlock-run`).** Rejected: another npm package family, MSVC provisioning, and a Windows build/release lane — all to ship ~150 lines of C the repository cannot currently exercise on CI (no real-Windows lane); koffi delivers the same COM surface with zero new supply chain. - **An N-API in-process addon.** Rejected for the same CI/toolchain reasons plus owned C++ for STA threading and message pumping that a child process + koffi express in TypeScript. - **Keep PowerShell primary and probe versions.** Rejected: the picker stays hostage to shell packaging (6 vs 7, Store aliases, profiles), and 5.1's legacy dialog remains the floor wherever pwsh is absent; the fallback-trigger widening alone was accepted into the fallback tier instead. - **Blocking the main thread for the modal call.** Rejected outright: the web host must keep serving RPC while the dialog is open. diff --git a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md index 6b90dc1c5f..ef81bc1f65 100644 --- a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md +++ b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md @@ -14,7 +14,7 @@ Windows 目录选择器的主层此前是围绕 WinForms `FolderBrowserDialog` ## 考虑过的替代方案 -- **预编译原生助手(`native/` 家族,如 `node-addon-landlock-run`)。** 否决:镜像仓库、npm 包家族、MSVC 供给和发布交接——只为交付约 150 行 CI 无法执行的 C(没有真 Windows 通道);koffi 以零新增供应链提供同一 COM 面。 +- **预编译原生助手(`native/` 家族,如 `@deepseek-ai/node-addon-landlock-run`)。** 否决:再增加一个 npm 包家族、MSVC 供给和 Windows 构建/发布通道——只为交付约 150 行目前无法在 CI 中执行的 C(现有 CI 没有真 Windows 通道);koffi 以零新增供应链提供同一 COM 面。 - **N-API 进程内插件。** 否决:同样的 CI/工具链原因,另加需要自有 C++ 处理 STA 线程与消息泵,而子进程 + koffi 用 TypeScript 就能表达。 - **保留 PowerShell 为主层并探测版本。** 否决:选择器仍被 shell 打包形态挟持(6 与 7、Store 别名、profile),且没有 pwsh 的机器地板仍是 5.1 的旧版对话框;仅把回退触发条件的拓宽吸收进回退层。 - **在主线程上阻塞模态调用。** 直接否决:对话框打开期间 web 宿主必须继续服务 RPC。 diff --git a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml index dcb3a9406b..51401adfdc 100644 --- a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md -2026-08-05-pwsh-ui-bash-parity.md: 6bbdb0e6bc69ef1af03a6a9146f83b84754cb2a6 -2026-08-05-pwsh-ui-bash-parity.zh.md: 75f3a3ddec002acaa1755c81114b0f122ab80593 +2026-08-05-pwsh-ui-bash-parity.md: 815b448b894e9c53b4c4a2076f6b94fdd316dd35 +2026-08-05-pwsh-ui-bash-parity.zh.md: 967c5a9e1409028043dc5028fdca640ddfeb1acc diff --git a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md index 6bbdb0e6bc..815b448b89 100644 --- a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md +++ b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md @@ -6,7 +6,7 @@ English | [中文](2026-08-05-pwsh-ui-bash-parity.zh.md) ## Problem -The [pwsh tool bash parity decision](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) made `dsh-tool-pwsh` behaviorally interchangeable with `dsh-tool-bash` for execution, markers, and background tasks, but explicitly deferred the human-visible half: a completed pwsh foreground call presented as a generic `console`-fenced card while the bash tool's completed call presented as a terminal card with a parsed exit-status pill. The roadmap that owned this gap ([Windows defaults to pwsh](../../proposed/feature/2026-08-01-windows-pwsh-default.md)) named "pwsh TUI/GUI rendering" as stage 2 — but the TUI package was removed ([`ed30088adb`](https://github.com/deepseek-harness/deepseek-harness/commit/ed30088adb)), leaving the Web surface as the only UI the gap affects. +The [pwsh tool bash parity decision](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) made `dsh-tool-pwsh` behaviorally interchangeable with `dsh-tool-bash` for execution, markers, and background tasks, but explicitly deferred the human-visible half: a completed pwsh foreground call presented as a generic `console`-fenced card while the bash tool's completed call presented as a terminal card with a parsed exit-status pill. The roadmap that owned this gap ([Windows defaults to pwsh](../../proposed/feature/2026-08-01-windows-pwsh-default.md)) named "pwsh TUI/GUI rendering" as stage 2, but the TUI package was removed, leaving the Web surface as the only UI the gap affects. ## Decision diff --git a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md index 75f3a3ddec..967c5a9e14 100644 --- a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md @@ -6,7 +6,7 @@ Status: implemented ## Problem -[pwsh 工具与 bash 对齐决策](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) 让 `dsh-tool-pwsh` 在执行、marker 与后台任务上行为可互换,但明确推迟了面向人类的一半:完成的 pwsh 前台调用呈现为通用 `console` 围栏卡片,而 bash 工具的完成调用呈现为带解析退出状态 pill 的 terminal 卡。拥有此缺口的路线图([Windows 默认改用 pwsh](../../proposed/feature/2026-08-01-windows-pwsh-default.md))把 "pwsh TUI/GUI 渲染" 列为阶段 2——但 TUI 包已被移除([`ed30088adb`](https://github.com/deepseek-harness/deepseek-harness/commit/ed30088adb)),Web 表面成为该缺口唯一影响的 UI。 +[pwsh 工具与 bash 对齐决策](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) 让 `dsh-tool-pwsh` 在执行、marker 与后台任务上行为可互换,但明确推迟了面向人类的一半:完成的 pwsh 前台调用呈现为通用 `console` 围栏卡片,而 bash 工具的完成调用呈现为带解析退出状态 pill 的 terminal 卡。拥有此缺口的路线图([Windows 默认改用 pwsh](../../proposed/feature/2026-08-01-windows-pwsh-default.md))把 "pwsh TUI/GUI 渲染" 列为阶段 2,但 TUI 包已被移除,使 Web 表面成为该缺口唯一影响的 UI。 ## Decision diff --git a/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.i18n.yaml new file mode 100644 index 0000000000..222ed1ebbe --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.md +2026-08-06-bundled-dsh-badge-skill.md: 512f67ca347ca311a1f80fef932f6af8c91b0fe9 +2026-08-06-bundled-dsh-badge-skill.zh.md: 88fcadf66944cb91419441be3916ae04968be663 diff --git a/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.md b/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.md new file mode 100644 index 0000000000..512f67ca34 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.md @@ -0,0 +1,25 @@ +# Agent Note: Bundled dsh badge skill + +Status: implemented + +English | [中文](2026-08-06-bundled-dsh-badge-skill.zh.md) + +## Problem + +The [Cordis tutorial](../../../../docs/cordis-tutorial/index.md) uses an official “powered by dsh” badge across its pages, but the shipped CLI has no reusable instructions or explicit opt-in provider for applying the same attribution elsewhere. + +## Decision + +`@deepseek-ai/dsh-skill-badge` is a native Cordis plugin that registers one immutable bundled provider on `ctx.skills`. The provider owns the `dsh-badge` summary, instruction body, and PNG resource base; `dsh-tool-skill` remains the sole owner of model-facing catalog and loader rendering. + +The shipped CLI composition declares `skill-badge` as disabled. Enabling that existing row is the explicit opt-in; disabled installations advertise no badge skill and gain no model-visible content. + +The provider uses the bundled rank after project, custom, and user filesystem sources, so a user-owned `dsh-badge` definition can override it through the ordinary registry precedence contract. Provider disposal removes the contribution through the registry-owned effect. + +## Alternatives considered + +**Mount packaged files through `dsh-skill-local`.** Rejected because filesystem discovery, parsing, and watching add lifecycle machinery that an immutable single-skill provider does not need. + +## Consequences + +The badge instructions and source PNG are versioned with DSH and resolve through a packaged directory resource base. The provider has no configuration surface. Package tests pin provider lifecycle and the official PNG bytes, while a keyless assembled-application snapshot pins the enabled catalog and loaded skill body. diff --git a/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.zh.md b/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.zh.md new file mode 100644 index 0000000000..88fcadf669 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.zh.md @@ -0,0 +1,25 @@ +# Agent Note: 内置 dsh 徽章 skill + +Status: implemented + +[English](2026-08-06-bundled-dsh-badge-skill.md) | 中文 + +## 问题 + +[Cordis 教程](../../../../docs/cordis-tutorial/index.md)的各个页面都使用官方「powered by dsh」徽章,但交付的 CLI(命令行界面)既没有用于在其他位置应用同样署名的可复用指令,也没有可显式选择加入的提供方。 + +## 决策 + +`@deepseek-ai/dsh-skill-badge` 是一个原生 Cordis 插件,会在 `ctx.skills` 上注册一个不可变的内置提供方。该提供方负责 `dsh-badge` 的摘要、指令正文和 PNG 资源基底;`dsh-tool-skill` 仍是面向模型的目录与 loader 渲染的唯一归属方。 + +交付的 CLI 组合将 `skill-badge` 声明为禁用。启用这个现有配置行就是显式选择加入;禁用它的安装实例不会公开任何徽章 skill(技能),也不会获得任何模型可见内容。 + +该提供方使用排在项目、自定义及用户文件系统来源之后的内置 rank,因此用户自有的 `dsh-badge` 定义可通过注册表的常规优先级契约覆盖它。提供方释放时,注册表拥有的 effect 会移除该贡献。 + +## 曾考虑的替代方案 + +**通过 `dsh-skill-local` 挂载随包文件。** 否决,因为文件系统发现、解析和监视会引入生命周期机制,而不可变的单一 skill 提供方并不需要这些机制。 + +## 后果 + +徽章指令和源 PNG 随 DSH 一同纳入版本管理,并通过以随包目录为基础的资源基底解析。该提供方没有配置面。包测试固定提供方生命周期和官方 PNG 的字节内容;无密钥的组装应用快照则固定启用后的目录和已加载的 skill 正文。 diff --git a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.i18n.yaml new file mode 100644 index 0000000000..bc2d1575ee --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md +2026-08-08-dsh-run-headless-command.md: d1cc0573d1d94bb7b30e98aa685d2b7d89878a99 +2026-08-08-dsh-run-headless-command.zh.md: 7b48dfd708efb197babcfcbeaf4f8a4ca76cde3f diff --git a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md new file mode 100644 index 0000000000..d1cc0573d1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md @@ -0,0 +1,41 @@ +# Agent Note: `dsh run` owns one-shot headless execution + +Status: implemented + +English | [中文](2026-08-08-dsh-run-headless-command.zh.md) + +## Problem + +The product launcher attached optional task text to its generic profile boot: `dsh --profile headless "task"`. That made one argv shape mean either a long-lived profile or a one-shot run according to a row discovered only after composition. The parser's `ProfileInvocation` carried optional task state, help presented a profile implementation detail as the user command, and a custom profile could accept a task only through the same overloaded root. + +The former `dsh -p` spelling was already absent from the parser, so restoring it or detecting it specially would add compatibility machinery to a pre-release interface. A separate application-file proposal also used the `run` verb, leaving two incompatible owners for one top-level command. + +## Decision + +One-shot execution owns an explicit grammar: + +```text +dsh run [--profile ] [--patch ...] +``` + +`--profile` defaults to `headless` and remains available for custom one-shot compositions. `--patch` is repeatable and occupies the existing overlay layer. Commander joins the variadic task arguments with spaces and rejects a missing or blank task before boot. + +The [profile plugin bundle decision](../architecture/2026-08-05-profile-plugin-bundles.md) owns the composition selected by this grammar. + +`RunInvocation` is a separate `DshInvocation` member. The generic profile invocation no longer carries task text, and its root command accepts no positional arguments. Both dispatch paths call the existing deep `runProfile` module: `profile` omits `task`, while `run` supplies it. There is no shallow `run.ts` forwarding module and no alias, warning, or custom detector for former spellings; they fail through the ordinary Commander grammar. A one-shot profile without `headless-runner` still fails through the existing composed-row check, while booting a profile that contains that row without a task points to `dsh run --profile ""`. + +The `run` verb belongs to one-shot task execution. Launching an application file must choose another command name; two top-level meanings selected by positional shape would recreate the ambiguity this command removes. + +The runner's user-visible contract stays the same: a fresh persisted session, browser observation URL on stderr, final assistant text on stdout, completed/non-completed exit mapping, and bounded signal shutdown. The product-level keyless acceptance exposed that the in-process mux consumer could lag the same-process `agent/status: idle` notification and derive output before reading the final frames. The idle notification now captures the authoritative final session sequence, and the runner waits until the ordered mux reaches that boundary (or the stream ends) before deriving text and exit reason. This enforces the existing idle-to-idle contract without adding a wire field or a timing delay. + +## Alternatives considered + +- **Keep task text on `dsh --profile`.** Rejected because profile boot and one-shot execution remain one grammar whose meaning depends on a late composition check. +- **Preserve `dsh -p` or the positional profile form as aliases.** Rejected under the pre-release stance: compatibility branches would outlive the interface they were meant to retire. +- **Make `--profile headless` mandatory under `run`.** Rejected because the shipped one-shot surface should have the shortest canonical spelling, while optional `--profile` preserves plugin-defined one-shot compositions. +- **Give `dsh run` to application-file launch and choose another headless verb.** Rejected because `run` describes executing a task through the harness; application-file ownership would make the product's primary one-shot command less direct and collide with custom one-shot profiles. +- **Add `apps/cli/src/run.ts`.** Rejected because it would only forward to `runProfile`, splitting command ownership without hiding any complexity. + +## Consequences + +This is an intentional breaking CLI change. Documentation, help, parser tests, built-bin acceptance, PTY shutdown coverage, and the assembled keyless snapshot use `dsh run`. Existing custom one-shot profiles keep working through `--profile`; long-lived profiles and config dumps retain their existing root grammar. The competing application-file command must be renamed and rebased separately rather than sharing or overloading `run`. diff --git a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md new file mode 100644 index 0000000000..7b48dfd708 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md @@ -0,0 +1,41 @@ +# Agent Note: `dsh run` 负责一次性 headless 执行 + +Status: implemented + +[English](2026-08-08-dsh-run-headless-command.md) | 中文 + +## 问题 + +产品启动器过去把可选任务文本挂在通用 profile 启动命令上:`dsh --profile headless "task"`。于是,同一种 argv 形态会表示常驻 profile 或一次性运行,具体含义取决于组合完成后才发现的配置行。解析器的 `ProfileInvocation` 携带可选任务状态,帮助信息把 profile 的实现细节呈现为用户命令,自定义 profile 也只能通过同一个过载的根命令接收任务。 + +解析器中已经没有原来的 `dsh -p` 写法,因此恢复该写法或加入特殊检测,会给预发布接口增加兼容机制。另一个应用文件提案也使用 `run` 动词,使同一个顶层命令同时归属两个互不兼容的功能。 + +## 决策 + +一次性执行采用明确语法: + +```text +dsh run [--profile ] [--patch ...] +``` + +`--profile` 默认为 `headless`,同时保留对自定义一次性组合的支持。`--patch` 可重复使用,并沿用既有 overlay 层的位置。Commander 用空格拼接可变数量的任务参数,并在启动前拒绝缺失或空白任务。 + +[profile 插件组合包决策](../architecture/2026-08-05-profile-plugin-bundles.md)负责该语法所选择的组合。 + +`RunInvocation` 是单独的 `DshInvocation` 成员。通用 profile 调用不再携带任务文本,其根命令也不接受位置参数。两条分派路径都调用已有的深层 `runProfile` 模块:`profile` 省略 `task`,`run` 则提供该字段。实现中没有只负责转发的浅层 `run.ts` 模块,也没有面向旧写法的别名、警告或自定义检测器;旧写法会按普通 Commander 语法失败。缺少 `headless-runner` 的一次性 profile 仍会触发既有的组合行检查;如果启动的 profile 包含该行却未提供任务,错误会指向 `dsh run --profile ""`。 + +`run` 动词只负责一次性任务执行。应用文件启动必须选择其他命令名;如果让两个顶层含义由位置参数形态决定,就会重新引入本命令消除的歧义。 + +运行器面向用户的契约保持不变:创建新的持久化会话,在 stderr 打印浏览器观察 URL,在 stdout 打印最终 assistant 文本,将完成/未完成映射为退出状态,并执行有界的信号关闭。产品级无密钥验收用例发现,进程内 mux 消费方可能落后于同进程的 `agent/status: idle` 通知,在读到最终帧之前就生成输出。idle 通知现在会捕获权威的会话最终事件序号,运行器则等待有序 mux 到达该边界(或流结束),再生成文本和退出原因。这一机制在不增加 wire 字段或定时延迟的前提下,落实了既有的 idle-to-idle 契约。 + +## 考虑过的替代方案 + +- **把任务文本保留在 `dsh --profile` 上。** 不予采纳:profile 启动和一次性执行仍共用同一套语法,其含义取决于较晚发生的组合检查。 +- **保留 `dsh -p` 或位置参数 profile 形式作为别名。** 不予采纳:根据预发布立场,这些兼容分支会比本应退役的接口存续更久。 +- **要求在 `run` 下必须指定 `--profile headless`。** 不予采纳:已交付的一次性接口应采用最短的规范写法,同时用可选的 `--profile` 保留插件定义的一次性组合。 +- **把 `dsh run` 交给应用文件启动,并为 headless 选择另一个动词。** 不予采纳:`run` 描述的是通过 harness 执行任务;若归应用文件所有,产品的主要一次性命令会更不直接,并与自定义一次性 profile 冲突。 +- **新增 `apps/cli/src/run.ts`。** 不予采纳:它只会转发到 `runProfile`,拆分命令归属,却没有隐藏任何复杂度。 + +## 后果 + +这是一次有意为之的 CLI(命令行界面)破坏性变更。文档、帮助信息、解析器测试、构建后二进制验收、PTY 关闭覆盖和组装应用的无密钥快照都使用 `dsh run`。现有自定义一次性 profile 可继续通过 `--profile` 工作;常驻 profile 和配置 dump 保留既有的根命令语法。与之竞争的应用文件命令必须单独改名并 rebase,不得共享或重载 `run`。 diff --git a/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.i18n.yaml new file mode 100644 index 0000000000..3639c8da6b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.md +2026-08-08-pi-ai-per-model-reasoning-declarations.md: b6264feeb724e3693078fa3fc3e3fc16ed01aacb +2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md: 1b30f7e0c42974c777a535e133a47caa217e2e5e diff --git a/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.md b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.md new file mode 100644 index 0000000000..b6264feeb7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.md @@ -0,0 +1,33 @@ +# Agent Note: Per-Model Reasoning Declarations in llm-pi-ai + +Status: implemented + +English | [中文](2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md) + +## Problem + +Under the declared-provider catalog ([[2026-08-03-pi-ai-declared-provider-catalog]], which deliberately kept reasoning out of the configurable fields), a hand-declared pi-ai route's models materialized with `reasoning: false`, so `getSupportedThinkingLevels` short-circuited to `["off"]`: the composer offered no effort picker for them, and the route-level `reasoning` default — the only reasoning knob a profile had — made every request to such a model fail with `UNSUPPORTED_REASONING_EFFORT` before network I/O. The same route-level knob was also the wrong altitude for catalog routes: one provider's models disagree about which levels they accept (deepseek ships `[off, high, max]` beside catalog models with `xhigh`), so a single per-route level could not be set without breaking part of the route, which is why the Models page stopped writing it entirely (#1860) and left `settings.yaml` with no way to align efforts per model. + +Two adjacent gaps compounded this. pi-ai decides the reasoning *wire dialect* (`compat.thinkingFormat`, `compat.supportsReasoningEffort`) by recognizing the endpoint URL, and a private gateway's URL says nothing — a DeepSeek-dialect gateway was spoken to in the OpenAI dialect with no configuration that could correct it. And the only way to touch one catalog model was the `models` list, which *replaces* the served catalog: narrowing `gpt-5`'s levels meant restating all thirty-eight openai models or silently dropping thirty-seven. + +## Decision + +`PiAiModelProfile` gains `reasoningEfforts`: **each key is a level selectors offer, its value the spelling dispatch sends on the wire**. The declaration translates to pi-ai's `Model.reasoning` + `thinkingLevelMap` with all seven levels decided explicitly — declared levels carry their wire value, undeclared levels are pinned `null` — so the profile author never needs pi-ai's asymmetric defaulting rule (absent means "supported" for the five base levels but "unsupported" for `xhigh`/`max`). `off` is the one three-state key: left out, no Off is offered and an explicit Off request is refused (an effortless request still goes out bare, leaving the provider its default); declared valueless, Off is offered and dispatch sends nothing (the `deepseek` dialect sends `thinking: {type: "disabled"}`); declared with a value, that value goes on the wire. `false` declares a non-reasoning model; an empty declaration is refused rather than guessed at. The spelling for "disable" is `false` rather than `{}` because schemastery materializes an absent dict as `{}` — only a `z.union([z.const(false), dict])` keeps absent, disabled, and declared distinguishable, and a bare `reasoningEfforts:` (YAML null) slips through that union unvalidated, so resolution refuses it explicitly. + +`compat.thinkingFormat` and `compat.supportsReasoningEffort` become configurable at two levels — route (its models' default) and model (winning per field) — resolving model → route → installed catalog entry → pi-ai's URL guess. They exist only on `openai-completions` (pi-ai types them nowhere else): a model-level switch on another protocol fails resolution, a route-level default skips such models, and a route with no completions model at all is refused. The two `chat-template` formats stay withheld for want of `chatTemplateKwargs`. Both enums are pinned to pi-ai's types through `Record` drift gates, so a pi-ai upgrade that adds a format fails compilation until the new member is classified (verified against the published 0.84.1 tarball, whose `thinkingFormat` union adds `baseten` over the pinned 0.82.1). + +`modelOverrides` reshapes individual catalog models without replacing the served set: key = catalog model id, value = a `models` entry minus `id`, materialized by handing the override to the existing entry path so capacities, efforts, compat, and request-default semantics stay identical. Unlike Pi's own config layer, which ignores unknown ids, every override that lands nowhere is refused — beside a `models` list, on a hand-declared route, naming an unknown model, or smuggling an `id` in the value (the schema passes unknown keys through, and a smuggled id would quietly rename the model). + +## Alternatives considered + +- **Pass `reasoning` + `thinkingLevelMap` through verbatim** (pi-ai's own radius-config shape). Rejected by the user for operator confusion: the map's `null`-marks-unsupported convention plus the asymmetric absent-key rule mean the config's meaning depends on knowledge of pi-ai internals; the chosen shape makes the key set itself the offer. +- **A bare level list** (`reasoningEfforts: [off, high]`). Cannot express wire renames, and the catalog's own maps prove renames are real: 66 of 1230 installed map entries are non-identity (`off→none`, `minimal→low`, `low→LOW`, `high→default`). +- **`{}` as the disable spelling.** Unimplementable: schemastery materializes an absent dict as `{}`, so every model without the field would have been force-disabled. +- **Folding this into the route-level `reasoning` knob.** That knob is a *default selection*, not a capability set; it stays, and a declared model's efforts now bound what it can select. + +## Consequences + +- The composer's effort pane works for hand-declared models with zero UI change — `resolveModelInfo` reports declared levels through the same seam catalog metadata uses (pinned by the `declared-reasoning` web scenario). +- #1860's deferred gap — a route-level effort a model cannot take failing its requests — now has an operator remedy: align the model's `reasoningEfforts` or drop the route default. +- There is deliberately no spelling for returning one map key or compat field to "whatever the catalog said": the declaration is the whole offer, so keeping a catalog value means restating it. The README documents this. +- `verify-package-invariants` is untouched: the feature adds configuration resolution, no new events or mutable runtime relations. diff --git a/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md new file mode 100644 index 0000000000..1b30f7e0c4 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md @@ -0,0 +1,33 @@ +# Agent Note: llm-pi-ai 的按模型推理声明 + +Status: implemented + +[English](2026-08-08-pi-ai-per-model-reasoning-declarations.md) | 中文 + +## 问题 + +在声明式提供方 catalog([[2026-08-03-pi-ai-declared-provider-catalog]],它刻意把推理排除在可配置字段之外)之下,手工声明的 pi-ai 路由,其模型物化出来就带着 `reasoning: false`,于是 `getSupportedThinkingLevels` 短路成 `["off"]`:输入框不为它们提供档位选择器,而路由级的 `reasoning` 默认值——当时 profile 仅有的推理旋钮——让发往这类模型的每个请求都在网络 I/O 之前以 `UNSUPPORTED_REASONING_EFFORT` 失败。同一个路由级旋钮对 catalog 路由来说也放错了层级:同一提供方下各模型接受的档位并不一致(deepseek 自带 `[off, high, max]`,旁边就是带 `xhigh` 的 catalog 模型),单个路由级档位怎么设都会弄坏路由的一部分——这正是模型页彻底停写它的原因(#1860),而 `settings.yaml` 也因此没有了任何按模型对齐档位的办法。 + +两个相邻的缺口让问题雪上加霜。pi-ai 靠识别端点 URL 来决定推理的*协议方言*(`compat.thinkingFormat`、`compat.supportsReasoningEffort`),而私有网关的 URL 什么也说明不了——说 DeepSeek 方言的网关只会收到 OpenAI 方言的请求,且没有任何配置能更正它。另外,想动单个 catalog 模型,唯一的手段是 `models` 列表,而它会*替换*所服务的 catalog:收窄 `gpt-5` 的档位,意味着要么重述全部三十八个 openai 模型,要么静默丢掉三十七个。 + +## 决策 + +`PiAiModelProfile` 新增 `reasoningEfforts`:**每个键是选择器提供的一个档位,其值是分派在协议中发送的拼写**。该声明会转换为 pi-ai 的 `Model.reasoning` + `thinkingLevelMap`,七个档位全部显式决定——已声明的档位携带自己的协议值,未声明的档位一律固定为 `null`——因此 profile 作者永远不需要了解 pi-ai 那条不对称的默认规则(键缺席对五个基础档位意味着「支持」,对 `xhigh`/`max` 却意味着「不支持」)。`off` 是唯一的三态键:不写,选择器不提供 Off,显式请求 Off 会被拒绝(不点名档位的请求仍会不带参数地发出,提供方保留自己的默认行为);声明而不给值,则提供 Off,分派什么也不发送(`deepseek` 方言发送 `thinking: {type: "disabled"}`);声明并给值,该值就在协议中发送。`false` 声明一个不具备推理能力的模型;空声明会被拒绝,而不是去猜。「禁用」的拼写取 `false` 而非 `{}`,因为 schemastery 会把缺席的字典物化成 `{}`——只有 `z.union([z.const(false), dict])` 才能让缺席、禁用与已声明三态保持可区分;而裸写的 `reasoningEfforts:`(YAML null)会不经校验地从该 union 溜过去,因此解析对它显式拒绝。 + +`compat.thinkingFormat` 与 `compat.supportsReasoningEffort` 变为两级可配置——路由级(作为其模型的默认值)与模型级(逐字段胜出)——解析顺序为模型 → 路由 → 已安装 catalog 条目 → pi-ai 按 URL 得出的猜测。两者只存在于 `openai-completions` 上(pi-ai 也只在这一协议上为它们建了类型):在其他协议的模型上设模型级开关会使解析失败,路由级默认值会跳过这类模型,而完全没有 completions 模型的路由则被拒绝。两个 `chat-template` 格式因缺 `chatTemplateKwargs` 而继续保持不开放。两个枚举都经 `Record` 漂移门禁钉在 pi-ai 的类型上,因此新增格式的 pi-ai 升级会编译失败,直到新成员被归类(对照已发布的 0.84.1 tarball 验证过:其 `thinkingFormat` 联合类型相对钉住的 0.82.1 新增了 `baseten`)。 + +`modelOverrides` 就地重塑单个 catalog 模型而不替换所服务的集合:键 = catalog 模型 id,值 = 去掉 `id` 的 `models` 条目,物化时把覆盖交给既有的条目路径,因此容量、档位、compat 与请求默认值语义完全一致。与忽略未知 id 的 Pi 自有配置层不同,凡是落不到任何地方的覆盖都会被拒绝——与 `models` 列表并存、写在手工声明的路由上、点名未知模型,或在值里夹带 `id`(schema 会放行未知键,被夹带的 id 会悄悄把模型改名)。 + +## 曾考虑的替代方案 + +- **把 `reasoning` + `thinkingLevelMap` 原样透传**(pi-ai 自家 radius 配置的形状)。用户以运维人员困惑为由否决:map 用 `null` 标记「不支持」的约定,加上不对称的键缺席规则,意味着这份配置的含义取决于对 pi-ai 内部机制的了解;选定的形状则让键集合本身就是对外提供的全部。 +- **裸档位列表**(`reasoningEfforts: [off, high]`)。表达不了协议侧改名,而 catalog 自己的 map 证明改名真实存在:1230 条已安装 map 条目里有 66 条不是恒等映射(`off→none`、`minimal→low`、`low→LOW`、`high→default`)。 +- **用 `{}` 作为禁用拼写。** 无法实现:schemastery 会把缺席的字典物化成 `{}`,于是每个没写该字段的模型都会被强制禁用。 +- **把这件事并进路由级的 `reasoning` 旋钮。** 那个旋钮是*默认选择*,不是能力集合;它保留下来,而已声明模型的档位如今约束着它能选什么。 + +## 后果 + +- 输入框的档位面板对手工声明的模型直接可用,UI 零改动——`resolveModelInfo` 经 catalog 元数据所走的同一 seam 报告已声明档位(由 `declared-reasoning` web 场景钉住)。 +- #1860 暂缓的缺口——模型接不住的路由级档位会让发往它的请求失败——如今有了运维侧补救:对齐该模型的 `reasoningEfforts`,或去掉路由默认值。 +- 刻意不提供任何把单个 map 键或 compat 字段交还给「catalog 原本怎么说」的拼写:这份声明就是对外提供的全部,要保留某个 catalog 值就得重述它。README 记载了这一点。 +- `verify-package-invariants` 原封未动:该功能新增的是配置解析,没有新事件,也没有可变的运行时关系。 diff --git a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.i18n.yaml new file mode 100644 index 0000000000..3774ba6e69 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md +2026-08-08-user-explicit-skill-invocation.md: d925938279923282170dc99934f4fa44d8ecf2b4 +2026-08-08-user-explicit-skill-invocation.zh.md: 64e23be0b42519fb9681adefcd0f05074d3aa35e diff --git a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md new file mode 100644 index 0000000000..d925938279 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md @@ -0,0 +1,36 @@ +# Agent Note: User-explicit skill invocation at the pre-step gesture boundary + +Status: implemented + +English | [中文](2026-08-08-user-explicit-skill-invocation.zh.md) + +## Problem + +A `disable-model-invocation: true` skill is user-only by design: it never enters the model-facing catalog and the `skill` tool refuses to load it. Its only legitimate entry point is an explicit user gesture — yet the web client had none. `skill.list` filtered to the model-and-user intersection (hiding user-only skills from the menu), an entered `/name` line rode into the default prompt sink as plain text, and the model it reached was forbidden to load the skill — so it degraded to `read`-ing the SKILL.md file or ignoring the gesture (issue #1470). Even for ordinary skills, the decision-21 plain-text reference made user invocation a collaboration cue the model could ignore, not a guarantee. + +## Decision + +User-explicit invocation is a host-side pre-step injection, uniform for every user-invocable skill and every front end: + +- `dsh-tool-skill` registers a second `agent/pre-step` listener (beside its catalog listener, the same seam `workspace-instructions` and the runtime-context snapshot ride): it scans the step's claimed messages for whitespace-bounded `/name` tokens — anywhere in the text, the same word-boundary shape the transcript chip decoration uses — collects first-seen-deduplicated names, loads each through `ctx.skills.get`, checks `isUserInvocable` on the loaded definition (the single lookup that produces what is injected), renders it with the shared `renderSkillContent`, and appends the injections after every other injection of the step: background first (workspace rules, runtime policy, catalog), the material the model must act on last, closest to its answer. Registration order pins the placement — the gesture listener registers before the catalog listener, so the waterfall hands it the catalog-bearing list to extend. +- Precision is closed-set matching, exactly like slash commands: `/goal` resolves against the command registry, `/name` against the workspace's user-invocable skill directory; a miss stays ordinary prose, so nothing is ever guessed. Only `source.kind === 'user'` messages are scanned — external text cannot forge a gesture. Paths (`/usr/bin`), fractions (`5/8`), and prefixed tokens (`foo/name`) all break the boundary. +- The client stays decision 21: a menu pick lands the literal `/name ` and the prompt ships it verbatim; ui-skill implements no adjudication hooks and no reference codec. `skill.list` (now the domain's only RPC) serves every user-invocable skill with `modelInvocable` so menus mark user-only entries. A name shared with a host command resolves to the command — adjudication claims the line client-side before it becomes a prompt. +- The injection is a `user`-role message carrying the `skill-invocation` source (`{ name, form: 'instructions' }`), so `user/message` logging, the context-injection transcript row (labelled with the skill name), and replay all come free; `renderSkillContent` lives in the `dsh-skill` seam, shared verbatim with the `skill` tool result, and the catalog's closing sentence tells the model to follow an injected block instead of re-loading it. + +Peer-product survey (Pi, OpenCode, Claude Code, Kimi Code, Codex, DeepSeek-Reasonix — local checkouts) was unanimous that user-explicit triggering is programmatic injection with zero model participation; the final shape is closest to Codex's core-side `$name` mention scanning, which likewise frees every front end from implementing recognition. + +## Alternatives considered + +- **`skill.invoke` RPC (host injects, client claims)** — implemented first, in two iterations: a single mixed message (user text folded into the body), then a gesture prompt plus injection delivered through inbox primitives. Rejected after real-session testing: the mixed message polluted the injection with user prose; the two-message form depended on wake-ordering subtleties (`followup` claims the whole next-turn queue synchronously inside the first waking call, stranding any later message in the next turn — reproduced live), and the dedicated RPC duplicated a path `session.prompt` already provides while leaving TUI/ACP to reimplement recognition. The pre-step seam removes the RPC, the claim machinery, and the ordering hazard outright. +- **`agent.inject()` from the RPC handler** — the inject queue (`next-step`, wake-free) is claimed ahead of the next-turn prompt, putting the injection above the gesture in the log; and pairing it with a waking `followup` reintroduces the same ordering coupling. The pre-step listener injects inside the step assembly, where ordering is explicit. +- **A host `/skill ` command** (command registry, plan-mode precedent) — two-token UX, no name completion, and user-only skills stay undiscoverable in the menu; the per-cwd skill catalog also fits the static command registry poorly. Rejected. +- **Client-side expansion** (fetch body, splice into the prompt) — authorization becomes bypassable client courtesy, the log loses the invocation semantics, and Codex deleted its equivalent mechanism (custom prompts) in favor of core injection. Rejected. +- **Structured reference payload on the prompt wire** (Codex's `UserInput::Skill` analogue: the client ships `{skills: [...]}` beside the text and the boundary prefers it over scanning) — considered and deferred: the existing slash-command system is itself line-text on the wire, and closed-set directory matching already removes the guesswork; recorded as a ledger item should gesture precision ever need client intent. +- **Per-injection preamble line** (Kimi's `User activated the skill …`) — dropped in favor of the one-time catalog sentence: same context, paid once, and the injected block stays byte-identical with the tool result. + +## Consequences + +- Decision 21's plain-text reference is now the whole client story: the draft carries plain text, chip visuals derive from the lexicon, and the sent text is judged by the host boundary — a hand-typed gesture, a menu pick, and a TUI prompt are indistinguishable and equally deterministic. +- Every user-invocable skill invocation costs its full rendered body unconditionally — the price of determinism the peer survey showed everyone pays. Mentioning a known skill name mid-sentence loads it; that is the Codex mention semantic, accepted deliberately. +- The `skill-invocation` source rides `user/message`, so Model-visible ⟺ logged holds with no new event type, and replay/UI read metadata rather than text markers. +- Accepted residual of dropping the per-injection preamble: the no-reload framing rides only the catalog, and a workspace whose skills are all user-only never publishes a first catalog — an injection can arrive with no framing at all, and the model may redundantly try the `skill` tool once (the replacement catalog's empty arm carries the sentence; the never-published case does not). Publishing a catalog for framing alone was judged worse than that one recoverable error. diff --git a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md new file mode 100644 index 0000000000..64e23be0b4 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md @@ -0,0 +1,36 @@ +# Agent Note: pre-step 手势边界上的用户显式 skill 调用 + +Status: implemented + +[English](2026-08-08-user-explicit-skill-invocation.md) | 中文 + +## 问题 + +`disable-model-invocation: true` 的 skill(技能)在设计上就是仅限用户的:它绝不进入面向模型的目录,`skill` 工具也拒绝加载它。它唯一正当的入口是一次显式的用户手势——而 web 客户端此前没有这个入口。`skill.list` 过滤到模型与用户的交集(把仅限用户的 skill 挡在菜单之外),回车提交的 `/name` 一行以纯文本落入默认提示词 sink,而这行文本到达的模型又被禁止加载该 skill——于是退化为模型去 `read` 那份 SKILL.md 文件,或者干脆无视这次手势(issue #1470)。即使对普通 skill,决策 21 的纯文本引用也让用户调用只是模型可以忽略的协作线索,而不是保证。 + +## 决策 + +用户显式调用是一次宿主侧的 pre-step 注入,对每一个用户可调用的 skill 和每一种前端一致: + +- `dsh-tool-skill` 注册第二个 `agent/pre-step` 监听器(与其目录监听器并列,也是 `workspace-instructions` 与运行时上下文快照搭乘的同一 seam):它在该步骤已认领的消息中扫描以空白为界的 `/name` token——文本中任意位置均可,与 transcript(文本记录)chip 装饰所用的词边界形状相同——收集按首见去重的名称,逐个经 `ctx.skills.get` 加载,在已加载定义上检查 `isUserInvocable`(产生注入内容的正是这同一次查找),用共享的 `renderSkillContent` 渲染,并把注入追加在该步骤所有其他注入之后:背景在前(工作区规则、运行时策略、目录),模型必须着手处理的材料在最后、最贴近它的回答。注册顺序钉住了这一位置——手势监听器先于目录监听器注册,因此 waterfall 会把携带目录的列表交给它来扩展。 +- 精确性来自封闭集合匹配,与斜杠命令完全一致:`/goal` 对照命令注册表解析,`/name` 对照工作区的用户可调用 skill 目录解析;未命中即保持为普通行文,因此绝不猜测。只扫描 `source.kind === 'user'` 的消息——外部文本无法伪造手势。路径(`/usr/bin`)、分数(`5/8`)与带前缀的 token(`foo/name`)都会破坏该边界。 +- 客户端停留在决策 21:菜单 pick 落下字面文本 `/name `,提示词将其原样发出;ui-skill 不实现任何裁决钩子,也没有引用 codec。`skill.list`(现在是该领域唯一的 RPC)提供每一个用户可调用的 skill 并携带 `modelInvocable`,供菜单标出仅限用户的条目。与宿主命令同名的名称解析为命令——裁决在客户端把该行认领走,它尚未成为提示词。 +- 注入是一条携带 `skill-invocation` 来源(`{ name, form: 'instructions' }`)的 `user` 角色消息,因此 `user/message` 落账、上下文注入的 transcript 行(以 skill 名称标注)与回放全部免费获得;`renderSkillContent` 位于 `dsh-skill` seam,与 `skill` 工具结果逐字共享,目录的结尾一句会告诉模型遵循注入块而不是重新加载。 + +同类产品调研(Pi、OpenCode、Claude Code、Kimi Code、Codex、DeepSeek-Reasonix——本地检出)一致表明:用户显式触发都是模型零参与的程序化注入;最终形态最接近 Codex 核心侧的 `$name` mention 扫描——它同样让每一种前端免于自行实现识别。 + +## 考虑过的替代方案 + +- **`skill.invoke` RPC(宿主注入、客户端认领)**——最先实现,共两轮迭代:先是单条混合消息(用户文本折进正文),后是经 inbox 原语投递的手势提示词加注入两条消息。经真实会话测试后否决:混合消息让用户行文污染了注入;两条消息的形态依赖唤醒顺序的微妙之处(`followup` 在第一个唤醒调用内同步认领整个 next-turn 队列,把之后的消息滞留到下一轮次——已实际复现),而专设 RPC 复制了 `session.prompt` 已提供的路径,还让 TUI/ACP 不得不各自重新实现识别。pre-step seam 把 RPC、认领机制与顺序隐患一并干净移除。 +- **从 RPC 处理器调用 `agent.inject()`**——inject 队列(`next-step`,不唤醒)会在 next-turn 提示词之前被认领,使注入在日志中排到手势之上;而与会唤醒的 `followup` 搭配又会重新引入同样的顺序耦合。pre-step 监听器在步骤组装内部注入,那里的顺序是显式的。 +- **宿主 `/skill ` 命令**(命令注册表,plan 模式先例)——两 token 的 UX、没有名称补全、仅限用户的 skill 在菜单里仍不可发现;按 cwd 的 skill 目录也与静态命令注册表格格不入。否决。 +- **客户端展开**(拉取正文、拼进提示词)——授权沦为可被绕过的客户端善意,日志失去调用语义,而且 Codex 已删除其等价机制(custom prompts)转向核心注入。否决。 +- **提示词协议上的结构化引用载荷**(Codex `UserInput::Skill` 的类似物:客户端在文本旁附带 `{skills: [...]}`,边界优先采用它而不是扫描)——考虑过并暂缓:现有斜杠命令体系在协议上本身就是行文本,封闭集合的目录匹配已经消除了猜测;已记为台账事项,以备手势精确性某天需要客户端意图。 +- **每次注入一条前导语**(Kimi 的 `User activated the skill …`)——弃用,改为一次性的目录句子:同样的上下文、只支付一次,且注入块与工具结果保持逐字节一致。 + +## 后果 + +- 决策 21 的纯文本引用如今就是客户端的全部故事:草稿承载纯文本,chip 视觉由 lexicon 派生,发出的文本由宿主边界评判——手动键入的手势、菜单 pick 与 TUI 提示词无从区分,也同等确定。 +- 每一次用户可调用 skill 的调用都无条件付出其完整渲染正文的成本——这是确定性的代价,同类调研表明所有产品都在支付。在句子中间提到一个已知 skill 名称也会加载它;这就是 Codex 的 mention 语义,属于有意接受。 +- `skill-invocation` 来源搭乘 `user/message`,因此「模型可见 ⟺ 已记录」在不新增事件类型的情况下继续成立,回放与 UI 读取的是元数据而非文本标记。 +- 放弃逐次注入前导语后被接受的残余:no-reload framing 只搭乘目录,而 skill 全部为仅用户的工作区永远不会发布首个目录——注入可能在完全没有 framing 的情况下到达,模型可能多余地调用一次 `skill` 工具(替换目录的空臂携带该句;从未发布的情形没有)。仅为 framing 而发布目录被判定比这一次可恢复的错误更糟。 diff --git a/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml b/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml index fe42a92f75..a126f8c0d0 100644 --- a/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-06-17-ts-build-config.md -2026-06-17-ts-build-config.md: ec4c6a4aeb1074a69d45b2cf4b4c733b410ccceb -2026-06-17-ts-build-config.zh.md: 8115bb2557eea967d57eb6693e2bb288188792a8 +2026-06-17-ts-build-config.md: f25731921aaa6da7a4c9760244f73dc0670dc1d3 +2026-06-17-ts-build-config.zh.md: 41791129647b6a5bfeb966e05ee2c5b9132c45ef diff --git a/.agents/notes/implemented/process/2026-06-17-ts-build-config.md b/.agents/notes/implemented/process/2026-06-17-ts-build-config.md index ec4c6a4aeb..f25731921a 100644 --- a/.agents/notes/implemented/process/2026-06-17-ts-build-config.md +++ b/.agents/notes/implemented/process/2026-06-17-ts-build-config.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-06-17-ts-build-config.zh.md) -> Root project topology (which tsconfig owns which graph) has since moved to a solution root over two aggregate programs; see the [solution-root note](2026-07-22-tsconfig-solution-root-two-aggregates.md). The tsc-first pipeline decided here is unchanged. +> Root project topology uses a solution root over two aggregate programs; see the [solution-root note](2026-07-22-tsconfig-solution-root-two-aggregates.md). The [API Remotes build note](2026-08-08-api-remotes-generated-contract-build.md) defines the current command order in which the Host generates Remote contracts before the Client compiles. The tsc-first ownership decided here is unchanged. ## Problem @@ -30,18 +30,15 @@ Validation found several concrete technical issues and possible routes: In-package relative imports use explicit `.ts` specifiers. -`pnpm run build` is a two-stage build: +`pnpm run build` orders Host lib, Client lib, and Web; each lib phase keeps tsc emission before tsdown bundling: -- Stage 1: `tsc -b` over the root solution emits per-module `.js`, declarations `.d.ts`, JS sourcemaps `.js.map`, and declaration sourcemaps `.d.ts.map` into each package's `lib/types`. This is the authoritative TypeScript compilation result. Publication keeps `.d.ts`; packages whose runtime exports explicitly point into the emitted tree also keep its `.js` files. `.js.map` and `.d.ts.map` remain in the local build tree. - - The graph is the project-reference graph reachable from the root solution `tsconfig.json` through the two aggregates ([topology](2026-07-22-tsconfig-solution-root-two-aggregates.md)). It validates and emits package/vendor build results. -- Stage 2: a bundler reads the emitted JS under `lib/types` and writes the bundled runtime entry as `lib/index.js` or `lib/index.mjs` (follow current behavior). This stage is bundling only. It must not read TypeScript source or emit declarations. +- Host tsc runs `tsc -b` against `tsconfig.host.json`, emitting per-module `.js`, `.d.ts`, `.js.map`, and `.d.ts.map` into `lib/types` for each package in the Host graph; Host tsdown then reads that JavaScript, produces published entries, and runs Host TypeRT. +- Client tsc runs `tsc -b` against `tsconfig.client.json` after Host TypeRT has generated the Remote Client declarations; Client tsdown then reads the JavaScript emitted by the Client graph and produces the Client packages' Node loader entries and browser bundles. +- The Web build starts only after both lib phases complete. `tsdown` is no longer the owner of TypeScript compilation or declaration output. -`pnpm run typecheck` runs the same `tsc -b` graph. -- The aggregates (`tsconfig.host.json`, `tsconfig.client.json`) typecheck examples, tests, and scripts with `noEmit`, and validate package/vendor source through references. -- Referenced package/vendor projects keep the same emit behavior as build, so typecheck refreshes their `lib/types` outputs instead of using a separate no-emit graph. Project-specific strictness changes live in the owning `packages/*/*/tsconfig.json` or `vendor/*/tsconfig.json`. -- The no-emit aggregates disable `rewriteRelativeImportExtensions`; they emit nothing and include tests that import helpers across project-reference boundaries. Package/vendor emit projects keep the rewrite enabled. +`pnpm run typecheck` first runs the Host lib phase to generate the Remote declarations required by Client typechecking, then runs `tsc -b` against `tsconfig.client.json`. The two aggregates themselves check their respective examples, tests, and scripts with `noEmit`; referenced package/vendor projects retain the same emit behavior as the build. Composite projects keep their incremental build information inside their project-local `lib/` output. `pnpm run clean` derives live output directories from the root TypeScript project-reference graph, removes legacy root build information, and removes deleted `packages/*/*` directories that contain only known generated residue. Before removing an existing target, it resolves the target's parent and refuses it if that resolved parent is outside the repository, so a symlinked project reference cannot redirect cleanup outside the checkout. It preserves `node_modules` for every package that still has a `package.json`, and refuses to remove a manifest-less directory containing unknown files. Build does not invoke clean automatically, so ordinary builds retain incremental state. @@ -49,14 +46,18 @@ The command orchestration shape is: ```sh pnpm run build: -tsc -b -tsdown +tsc -b tsconfig.host.json +tsdown --env.DSH_BUILD_FACE host +tsc -b tsconfig.client.json +tsdown --env.DSH_BUILD_FACE client +pnpm run build:web pnpm run verify-node-next-types: tsx scripts/verify-node-next-types.ts pnpm run typecheck: -tsc -b +pnpm run build:lib:host +tsc -b tsconfig.client.json pnpm run clean: tsx scripts/clean.ts @@ -75,8 +76,8 @@ The source-mode demos run through their declared TypeScript launchers and the ro Build responsibilities are clearer: -- Each module under `packages//` and `vendor/*` has one local tsconfig for build, typecheck, and tools that run source directly, such as the `dsh` source loader, `tsx`, and `vitest`. -- The `build` command drives the root solution graph. `tsc -b` owns the publishable per-module `.js` and `.d.ts` output, and the bundler owns only `lib/index.*`. +- Each ordinary module under `packages//` and `vendor/*` has one local tsconfig for build, typecheck, and tools that run source directly, such as the `dsh` source loader, `tsx`, and `vitest`. `api/remotes` is the sole exception: generated-contract ordering requires one solution and two mutually exclusive emitting projects. +- The `build` command runs the Host and Client Project Reference graphs in order. In each phase, `tsc -b` owns the publishable per-module `.js` and `.d.ts` output, while the bundler owns only the published runtime bundles. - `lib/types/*.d.ts` is the publish declaration output; `.d.ts.map` remains only as a local compilation artifact. - `lib/types/*.d.ts` uses explicit `.ts` relative specifiers, which TypeScript's NodeNext/Node16 resolver maps to sibling `.d.ts` files. - `lib/types/*.js` is normally only a bundler input. It is published only when an explicit runtime export points into the emitted tree. diff --git a/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md b/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md index 8115bb2557..4179112964 100644 --- a/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md +++ b/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-06-17-ts-build-config.md) | 中文 -> 根项目拓扑(即哪个 tsconfig 拥有哪张图)后来改为由一个 solution 根文件统辖两个聚合 program;见[solution 根文件 Agent Note](2026-07-22-tsconfig-solution-root-two-aggregates.md)。本文确定的 TSC 优先流水线保持不变。 +> 根项目拓扑由一个 solution 根文件统辖两个 aggregate program;见 [solution 根文件 Agent Note](2026-07-22-tsconfig-solution-root-two-aggregates.md)。Host 生成 Remote 契约后再编译 Client 的当前命令顺序见 [API Remotes 构建 Agent Note](2026-08-08-api-remotes-generated-contract-build.md)。本文确定的 tsc-first 职责保持不变。 ## 问题 @@ -30,18 +30,15 @@ Status: implemented 包内相对导入使用显式 `.ts` 说明符。 -`pnpm run build` 是两阶段构建: +`pnpm run build` 按 Host lib、Client lib 和 Web 排序;每个 lib 阶段都保持 tsc 先发射、tsdown 后打包: -- 阶段 1:在根 solution 上执行 `tsc -b`,将逐模块的 `.js`、声明文件 `.d.ts`、JS sourcemap `.js.map` 和声明 sourcemap `.d.ts.map` 输出到各包的 `lib/types`。这是权威的 TypeScript 编译结果。发布时保留 `.d.ts`;如果包的运行时 export 显式指向该输出树,也会保留其中的 `.js` 文件。`.js.map` 和 `.d.ts.map` 留在本地构建树中。 - - 该图是从根 solution `tsconfig.json` 经两个聚合可达的 project-reference 图([拓扑](2026-07-22-tsconfig-solution-root-two-aggregates.md)),用于校验并输出包/vendor 的构建结果。 -- 阶段 2:打包器读取 `lib/types` 下输出的 JS,将打包后的运行时入口写为 `lib/index.js` 或 `lib/index.mjs`(沿用当前行为)。此阶段仅做打包,禁止读取 TypeScript 源码或输出声明文件。 +- Host tsc 对 `tsconfig.host.json` 执行 `tsc -b`,把逐模块 `.js`、`.d.ts`、`.js.map` 与 `.d.ts.map` 输出到 Host 图各 package 的 `lib/types`;Host tsdown 随后读取这些 JS,生成发布入口并运行 Host TypeRT。 +- Client tsc 在 Host TypeRT 已生成 Remote Client 声明后对 `tsconfig.client.json` 执行 `tsc -b`;Client tsdown 再读取 Client 图发射的 JS,生成 Client package 的 Node loader 入口与 browser bundle。 +- Web build 只在两个 lib 阶段完成后启动。 `tsdown` 不再负责 TypeScript 编译或声明文件输出。 -`pnpm run typecheck` 运行同一张 `tsc -b` 图。 -- 两个聚合(`tsconfig.host.json`、`tsconfig.client.json`)以 `noEmit` 方式检查示例、测试和脚本,并通过 references 校验包/vendor 源码。 -- 被引用的包/vendor 项目保持与构建相同的输出行为,因此类型检查会刷新它们的 `lib/types` 输出,而无需使用独立的 no-emit 图。项目特定的严格度变更放在各自的 `packages/*/*/tsconfig.json` 或 `vendor/*/tsconfig.json` 中。 -- 两个 no-emit 聚合禁用 `rewriteRelativeImportExtensions`;它们不输出任何文件,且包含跨 project-reference 边界导入 helper 的测试。包/vendor 的 emit 项目保持重写开启。 +`pnpm run typecheck` 先执行 Host lib 阶段,以生成 Client 类型检查所需的 Remote 声明,再对 `tsconfig.client.json` 执行 `tsc -b`。两个 aggregate 本身以 `noEmit` 方式检查各自的示例、测试与脚本;被引用的 package/vendor project 保持与构建相同的发射行为。 复合项目将增量构建信息保存在各项目本地的 `lib/` 输出中。`pnpm run clean` 会根据根 TypeScript project-reference 图确定当前有效的输出目录,删除遗留的根目录构建信息,并删除已删除包留下且仅包含已知生成残留的 `packages/*/*` 目录。在删除现有目标前,该命令会解析目标父目录的真实路径;如果解析后的父目录位于仓库之外,则拒绝删除,防止使用符号链接的 project reference 将清理操作重定向到工作副本之外。对于仍有 `package.json` 的每个包,该命令都会保留 `node_modules`;如果不含 `package.json` 的目录中存在未知文件,则拒绝删除。构建不会自动调用 clean,因此常规构建会保留增量状态。 @@ -49,14 +46,18 @@ Status: implemented ```sh pnpm run build: -tsc -b -tsdown +tsc -b tsconfig.host.json +tsdown --env.DSH_BUILD_FACE host +tsc -b tsconfig.client.json +tsdown --env.DSH_BUILD_FACE client +pnpm run build:web pnpm run verify-node-next-types: tsx scripts/verify-node-next-types.ts pnpm run typecheck: -tsc -b +pnpm run build:lib:host +tsc -b tsconfig.client.json pnpm run clean: tsx scripts/clean.ts @@ -75,8 +76,8 @@ tsx scripts/clean.ts 构建职责更加清晰: -- `packages//` 和 `vendor/*` 下的每个模块有一份本地 tsconfig,同时服务于构建、类型检查和直接运行源码的工具(如 `dsh` 源码 loader、`tsx` 和 `vitest`)。 -- `build` 命令驱动根 solution 图。`tsc -b` 负责可发布的逐模块 `.js` 和 `.d.ts` 输出,打包器仅负责 `lib/index.*`。 +- `packages//` 和 `vendor/*` 下的每个普通模块有一份本地 tsconfig,同时服务于构建、类型检查和直接运行源码的工具(如 `dsh` 源码 loader、`tsx` 和 `vitest`)。`api/remotes` 因生成契约顺序使用一个 solution 和两个互斥的 emitting project,是唯一例外。 +- `build` 命令按 Host 与 Client Project Reference 图执行。每个阶段都由 `tsc -b` 负责可发布的逐模块 `.js` 和 `.d.ts` 输出,打包器仅负责发布 runtime bundle。 - `lib/types/*.d.ts` 是发布用的声明输出;`.d.ts.map` 只作为本地编译产物保留。 - `lib/types/*.d.ts` 使用显式 `.ts` 相对说明符,TypeScript 的 NodeNext/Node16 解析器会将其映射到同级的 `.d.ts` 文件。 - `lib/types/*.js` 通常仅作为打包器输入。只有显式运行时 export 指向该输出树时,才会发布这些文件。 diff --git a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.i18n.yaml b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.i18n.yaml index aa4c089a00..d96a3b4d6c 100644 --- a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-06-node-engine-floor.md -2026-07-06-node-engine-floor.md: ef047d885a442106a35922f4716d2996d8a98ca7 -2026-07-06-node-engine-floor.zh.md: c409b006baa3451eb5fc5260be4e14ffe88776c8 +2026-07-06-node-engine-floor.md: e42b809f83fe388526ecbd74192989e0bcdbba67 +2026-07-06-node-engine-floor.zh.md: 92fa00f7a270f4506cba6c899e9edf6a2fdd3209 diff --git a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.md b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.md index ef047d885a..e42b809f83 100644 --- a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.md +++ b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.md @@ -15,7 +15,7 @@ Set `engines.node` to `^22.19.0 || >=24.0.0` and test keyless CI on `['22.19', 2 Two Node features gate the source runtime: - **`node:sqlite`** — `packages/session-persistence/session-persistence-sqlite` does a top-level `import { DatabaseSync } from 'node:sqlite'`. The module dropped its `--experimental-sqlite` flag requirement at **22.13** (LTS) and **23.4** (Current); before those, importing it throws at load. -- **Native TypeScript type-stripping** — the built-mode `examples/headless-agent/tests/keyless-smoke.e2e.ts` smoke boots `dsh-cli-demo`'s published `lib/bin.js` under plain `node` (no tsx) and loads the example's `.ts` test adapter (`cli-mock-llm.ts`). Type-stripping is the default from **22.18** (LTS) and **23.6** (Current); before those it needs `--experimental-strip-types`. +- **Native TypeScript type-stripping** — the built-mode `examples/headless-agent/tests/keyless-smoke.e2e.ts` smoke boots its unexported `.ts` driver under plain `node` (no tsx) and loads the example's `.ts` test adapter (`cli-mock-llm.ts`). Type-stripping is the default from **22.18** (LTS) and **23.6** (Current); before those it needs `--experimental-strip-types`. Those source features clear on the 22.x line at **22.18**, but the installed Pi adapter dependency raises the advertised LTS floor. `@deepseek-ai/dsh-llm-pi-ai` depends on `@earendil-works/pi-ai@0.79.3`, whose package declares `engines.node >=22.19.0`, so the LTS floor is **22.19**. The 24.x branch remains `>=24.0.0`. The disjoint range excludes Node 23 entirely: Node 23.0–23.5 still has at least one flagged source feature, and the 23 line is non-LTS/EOL, so advertising `>=23.6` would add a dead release line and a CI leg no deployment should use. @@ -25,7 +25,7 @@ Those source features clear on the 22.x line at **22.18**, but the installed Pi - The advertised LTS branch no longer undercuts the Pi adapter dependency floor. - CI proves the Node 22 LTS floor directly with Node 22.19, keeps primary coverage on `node: 24`, and exercises Node 26 as the next even line; focused compatibility smokes run on all three versions. -- The built-bin smoke needs no version-conditional flag: at 22.19 type-stripping is already the default, so the test stays the plain `node lib/bin.js` path it documents. +- The built-mode smoke needs no version-conditional flag: at 22.19 type-stripping is already the default, so the example-owned TypeScript driver stays a plain `node fixture.ts` path. - A future dependency or source API that raises the runtime floor must move `engines.node`, the compatibility matrix, and this Agent Note in the same change. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.zh.md b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.zh.md index c409b006ba..92fa00f7a2 100644 --- a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.zh.md +++ b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.zh.md @@ -15,7 +15,7 @@ Status: implemented 两个 Node 特性决定了源码运行时的门槛: - **`node:sqlite`**:`packages/session-persistence/session-persistence-sqlite` 在顶层执行 `import { DatabaseSync } from 'node:sqlite'`。该模块在 **22.13**(LTS)和 **23.4**(Current)取消了 `--experimental-sqlite` 标志要求;在此之前,导入它会在加载时抛出异常。 -- **原生 TypeScript 类型剥离**——构建模式的 `examples/headless-agent/tests/keyless-smoke.e2e.ts` 冒烟测试使用纯 `node`(无 tsx)启动 `dsh-cli-demo` 已发布的 `lib/bin.js`,并加载示例的 `.ts` 测试适配器(`cli-mock-llm.ts`)。类型剥离从 **22.18**(LTS)和 **23.6**(Current)起成为默认行为;更早版本需要 `--experimental-strip-types`。 +- **原生 TypeScript 类型剥离**——构建模式的 `examples/headless-agent/tests/keyless-smoke.e2e.ts` 冒烟测试使用纯 `node`(无 tsx)启动该示例未导出的 `.ts` driver,并加载示例的 `.ts` 测试适配器(`cli-mock-llm.ts`)。类型剥离从 **22.18**(LTS)和 **23.6**(Current)起成为默认行为;更早版本需要 `--experimental-strip-types`。 这些源码特性在 22.x 线上于 **22.18** 全部就绪,但已安装的 Pi 适配器依赖将宣传的 LTS 下限进一步提高。`@deepseek-ai/dsh-llm-pi-ai` 依赖 `@earendil-works/pi-ai@0.79.3`,后者的包声明 `engines.node >=22.19.0`,因此 LTS 下限为 **22.19**。24.x 分支保持 `>=24.0.0`。该不相交范围完全排除了 Node 23:Node 23.0–23.5 至少还有一个源码特性需要标志,而 23 线是非 LTS/已 EOL 的,宣传 `>=23.6` 会增加一条已终止的发布线和一条 CI 分支,而没有任何部署应当使用它。 @@ -25,7 +25,7 @@ Status: implemented - 宣传的 LTS 分支不再低于 Pi 适配器依赖的下限。 - CI 通过 Node 22.19 直接验证 Node 22 LTS 下限,将主要覆盖率任务保留在 `node: 24`,并用 Node 26 验证下一个偶数线;三个版本均运行聚焦的兼容性冒烟测试。 -- built-bin 冒烟测试无需版本条件标志:在 22.19 上类型剥离已是默认行为,因此测试保持其文档所述的纯 `node lib/bin.js` 路径。 +- 构建模式冒烟测试无需版本条件标志:在 22.19 上类型剥离已是默认行为,因此示例自有的 TypeScript driver 保持使用纯 `node fixture.ts` 路径。 - 未来若依赖或源码 API 提高运行时下限,必须在同一变更中同步调整 `engines.node`、兼容性矩阵和本 Agent Note。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml index 7fa4d3fbba..07e0a89d0f 100644 --- a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-13-documentation-site-projection.md -2026-07-13-documentation-site-projection.md: f19d9b309aa22821a75086dc07ee302097631ba0 -2026-07-13-documentation-site-projection.zh.md: cc5e94e709f0639fd35ad81165b199cc5c9effc0 +2026-07-13-documentation-site-projection.md: d9af915754fa6a1df51a27d18d412597472aaa73 +2026-07-13-documentation-site-projection.zh.md: 5b6de4b3425b6d20335a0f8055468ced414d4034 diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md index f19d9b309a..d9af915754 100644 --- a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md +++ b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md @@ -18,7 +18,9 @@ Canonical Markdown remains in the repository tier that owns it. Product-facing g Locale home projections retain only the canonical YAML frontmatter. The repository-facing body can keep its H1 and bilingual source links, while the VitePress home theme owns the rendered hero and features and the site navigation owns locale switching. -The projector parses Markdown links without reserializing the document. A link to another published source becomes a site-relative route; a link to an unpublished repository file becomes a GitHub source link; a repository image is copied into the generated tree and referenced from there ([why](2026-08-06-doc-site-carries-its-images.md)). Missing relative targets fail projection. Unit tests pin these transformations, and `docs:check` runs the projector tests plus a production VitePress build as part of `doc-sync` and the parallel documentation gates. +The projector parses Markdown links without reserializing the document. A link to another published source becomes a site-relative route; a link to an unpublished repository file becomes a source link under the public `deepseek-ai/deepseek-harness-sdk` home; a repository image is copied into the generated tree and referenced from there ([why](2026-08-06-doc-site-carries-its-images.md)). Missing relative targets fail projection. Unit tests pin these transformations, and `docs:check` runs the projector tests plus a production VitePress build as part of `doc-sync` and the parallel documentation gates. + +`verify-public-repository-links` rejects internal repository remotes from tracked files. Public source links use the public home, while work tracking stays in repository metadata and source carries a TODO only when the local boundary matters to maintainers. `website/AGENTS.md` is the only maintained Markdown file in the website subtree. The projector test enumerates tracked and unignored files and rejects any other website Markdown, so site-specific locale, route, API, or generated source copies cannot bypass the publication manifest. diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md index cc5e94e709..5b6de4b342 100644 --- a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md +++ b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md @@ -18,7 +18,9 @@ Status: implemented 各 locale 的首页投影只保留权威 YAML frontmatter。面向仓库的正文可以保留其 H1 和双语源文件链接,而 VitePress 首页主题负责渲染 hero 与功能区,网站导航负责切换 locale。 -投影器解析 Markdown 链接,但不会重新序列化文档。指向另一个已发布源文件的链接会变成站内相对路由;指向未发布仓库文件的链接会变成 GitHub 源文件链接;仓库图片会被拷贝进生成树并从那里引用([原因](2026-08-06-doc-site-carries-its-images.md))。相对目标不存在时,投影会失败。单元测试会锁定这些转换行为,`docs:check` 则运行投影器测试和 VitePress 生产构建,并将二者纳入 `doc-sync` 和并行文档门禁。 +投影器解析 Markdown 链接,但不会重新序列化文档。指向另一个已发布源文件的链接会变成站内相对路由;指向未发布仓库文件的链接会变成公开 `deepseek-ai/deepseek-harness-sdk` 主页下的源文件链接;仓库图片会被拷贝进生成树并从那里引用([原因](2026-08-06-doc-site-carries-its-images.md))。相对目标不存在时,投影会失败。单元测试会锁定这些转换行为,`docs:check` 则运行投影器测试和 VitePress 生产构建,并将二者纳入 `doc-sync` 和并行文档门禁。 + +`verify-public-repository-links` 会拒绝已跟踪文件中的内部仓库远程链接。公开源文件链接使用公开主页,而工作跟踪留在仓库元数据中;只有本地边界对维护者有意义时,源文件才保留 TODO。 `website/AGENTS.md` 是网站子树中唯一维护的 Markdown 文件。投影器测试会枚举所有已跟踪文件和未被忽略的未跟踪文件,并拒绝网站中的任何其他 Markdown,因此网站专用的 locale、路由、API 或生成源文件副本无法绕过发布 manifest。 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index 8ccd5ca13e..1dc5d79676 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md -2026-07-22-evidence-based-larger-hosted-runners.md: d46b8291ec05e997728da76354354f9e36bd2fb4 -2026-07-22-evidence-based-larger-hosted-runners.zh.md: f43712859d8351ffff45c7b6d5eb2b65015ee4c3 +2026-07-22-evidence-based-larger-hosted-runners.md: 53cc86efce9061c8f9836a17cb35ebb128085b7a +2026-07-22-evidence-based-larger-hosted-runners.zh.md: 0484548c76cb7eed11dc4235ef024a700499ef6a diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index d46b8291ec..53cc86efce 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -26,7 +26,7 @@ The artifact boundary remains explicit. `scripts/publint-all.ts` calls publint's Windows shares one 32-core setup across the blocking build and production site plus observational built-artifact contracts. Linux owns the duplicate lint, coverage, and snapshot inventories because running those observational copies on Windows extends the paid critical path without adding a blocking platform claim. -An [exact-head all-size benchmark](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29908491351) ran the complete unsharded primary Node aggregate on every Linux pool before the eager-build correction: +An exact-head all-size benchmark ran the complete unsharded primary Node aggregate on every Linux pool before the eager-build correction: | Complete Linux primary | 4 cores | 8 cores | 16 cores | 32 cores | 64 cores | 96 cores | |---|---:|---:|---:|---:|---:|---:| @@ -40,13 +40,13 @@ The same benchmark measured the required Windows build surfaces across every pro |---|---:|---:|---:|---:|---:|---:| | Active time | 152 s | 104 s | 104 s | 92 s | 103 s | 110 s | -Repository work gains little above 16 Windows cores, but the 32-core pool can start the complete outer inventory together. A [retargeted production validation](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29907581119/attempts/2) completed the full one-box Windows inventory in 173 seconds, including coverage and snapshot replay, so Windows remains consolidated. +Repository work gains little above 16 Windows cores, but the 32-core pool can start the complete outer inventory together. A retargeted production validation completed the full one-box Windows inventory in 173 seconds, including coverage and snapshot replay, so Windows remains consolidated. -The larger client package graph makes cache mechanics and scheduler pressure part of the measured workload. In [one exact-head candidate run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29912577681), Linux spent 39 seconds in repository gates but 69 seconds in the complete job, while Windows spent 117 seconds in repository gates and 228 seconds in the complete job. The Windows pnpm cache downloaded its 154 MB archive in about two seconds but spent 27 seconds extracting it, followed by a 23-second install and a 14-second post-job save. A [cacheless all-size trace](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29913033155) completed the same 32-core Windows install in 27 seconds. A future larger-runner rollout therefore needs complete-job measurements rather than gate-only timing. +The larger client package graph makes cache mechanics and scheduler pressure part of the measured workload. In one exact-head candidate run, Linux spent 39 seconds in repository gates but 69 seconds in the complete job, while Windows spent 117 seconds in repository gates and 228 seconds in the complete job. The Windows pnpm cache downloaded its 154 MB archive in about two seconds but spent 27 seconds extracting it, followed by a 23-second install and a 14-second post-job save. A cacheless all-size trace completed the same 32-core Windows install in 27 seconds. A future larger-runner rollout therefore needs complete-job measurements rather than gate-only timing. Host setup remains part of any comparison. A standard Node 26 job once spent 36 of its 67 seconds in `Set up job`, while `actions/setup-node` spent 46.56 seconds printing cached Windows environment details after finding Node in the hosted toolcache. A Linux candidate also spent 18 seconds registering a 50 KB Bubblewrap package because the hosted image scanned 202,507 package-database files. [`scripts/prepare-ci-bubblewrap.sh`](../../../../scripts/prepare-ci-bubblewrap.sh) instead verifies and extracts the pinned payload into the ephemeral runner directory, runs a functional confinement probe, and overlaps that preparation with dependency installation. -Inner and outer worker limits are separate controls. An [exact-head 32-worker ESLint experiment](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29918329463) slowed lint to 52.28 seconds and coverage to 42.71 seconds, where an adapter idle-timeout test failed. A later 8-gate trace reduced coverage to 35.17 seconds but delayed the production-site build until the aggregate reached 41.06 seconds. Core count therefore does not justify copying an equally large worker limit. +Inner and outer worker limits are separate controls. An exact-head 32-worker ESLint experiment slowed lint to 52.28 seconds and coverage to 42.71 seconds, where an adapter idle-timeout test failed. A later 8-gate trace reduced coverage to 35.17 seconds but delayed the production-site build until the aggregate reached 41.06 seconds. Core count therefore does not justify copying an equally large worker limit. The process-bound coverage project contains exactly five suite files. Thirty-two forks crashed Node 24's CJS lexer twice, and a later 16-fork run reproduced the worker loss and invalid coverage result. The single Vitest invocation therefore uses threads for the broad inventory and reserves forks for suites that exercise process-global state, `process` APIs, or timing-sensitive process I/O. That narrow fork inventory includes the local bash process-plumbing suite and the pi-ai adapter suite because aggregate contention changed timing observations in both. These failures make deterministic coverage, not advertised cores, the upper bound on worker selection. diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index f43712859d..0484548c76 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -26,7 +26,7 @@ Linux 主流程使用 3 个相互独立的 32 核作业。覆盖率单独运行 Windows 以一次 32 核环境设置同时承载阻塞性构建、生产网站和观测性的构建产物契约。重复的 lint、覆盖率和快照清单由 Linux 承担,因为在 Windows 上运行这些观测性副本会延长付费关键路径,却不会新增任何阻塞性平台契约。 -一次[分支头精确的全规格基准测试](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29908491351)在修正构建尽早启动逻辑前,对每种 Linux 池都运行了完整且未分片的主 Node 聚合流程: +一次分支头精确的全规格基准测试在修正构建尽早启动逻辑前,对每种 Linux 池都运行了完整且未分片的主 Node 聚合流程: | Linux 完整主流程 | 4 核 | 8 核 | 16 核 | 32 核 | 64 核 | 96 核 | |---|---:|---:|---:|---:|---:|---:| @@ -40,13 +40,13 @@ Windows 以一次 32 核环境设置同时承载阻塞性构建、生产网站 |---|---:|---:|---:|---:|---:|---:| | 活动耗时 | 152 秒 | 104 秒 | 104 秒 | 92 秒 | 103 秒 | 110 秒 | -Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完整的外层清单同时启动。一次[重新定向的生产验证](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29907581119/attempts/2)在 173 秒内完成了单机 Windows 完整清单,其中包括覆盖率和快照回放,因此 Windows 继续采用合并执行方式。 +Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完整的外层清单同时启动。一次重新定向的生产验证在 173 秒内完成了单机 Windows 完整清单,其中包括覆盖率和快照回放,因此 Windows 继续采用合并执行方式。 -客户端包依赖图增大后,缓存机制和调度器压力也成为实测工作负载的一部分。在[一次分支头精确的候选运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29912577681)中,Linux 的仓库门禁耗时 39 秒,完整作业耗时 69 秒;Windows 的仓库门禁耗时 117 秒,完整作业耗时 228 秒。Windows pnpm 缓存的 154 MB 归档下载耗时约 2 秒,但解压耗时 27 秒,随后安装耗时 23 秒,作业结束后的保存又耗时 14 秒。一次[无缓存的全规格运行轨迹](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29913033155)在 27 秒内完成了同一台 32 核 Windows 运行器上的安装。因此,未来若要启用大型运行器,需要测量完整作业,而不能只测门禁耗时。 +客户端包依赖图增大后,缓存机制和调度器压力也成为实测工作负载的一部分。在一次分支头精确的候选运行中,Linux 的仓库门禁耗时 39 秒,完整作业耗时 69 秒;Windows 的仓库门禁耗时 117 秒,完整作业耗时 228 秒。Windows pnpm 缓存的 154 MB 归档下载耗时约 2 秒,但解压耗时 27 秒,随后安装耗时 23 秒,作业结束后的保存又耗时 14 秒。一次无缓存的全规格运行轨迹在 27 秒内完成了同一台 32 核 Windows 运行器上的安装。因此,未来若要启用大型运行器,需要测量完整作业,而不能只测门禁耗时。 任何比较都必须计入主机设置。一个标准 Node 26 作业曾在总共 67 秒的耗时中,把 36 秒用在 `Set up job` 上;`actions/setup-node` 从托管 toolcache 找到 Node 后,仍花费 46.56 秒输出缓存的 Windows 环境详情。一个 Linux 候选作业还在注册 50 KB 的 Bubblewrap 包时耗时 18 秒,因为托管映像扫描了 202,507 个包数据库文件。[`scripts/prepare-ci-bubblewrap.sh`](../../../../scripts/prepare-ci-bubblewrap.sh) 改为验证固定版本的 payload 并将其解压到临时运行器目录,执行功能性隔离探针,并让这项准备工作与依赖安装重叠执行。 -内层与外层工作线程上限是相互独立的控制机制。一次[分支头精确、使用 32 个工作线程的 ESLint 实验](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29918329463)使 lint 耗时增至 52.28 秒、覆盖率耗时增至 42.71 秒;同一次运行中,一项适配器空闲超时测试失败。后来一次同时运行 8 项门禁的运行轨迹将覆盖率耗时降至 35.17 秒,但生产网站构建被延后,直到聚合流程耗时达到 41.06 秒时才完成。因此,不能仅凭核心数照搬同等规模的工作线程上限。 +内层与外层工作线程上限是相互独立的控制机制。一次分支头精确、使用 32 个工作线程的 ESLint 实验使 lint 耗时增至 52.28 秒、覆盖率耗时增至 42.71 秒;同一次运行中,一项适配器空闲超时测试失败。后来一次同时运行 8 项门禁的运行轨迹将覆盖率耗时降至 35.17 秒,但生产网站构建被延后,直到聚合流程耗时达到 41.06 秒时才完成。因此,不能仅凭核心数照搬同等规模的工作线程上限。 进程约束的覆盖率项目恰好包含 5 个套件文件。32 个 fork 曾两次导致 Node 24 的 CJS 词法分析器崩溃,后来一次使用 16 个 fork 的运行又复现了工作进程丢失和无效的覆盖率结果。因此,单次 Vitest 调用会对大范围测试清单使用线程,只为涉及进程全局状态、`process` API 或对时间敏感的进程 I/O 的套件保留 fork。这份有限的 fork 清单包括本地 bash 进程通路套件和 pi-ai 适配器套件,因为聚合争用改变了二者的时序观测结果。这些故障表明,选择工作线程数量时,上限取决于能否得到确定的覆盖率结果,而非标称核心数。 diff --git a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.i18n.yaml index d712a411b9..e7f76356e2 100644 --- a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.md -2026-07-22-fast-local-git-hooks.md: 838024c4293372b1430d357774feb06cd9742b9b -2026-07-22-fast-local-git-hooks.zh.md: 26968dd19ffb42f4a618dc760b2a6edeb4900393 +2026-07-22-fast-local-git-hooks.md: af8a9380fc3cbf4f672ca0b32faa7b187522cf11 +2026-07-22-fast-local-git-hooks.zh.md: 04f3a407e421c4d585b8b78b16b9055fd19ac756 diff --git a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.md b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.md index 838024c429..af8a9380fc 100644 --- a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.md +++ b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.md @@ -12,9 +12,9 @@ Fast hooks still need to reject cheap, high-confidence defects before work leave ## Decision -[lefthook.yml](../../../../lefthook.yml) keeps both hooks as bounded local checkpoints. Pre-commit runs sequentially: a formatting-only ESLint config fixes and re-stages changed JavaScript and TypeScript, [Oxlint](2026-07-29-oxlint-linter.md) validates those files and applies native safe fixes, `git diff --cached --check` rejects staged whitespace errors, and the vendor manifest guard checks vendored-source metadata. Pre-push invokes the repository TypeScript binary directly in incremental build mode. +[lefthook.yml](../../../../lefthook.yml) keeps both hooks as bounded local checkpoints. Pre-commit runs sequentially: a formatting-only ESLint config fixes and re-stages changed JavaScript and TypeScript, a project-free [Oxlint](2026-07-29-oxlint-linter.md) profile validates those files and applies native safe fixes, `git diff --cached --check` rejects staged whitespace errors, and the vendor manifest guard checks vendored-source metadata. Pre-push runs `pnpm run typecheck`, which prepares the generated Host TypeRT contracts before the Client incremental typecheck. -Neither hook runs tests, snapshots, documentation checks, builds, hygiene, or the gate scheduler. The opt-in `check:all` package script selects the `check-all` scheduler inventory in [scripts/run-gates.ts](../../../../scripts/run-gates.ts) independently of the hooks; it is a contributor command, not an agent instruction. +Pre-commit does not run type analysis, tests, snapshots, documentation checks, builds, hygiene, or the gate scheduler. Pre-push adds only the Host contract build required by repository typecheck. The opt-in `check:all` package script selects the `check-all` scheduler inventory in [scripts/run-gates.ts](../../../../scripts/run-gates.ts) independently of the hooks; it is a contributor command, not an agent instruction. Agents inspect the outgoing diff and run the narrowest tests and checks that cover its behavior once. CI owns exhaustive coverage, built-artifact checks, and the platform matrix. A complete local rehearsal is reserved for an explicit request, CI diagnosis, or a repository-wide change that cannot be validated credibly by narrower evidence. @@ -31,6 +31,6 @@ This decision supersedes the local-hook portion of [Parallel pre-push gates](202 ## Consequences -Normal commits take the staged formatter-and-lint critical path, and warm pushes take the incremental typecheck critical path. Contributors retain a one-command opt-in rehearsal without widening the hook critical paths or the agent-required validation set. Hook latency is observed in development and PR evidence rather than enforced by a timing test whose result would depend on host load and cache state. +Normal commits take the project-free staged formatter-and-lint critical path, and warm pushes take the prepared incremental typecheck critical path. Contributors retain a one-command opt-in rehearsal without widening the hook critical paths or the agent-required validation set. Hook latency is observed in development and PR evidence rather than enforced by a timing test whose result would depend on host load and cache state. Local publication no longer proves the exhaustive repository matrix. Agents must select relevant behavioral evidence, reviewers must evaluate whether that selection matches the diff, and CI supplies the comprehensive signal once per pushed revision. diff --git a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.zh.md b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.zh.md index 26968dd19f..04f3a407e4 100644 --- a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.zh.md @@ -12,9 +12,9 @@ agent(智能体)已经会运行能够覆盖自身改动的测试和检查, ## 决策 -[lefthook.yml](../../../../lefthook.yml) 将两个钩子都保留为有界的本地检查点。Pre-commit 按顺序运行:仅用于格式化的 ESLint 配置修复改动过的 JavaScript 和 TypeScript 文件并重新暂存,[Oxlint](2026-07-29-oxlint-linter.md) 验证这些文件并应用原生安全修复,`git diff --cached --check` 拒绝暂存 diff 中的空白错误,vendor manifest(元数据清单)守卫检查 vendor 源码元数据。Pre-push 直接调用仓库内的 TypeScript 二进制,并启用增量构建模式。 +[lefthook.yml](../../../../lefthook.yml) 将两个钩子都保留为有界的本地检查点。Pre-commit 按顺序运行:仅用于格式化的 ESLint 配置修复改动过的 JavaScript 和 TypeScript 文件并重新暂存,不加载项目的 [Oxlint](2026-07-29-oxlint-linter.md) 配置验证这些文件并应用原生安全修复,`git diff --cached --check` 拒绝暂存 diff 中的空白错误,vendor manifest(元数据清单)守卫检查 vendor 源码元数据。Pre-push 运行 `pnpm run typecheck`;该命令会先准备好生成的 Host TypeRT 契约,再运行 Client 增量类型检查。 -两个钩子都不运行测试、快照、文档检查、构建、`hygiene` 或门禁调度器。可选运行的 `check:all` 包脚本独立于这些钩子,从 [scripts/run-gates.ts](../../../../scripts/run-gates.ts) 中选择 `check-all` 调度器清单;它是贡献者命令,而非对 agent 的指令。 +Pre-commit 不运行类型分析、测试、快照、文档检查、构建、`hygiene` 或门禁调度器。Pre-push 只增加仓库类型检查所需的 Host 契约构建。可选运行的 `check:all` 包脚本独立于这些钩子,从 [scripts/run-gates.ts](../../../../scripts/run-gates.ts) 中选择 `check-all` 调度器清单;它是贡献者命令,而非对 agent 的指令。 agent 检查待推送的 diff,并仅运行一次能够覆盖其行为的最小范围测试和检查。CI 负责全量覆盖率门禁、构建产物检查与平台矩阵。只有在明确要求、诊断 CI,或涉及全仓库的改动无法由范围更窄的证据得到可信验证时,才完整运行一遍本地检查矩阵。 @@ -31,6 +31,6 @@ agent 检查待推送的 diff,并仅运行一次能够覆盖其行为的最小 ## 结果 -普通提交的关键路径是暂存文件格式化与 lint,缓存已预热时推送的关键路径是增量类型检查。贡献者仍可选择用一条命令完整演练,且不会扩展钩子关键路径或 agent 必须运行的验证集合。钩子耗时只作为开发观察数据和 PR(Pull Request)证据记录,不设置会受主机负载与缓存状态影响的计时测试。 +普通提交的关键路径是不加载项目的暂存文件格式化与 lint,缓存已预热时推送的关键路径是经过准备的增量类型检查。贡献者仍可选择用一条命令完整演练,且不会扩展钩子关键路径或 agent 必须运行的验证集合。钩子耗时只作为开发观察数据和 PR(Pull Request)证据记录,不设置会受主机负载与缓存状态影响的计时测试。 从本地推送成功不再能证明仓库完整矩阵已通过。agent 必须选择相关的行为证据,评审人必须判断该选择是否与 diff 相符,CI 则对每个推送版本提供一次全面信号。 diff --git a/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.i18n.yaml index d191386725..2a6cdcc2d0 100644 --- a/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-22-tsconfig-solution-root-two-aggregates.md: 19c229693b98ff3825caf935fa647ab85aff0f56 -2026-07-22-tsconfig-solution-root-two-aggregates.zh.md: becc43de1ef2f6a53b0f6c2285eb64d9b42604f1 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md +2026-07-22-tsconfig-solution-root-two-aggregates.md: 6e8d192b5fea1c7045ade6935fd0e2dc8434c502 +2026-07-22-tsconfig-solution-root-two-aggregates.zh.md: 60581e3de55227a393e2528ce7d3c8fa1bf9c570 diff --git a/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md b/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md index 19c229693b..6e8d192b5f 100644 --- a/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md +++ b/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md @@ -27,7 +27,7 @@ One solution root, two check units, one shared base pair, no separate build or v The load-bearing principle: **cordis `Context` declaration-merge collisions exist only inside a `ts.Program`, never in module resolution.** A solution file forms no program, so referencing both aggregates from one root cannot collide the merges; vite-tsconfig-paths reads only `paths` and `include` and discards types, so one facade may span both sides. The only way to explode is to flatten both sides into a single program — hence two derived disciplines: `tsconfig.base.json` never gains `include`/`files` (it would leak into every extending package and narrow the facade), and every repo-wide `ts.Program` consumer (`scripts/ts-project.ts`, doc-typecheck standalone mode) seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly, never the root solution. Program-backed generators and semantic gates intentionally stay host-only; the client side gets program-backed gates only when a real need arrives. -Commands collapse to one graph and keep the config name explicit: `typecheck` = `tsc -b tsconfig.json`, `build` = `tsc -b tsconfig.json && tsdown`, lefthook pre-push stays `tsc -b tsconfig.json --pretty false` unchanged (the same line now covers both sides through the solution). `tsconfig.build.json` and `tsconfig.vitest.json` are deleted; all vitest configs point vite-tsconfig-paths at `tsconfig.base.json`. +The root `tsconfig.json` remains the solution entry for explicitly running the complete Project Reference graph, and lefthook pre-push incrementally covers both sides through `tsc -b tsconfig.json --pretty false`. Because the Client depends on Remote contracts generated by Host tsdown, the repository's `build` and `typecheck` commands run the Host and Client in order; the [API Remotes build note](2026-08-08-api-remotes-generated-contract-build.md) owns the exact orchestration. `tsconfig.build.json` and `tsconfig.vitest.json` are deleted; all vitest configs point vite-tsconfig-paths at `tsconfig.base.json`. The solution root `extends` the base deliberately: `examples/` and `scripts/` have no nearer tsconfig, so tsx (get-tsconfig) resolves their workspace imports through the root file. `extends` restores the `paths` map there while `files: []` keeps the file program-less. Their *type checking* is unaffected by this: examples, scripts, and website files are included by the host aggregate. @@ -41,5 +41,5 @@ The solution root `extends` the base deliberately: `examples/` and `scripts/` ha - `docs/development.md#typescript-project-layout` is the authoritative description; root `AGENTS.md` carries the two disciplines as conventions. - The [ts-build-config note](2026-06-17-ts-build-config.md) keeps ownership of the tsc-first build pipeline (tsc emits, tsdown bundles, `.ts` specifiers with `rewriteRelativeImportExtensions`); its former "one root typecheck project" shape is superseded by this note. -- Adding a package registers it in exactly one aggregate's references (host packages in `tsconfig.host.json`, client packages in `tsconfig.client.json`); the build graph needs no separate registration. -- The build gate depends on the typecheck gate: both now drive the same `tsc -b` graph, so running them concurrently would race the same `.tsbuildinfo` files. +- Adding an ordinary package registers it in exactly one aggregate's references: Host packages in `tsconfig.host.json` and Client packages in `tsconfig.client.json`. `api/remotes` is the only explicit split exception because the Host generates a contract that the Client consumes later; its two concrete projects are registered separately, while its package-root solution enters neither aggregate. +- The Host and Client build phases must run serially: Client tsc cannot begin until Host tsdown has generated the contract. Each phase reuses its projects' incremental state instead of processing the same graph concurrently. diff --git a/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.zh.md b/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.zh.md index becc43de1e..60581e3de5 100644 --- a/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.zh.md @@ -27,7 +27,7 @@ GUI 拆分引入了第二个聚合 program(`tsconfig.client.json`,见[分层 整个方案立足的原则:**cordis `Context` 的声明合并冲突只存在于同一个 `ts.Program` 内部,从不发生在模块解析中。** solution 文件不构成 program,因此从一个根文件同时引用两个聚合不会让两侧的声明合并相撞;vite-tsconfig-paths 只读取 `paths` 与 `include`、丢弃全部类型信息,因此一个门面可以横跨两侧。唯一会爆炸的做法是把两侧压平进同一个 program,由此推出两条派生纪律:`tsconfig.base.json` 永远不得添加 `include`/`files`(否则会泄漏进每个继承它的包,并收窄门面范围);每个全仓级 `ts.Program` 消费方(`scripts/ts-project.ts`、doc-typecheck 独立模式)都显式以 `tsconfig.host.json` 或 `tsconfig.client.json` 为种子,绝不使用根 solution。基于 program 的生成器与语义门禁有意只留在宿主侧;客户端侧只有在真实需求出现时才引入基于 program 的门禁。 -各命令收敛到一张图,且显式写出配置名:`typecheck` = `tsc -b tsconfig.json`,`build` = `tsc -b tsconfig.json && tsdown`,lefthook pre-push 保持 `tsc -b tsconfig.json --pretty false` 不变(经由 solution,这同一行命令现已覆盖两侧)。`tsconfig.build.json` 与 `tsconfig.vitest.json` 删除;所有 vitest 配置都把 vite-tsconfig-paths 指向 `tsconfig.base.json`。 +根 `tsconfig.json` 仍是显式执行完整 Project Reference 图的 solution 入口,lefthook pre-push 通过 `tsc -b tsconfig.json --pretty false` 增量覆盖两侧。仓库的 `build` 与 `typecheck` 命令因 Client 依赖 Host tsdown 生成的 Remote 契约而按 Host、Client 顺序运行,具体编排由 [API Remotes 构建 Note](2026-08-08-api-remotes-generated-contract-build.md)负责。`tsconfig.build.json` 与 `tsconfig.vitest.json` 已删除;所有 vitest 配置都把 vite-tsconfig-paths 指向 `tsconfig.base.json`。 solution 根文件刻意 `extends` base:`examples/` 与 `scripts/` 没有更近的 tsconfig,tsx(get-tsconfig)通过根文件解析它们的 workspace 导入。`extends` 把 `paths` 映射带回根文件,`files: []` 则让它始终不构成 program。这不影响两者的*类型检查*:examples、scripts 与 website 的文件由宿主聚合纳入。 @@ -41,5 +41,5 @@ solution 根文件刻意 `extends` base:`examples/` 与 `scripts/` 没有更 - `docs/development.md#typescript-project-layout` 是权威描述;根 `AGENTS.md` 以约定形式收录上述两条纪律。 - [ts-build-config Agent Note](2026-06-17-ts-build-config.md) 继续拥有 tsc 先行的构建流水线(tsc 负责输出,tsdown 负责打包,`.ts` 说明符配合 `rewriteRelativeImportExtensions`);其原先「单一根类型检查项目」的形态由本文取代。 -- 新增一个包只登记进恰好一个聚合的 references(宿主包进 `tsconfig.host.json`,客户端包进 `tsconfig.client.json`);构建图无需另行登记。 -- 构建门禁依赖类型检查门禁:两者现在驱动同一张 `tsc -b` 图,并发运行会在同一批 `.tsbuildinfo` 文件上竞态。 +- 新增一个普通 package 只登记进恰好一个 aggregate 的 references(Host package 进 `tsconfig.host.json`,Client package 进 `tsconfig.client.json`)。`api/remotes` 因 Host 生成契约与 Client 消费契约的顺序关系成为唯一显式拆分例外;其两个具体 project 分别登记,包根 solution 不进入任一 aggregate。 +- Host 与 Client 构建阶段必须串行:Host tsdown 生成契约后 Client tsc 才能开始。各阶段复用各 project 的增量状态,不通过并发重复处理同一张图。 diff --git a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.i18n.yaml index be725fc905..ac859f10dd 100644 --- a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md -2026-07-26-briefed-minimal-translation-updates.md: a47251376771165d0eb229aaa0fb7f63589d7d77 -2026-07-26-briefed-minimal-translation-updates.zh.md: c3c1b4e845b5a45acde55884b883b1dd1e570d77 +2026-07-26-briefed-minimal-translation-updates.md: afd990b7b63adfd0e66a4726975b678d044e7cad +2026-07-26-briefed-minimal-translation-updates.zh.md: dcdb253746041a7928ab7a544ae427293bc3f2de diff --git a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md index a472513767..afd990b7b6 100644 --- a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md +++ b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md @@ -12,7 +12,7 @@ The [bilingual pairing contract](2026-07-02-bilingual-docs-and-pairing-gate.md) Pair updates run on a generated briefing instead of the guidance corpus; only new pairs still run the whole-document workflow, which is unchanged. -- **`pnpm run gen-translation-brief [--apply] [pair...]`** ([scripts/gen-translation-brief.ts](../../../../scripts/gen-translation-brief.ts), assembly in [scripts/translation-brief.ts](../../../../scripts/translation-brief.ts)) prints, per out-of-sync pair, the authored side's diff from its recorded last-confirmed blob to the working tree plus the change mapped at the narrowest safely aligned granularity, deterministically widening on mapping failure: a change confined to the pair's byte-identical code fences is computed outright (`--apply` splices it into the counterpart and validates the result against the pairing gate's structural signature before writing); otherwise changed Markdown units (headings, paragraphs, table rows, list items, code fences, block quotes, HTML blocks, thematic breaks, link definitions — matched by container-scoped kind sequences) each carry their last-confirmed source, current source, and current counterpart text with line numbers; units that do not align fall back to depth-matched heading sections; and when sections do not align either, or both sides drifted, the briefing says so and withholds the mapping instead of guessing. Terminology rows are matched against the changed spans only (word-boundary English matching with plural inflections), and for Chinese targets the briefing tracks each relevant term's document-wide first occurrence — when an edit moves it, the vacated and receiving spans join the briefing with an explanatory note, since the 首次出现 annotation must move with it. The unit mapping, code splice, and first-occurrence mechanics adopt the planner design from the [incremental prompt-pipeline work](https://github.com/deepseek-harness/deepseek-harness/pull/684), whose provider-backed bake-off independently validated the same scope ladder for the automated pipeline. The briefing is the translator's whole working set; the full sources of truth remain the escalation path for decisions the briefing cannot answer. +- **`pnpm run gen-translation-brief [--apply] [pair...]`** ([scripts/gen-translation-brief.ts](../../../../scripts/gen-translation-brief.ts), assembly in [scripts/translation-brief.ts](../../../../scripts/translation-brief.ts)) prints, per out-of-sync pair, the authored side's diff from its recorded last-confirmed blob to the working tree plus the change mapped at the narrowest safely aligned granularity, deterministically widening on mapping failure: a change confined to the pair's byte-identical code fences is computed outright (`--apply` splices it into the counterpart and validates the result against the pairing gate's structural signature before writing); otherwise changed Markdown units (headings, paragraphs, table rows, list items, code fences, block quotes, HTML blocks, thematic breaks, link definitions — matched by container-scoped kind sequences) each carry their last-confirmed source, current source, and current counterpart text with line numbers; units that do not align fall back to depth-matched heading sections; and when sections do not align either, or both sides drifted, the briefing says so and withholds the mapping instead of guessing. Terminology rows are matched against the changed spans only (word-boundary English matching with plural inflections), and for Chinese targets the briefing tracks each relevant term's document-wide first occurrence — when an edit moves it, the vacated and receiving spans join the briefing with an explanatory note, since the 首次出现 annotation must move with it. The unit mapping, code splice, and first-occurrence mechanics adopt the planner design from the incremental prompt-pipeline work; its provider-backed bake-off independently validated the same scope ladder for the automated pipeline. The briefing is the translator's whole working set; the full sources of truth remain the escalation path for decisions the briefing cannot answer. - **The update path in [dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md)** consumes the briefing: mechanical (code-fence-only) changes are applied with `--apply`, no subagent; prose diffs go to a subagent whose prompt is the briefing, not the corpus; verification is clause-by-clause on the changed spans, not the whole document. - **The pairing gate takes pair arguments.** `verify-translation-pairing [pair...]` checks just the named pairs (any of a pair's three files, or the bare stem, names it); the corpus-wide sweep remains the no-argument form that `doc-sync` and CI run. `--write` now requires naming the confirmed pairs — bare `--write` refuses, and re-recording everything is an explicit `--write --all` — because the old bare form silently blessed every drifted pair in the tree, including ones the caller never looked at, and a prose-only drift would then stay green forever. Each record's comment names its own scoped command. Before recording, `--write` stores each side's exact bytes with `git hash-object -w --stdin` and pins the blob under a content-addressed local `refs/dsh/translation-pairing/snapshots/` ref; an uncommitted last-confirmed snapshot is therefore available to the briefing generator's later `git cat-file`, not merely named by a hash that Git cannot resolve or left vulnerable to garbage collection. diff --git a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.zh.md b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.zh.md index c3c1b4e845..dcdb253746 100644 --- a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.zh.md @@ -12,7 +12,7 @@ Status: implemented 配对更新基于生成的简报(briefing)运行,而非基于指导语料;只有新建配对仍走整篇文档工作流,后者保持不变。 -- **`pnpm run gen-translation-brief [--apply] [pair...]`**([scripts/gen-translation-brief.ts](../../../../scripts/gen-translation-brief.ts),组装逻辑在 [scripts/translation-brief.ts](../../../../scripts/translation-brief.ts))针对每个失去同步的配对,打印被改一侧从其记录在案的上次确认 blob 到当前工作区的 diff,并附上以能安全对齐的最窄粒度映射的这次改动,映射失败时粒度确定性地逐级放宽:仅落在配对中逐字节一致的围栏代码块内的改动会直接算出(`--apply` 会把它拼接进对侧文件,并在写入前用配对门禁的结构签名校验所得结果);否则,每个有改动的 Markdown 单元(标题、段落、表格行、列表项、围栏代码块、块引用、HTML 块、分隔线、链接定义;匹配依据是以容器为作用域的种类序列)都带上各自的上次确认源文、当前源文与当前对侧文本及行号;无法对齐的单元回退到按深度匹配的标题章节;当章节也无法对齐或两侧同时漂移时,简报会明说这一点并省略映射,而不是靠猜。术语表行只与改动块匹配(英文术语按词边界匹配,含复数变形);当目标侧是中文时,简报还会跟踪每个相关术语在整篇文档中的首次出现:一旦某次编辑使其移位,腾出的与接收的两处区间就会附一条解释性说明加入简报,因为「首次出现」括注必须随之移动。单元映射、代码拼接与首次出现机制采纳了[增量提示词流水线工作](https://github.com/deepseek-harness/deepseek-harness/pull/684)中的规划器设计;该项工作中接入提供方的对比评测,已为自动流水线独立验证了同一套范围阶梯。简报就是译者的全部工作集;简报回答不了的决策,仍以完整的真源文档作为升级求证路径。 +- **`pnpm run gen-translation-brief [--apply] [pair...]`**([scripts/gen-translation-brief.ts](../../../../scripts/gen-translation-brief.ts),组装逻辑在 [scripts/translation-brief.ts](../../../../scripts/translation-brief.ts))针对每个失去同步的配对,打印被改一侧从其记录在案的上次确认 blob 到当前工作区的 diff,并附上以能安全对齐的最窄粒度映射的这次改动,映射失败时粒度确定性地逐级放宽:仅落在配对中逐字节一致的围栏代码块内的改动会直接算出(`--apply` 会把它拼接进对侧文件,并在写入前用配对门禁的结构签名校验所得结果);否则,每个有改动的 Markdown 单元(标题、段落、表格行、列表项、围栏代码块、块引用、HTML 块、分隔线、链接定义;匹配依据是以容器为作用域的种类序列)都带上各自的上次确认源文、当前源文与当前对侧文本及行号;无法对齐的单元回退到按深度匹配的标题章节;当章节也无法对齐或两侧同时漂移时,简报会明说这一点并省略映射,而不是靠猜。术语表行只与改动块匹配(英文术语按词边界匹配,含复数变形);当目标侧是中文时,简报还会跟踪每个相关术语在整篇文档中的首次出现:一旦某次编辑使其移位,腾出的与接收的两处区间就会附一条解释性说明加入简报,因为「首次出现」括注必须随之移动。单元映射、代码拼接与首次出现机制采纳了增量提示词流水线工作的规划器设计;该项工作中接入提供方的对比评测,已为自动流水线独立验证了同一套范围阶梯。简报就是译者的全部工作集;简报回答不了的决策,仍以完整的真源文档作为升级求证路径。 - **[dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) 中的更新路径**消费这份简报:机械类改动(只涉及围栏代码块)用 `--apply` 应用,不动用 subagent;行文类 diff 交给 subagent,其提示词就是简报本身,而非指导语料;核验只对改动块逐句进行,不覆盖整篇文档。 - **配对门禁接受配对参数。**`verify-translation-pairing [pair...]` 只检查被点名的配对(配对三个文件中的任意一个,或其裸词干,都能指代该配对);全语料扫描仍是 `doc-sync`(文档同步门禁)与 CI 运行的无参数形式。`--write` 现在要求点名已确认的配对:裸 `--write` 会拒绝执行,重新记录全部配对必须显式写 `--write --all`;原因是旧的裸形式会默默为树中每一个漂移的配对背书,包括调用者从未看过的那些,纯行文层面的漂移于是可以永远保持绿灯。每份记录的注释都写明针对该配对自身的按对命令。写下记录之前,`--write` 用 `git hash-object -w --stdin` 存入每一侧的精确字节,并在内容寻址的本地 `refs/dsh/translation-pairing/snapshots/` ref 下固定该 blob;未提交的上次确认快照因此能被简报生成器之后的 `git cat-file` 取回,而不只是留下一个 Git 无法解析的 hash 名称或暴露于垃圾回收。 diff --git a/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.i18n.yaml index 07c742c518..316c31771e 100644 --- a/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-27-dependabot-version-updates.md -2026-07-27-dependabot-version-updates.md: 725649652c5b91ba4897d03b548b9aa5c3694c21 -2026-07-27-dependabot-version-updates.zh.md: 6400ba8ed94bf138fcece90e5d7ff82886d33ed1 +2026-07-27-dependabot-version-updates.md: 5d42563788d9f1e72da65c8e9750d6b1ecba06a5 +2026-07-27-dependabot-version-updates.zh.md: 4847059944e7e35de5719a6cbfd3d5b133467ccb diff --git a/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.md b/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.md index 725649652c..5d42563788 100644 --- a/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.md +++ b/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.md @@ -6,23 +6,23 @@ English | [中文](2026-07-27-dependabot-version-updates.zh.md) ## Problem -Maintained registry and GitHub Actions dependencies need a regular update path. Adopting every release immediately increases exposure to compromised releases and early regressions, while leaving updates entirely manual lets dependency drift accumulate. Vendored Cordis sources and independently locked workspaces also cannot be treated as one undifferentiated package tree. +Maintained registry and GitHub Actions dependencies need a regular update path. Adopting every release immediately increases exposure to compromised releases and early regressions, while leaving updates entirely manual lets dependency drift accumulate. Vendored Cordis sources cannot be treated like registry dependencies, and workspaces sharing one lockfile must be updated through the same package tree. ## Decision -The default branch carries [`.github/dependabot.yml`](../../../../.github/dependabot.yml) with weekly version-update checks for the root pnpm workspace, the independently locked `native/landlock-run` pnpm workspace, the `python/sdk` uv project, and GitHub Actions. Every entry sets `cooldown.default-days` to `30`, so a version release becomes eligible only after it is at least 30 days old and is proposed on the next weekly check. +The default branch carries [`.github/dependabot.yml`](../../../../.github/dependabot.yml) with weekly version-update checks for the root pnpm workspace, including `native/landlock-run`, the `python/sdk` uv project, and GitHub Actions. Every entry sets `cooldown.default-days` to `30`, so a version release becomes eligible only after it is at least 30 days old and is proposed on the next weekly check. The [in-repository Landlock release decision](2026-08-06-in-repository-landlock-release.md) owns the shared-workspace boundary. -The root pnpm version-update scan excludes `vendor/**`, whose source and manifests move only through the [vendoring procedure](../../../../vendor/README.md), and `native/landlock-run/**`, which its dedicated entry owns. GitHub applies `exclude-paths` only to version updates; a security pull request that touches a vendored manifest is replaced through the vendoring procedure instead of being merged as generated. Dependabot pull requests receive the repository's `cleanup` kind and `area/infra` area labels, run the normal pull-request checks, and remain subject to maintainer review; this automation does not merge them. +The root pnpm version-update scan excludes `vendor/**`, whose source and manifests move only through the [vendoring procedure](../../../../vendor/README.md). GitHub applies `exclude-paths` only to version updates; a security pull request that touches a vendored manifest is replaced through the vendoring procedure instead of being merged as generated. Dependabot pull requests receive the repository's `cleanup` kind and `area/infra` area labels, run the normal pull-request checks, and remain subject to maintainer review; this automation does not merge them. Repository settings enable dependency vulnerability alerts and Dependabot security updates. GitHub does not apply version-update cooldowns to those security updates, so security fixes remain eligible immediately. A generated pnpm security pull request can still fail the repository's lockfile release-age verification when dependency resolution selects unrelated fresh transitive versions; that pull request waits or is narrowed instead of weakening the policy. The repository's coordinated fresh-release exceptions are not copied into Dependabot's cooldown exclusions: automated version updates use the uniform 30-day wait, while an explicitly reviewed manual update can still follow its owning release procedure. -The pnpm entries keep both workspaces on their pinned pnpm 11 instead of introducing an automation-only downgrade. The current Dependabot updater installs the version requested by `packageManager` and reads both workspaces' lockfile format `9.0`; the provider-run update job remains the integration check. +The pnpm entry keeps the unified workspace on its pinned pnpm 11 instead of introducing an automation-only downgrade. The current Dependabot updater installs the version requested by the root `packageManager` and reads the root lockfile format `9.0`; the provider-run update job remains the integration check. ## Alternatives considered - **Immediate version updates.** Rejected because they remove the requested release-age quarantine and make the project an early consumer of every upstream release. - **Automatic merging after CI.** Rejected because dependency changes can alter runtime, build, and release behavior; the normal review decision remains part of accepting an update. -- **One recursive npm scan.** Rejected because it could admit vendored manifests or conflate the root and native lockfiles. Explicit exclusions and a dedicated native entry preserve their ownership boundaries. +- **A separate native npm scan.** Rejected because the Landlock manifests belong to the root workspace and lockfile; splitting their update would recreate an ownership boundary the package manager no longer has. The root scan excludes only vendored manifests. - **Renovate or a scheduled agent.** Both can propose aged updates, but Dependabot is the requested service and the repository's CI already recognizes its pull requests as an untrusted dependency source. - **Cooldown exemptions for coordinated fresh releases.** Rejected for the automated path because those releases require an explicit synchronization or model-catalog decision rather than a generic update proposal. diff --git a/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.zh.md b/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.zh.md index 6400ba8ed9..4847059944 100644 --- a/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.zh.md +++ b/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.zh.md @@ -6,23 +6,23 @@ Status: implemented ## 问题 -来自包注册表的依赖与 GitHub Actions 依赖都需要定期更新机制。每个新版本一经发布便立即采用,会增加受到遭入侵的版本和早期回归影响的风险;但完全依靠手动更新,又会导致依赖版本差距持续扩大。以源码形式纳入仓库的 Cordis 与各自维护独立锁文件的工作区,也不能不加区分地视为同一棵包树。 +来自包注册表的依赖与 GitHub Actions 依赖都需要定期更新机制。每个新版本一经发布便立即采用,会增加受到遭入侵的版本和早期回归影响的风险;但完全依靠手动更新,又会导致依赖版本差距持续扩大。以源码形式纳入仓库的 Cordis 不能当作注册表依赖处理,而共用一份锁文件的工作区必须通过同一棵包树更新。 ## 决策 -默认分支包含 [`.github/dependabot.yml`](../../../../.github/dependabot.yml),其中为根 pnpm 工作区、独立维护锁文件的 `native/landlock-run` pnpm 工作区、`python/sdk` uv 项目和 GitHub Actions 配置了每周一次的版本更新检查。每个更新项都将 `cooldown.default-days` 设为 `30`,因此某个版本只有在发布至少 30 天后才符合更新条件,并会在下一次每周检查时生成更新提案。 +默认分支包含 [`.github/dependabot.yml`](../../../../.github/dependabot.yml),其中为包含 `native/landlock-run` 的根 pnpm 工作区、`python/sdk` uv 项目和 GitHub Actions 配置了每周一次的版本更新检查。每个更新项都将 `cooldown.default-days` 设为 `30`,因此某个版本只有在发布至少 30 天后才符合更新条件,并会在下一次每周检查时生成更新提案。[仓库内 Landlock 发布决策](2026-08-06-in-repository-landlock-release.md)负责共享工作区边界。 -根 pnpm 工作区的版本更新扫描排除 `vendor/**`,其中的源码和 manifest(元数据清单)只能通过 [vendoring 流程](../../../../vendor/README.md)变更;扫描还排除由专用更新项负责的 `native/landlock-run/**`。GitHub 仅将 `exclude-paths` 用于版本更新;如果安全更新 PR(Pull Request)涉及随源码纳入仓库的 manifest,则改由 vendoring 流程处理,以替代自动生成的 PR,而不会将其原样合并。Dependabot PR 会获得仓库的 `cleanup` 类型标签和 `area/infra` 区域标签,运行常规 PR 检查,并且仍须由维护者评审;该自动化不会合并这些 PR。 +根 pnpm 工作区的版本更新扫描排除 `vendor/**`,其中的源码和 manifest(元数据清单)只能通过 [vendoring 流程](../../../../vendor/README.md)变更。GitHub 仅将 `exclude-paths` 用于版本更新;如果安全更新 PR(Pull Request)涉及随源码纳入仓库的 manifest,则改由 vendoring 流程处理,以替代自动生成的 PR,而不会将其原样合并。Dependabot PR 会获得仓库的 `cleanup` 类型标签和 `area/infra` 区域标签,运行常规 PR 检查,并且仍须由维护者评审;该自动化不会合并这些 PR。 仓库设置已启用依赖项漏洞警报和 Dependabot 安全更新。GitHub 不会对这些安全更新应用版本更新冷却期,因此安全修复仍可立即进入更新流程。如果依赖解析还选中了其他刚发布的传递依赖,pnpm 安全更新 PR 仍可能无法通过仓库的锁文件发布时长校验;此类 PR 应等待隔离期结束或缩小更新范围,不得因此放宽政策。仓库为协调刚发布版本而设置的例外,不会纳入 Dependabot 的冷却期排除项:自动版本更新统一等待 30 天;经过明确评审的手动更新仍可遵循相应的发布流程。 -pnpm 更新项让两个工作区继续使用已固定的 pnpm 11,不会仅为了自动化而降级版本。当前 Dependabot 更新器会安装 `packageManager` 指定的版本,并读取两个工作区使用的 `9.0` 锁文件格式;由提供方运行的更新任务仍作为集成检查。 +pnpm 更新项让统一工作区继续使用已固定的 pnpm 11,不会仅为了自动化而降级版本。当前 Dependabot 更新器会安装根 `packageManager` 指定的版本,并读取根锁文件的 `9.0` 格式;由提供方运行的更新任务仍作为集成检查。 ## 考虑过的替代方案 - **立即进行版本更新。** 不采用,因为这会取消所要求的版本发布后隔离期,使项目在每个上游版本的发布初期就采用该版本。 - **CI 通过后自动合并。** 不采用,因为依赖变更可能改变运行时、构建和发布行为;是否接受更新仍须经过常规评审决策。 -- **使用一次递归 npm 扫描。** 不采用,因为它可能将随源码纳入仓库的 manifest 纳入更新范围,或混淆根工作区与 native 工作区的锁文件。显式排除项和专用 native 更新项可维持各自的归属边界。 +- **为 native 配置独立的 npm 扫描。** 不采用,因为 Landlock manifest 属于根工作区和根锁文件;拆分更新会重建一个包管理器已不存在的归属边界。根扫描仅排除随源码纳入的 manifest。 - **Renovate 或定期运行的 agent(智能体)。** 二者都能为发布已满一定时长的版本提出更新,但所要求的服务是 Dependabot,而且仓库 CI 已将其 PR 视为不可信的依赖来源。 - **为需协调的刚发布版本设置冷却期豁免。** 自动化路径不采用,因为此类版本需要明确的同步决策或模型目录决策,不能由通用更新提案代替。 diff --git a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml index 4786909a7f..19945a6580 100644 --- a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md -2026-07-27-wine-windows-gates-experiment.md: 640c8e455b1a35ea4ac83454227147b9979316dc -2026-07-27-wine-windows-gates-experiment.zh.md: 67f59a93d1b1fad98e36e6f9c51bc77abb9e3d07 +2026-07-27-wine-windows-gates-experiment.md: 1b01fe00dc1588482442a3cedc35eefa7fdb0975 +2026-07-27-wine-windows-gates-experiment.zh.md: b50739f5b63c5836248c94927037e38900abc84f diff --git a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md index 640c8e455b..1b01fe00dc 100644 --- a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md +++ b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md @@ -14,7 +14,7 @@ The question the experiment answered: can a plain Linux runner produce an equiva The required pull-request `windows` job in [ci.yml](../../../../.github/workflows/ci.yml) (`windows node 24 / wine blocking`) runs the blocking gate commands on `ubuntu-latest` under Wine with real Windows binaries: a checksum-verified win-x64 Node.js executes `tsc -b`, `tsdown`, and the VitePress production build, so the win32 branches of the toolchain — backslash path handling, `CreateProcess` spawn semantics, PE loading of `@esbuild/win32-x64`, and the rolldown/rollup MSVC `.node` addons — actually execute. The master `serial-windows` job is untouched: the complete native-kernel inventory, including the observational portability gates this lane does not run, still executes on real `windows-2025` on every master push. -Dependencies install natively on Linux with `supportedArchitectures` extended to win32-x64, which materializes the Windows platform packages in the same store; the cmd-shim layer is bypassed by invoking each tool's JavaScript entrypoint directly, the same processes `run-gates` ultimately spawns. `nodeLinker: hoisted` is load-bearing, not stylistic: the independent prototype in [PR #689](https://github.com/deepseek-harness/deepseek-harness/pull/689) kept pnpm's default isolated layout — including a faithful offline Windows-pnpm re-install over a Linux-prefetched store — and Windows Node under Wine still could not resolve `@esbuild/win32-x64` or load the koffi prebuild through the isolated symlink chain, failing before any repository gate ran. A flat layout with real files is what makes the gates reachable at all; #689's checksum pinning is adopted, while its Windows-pnpm-installs-the-tree goal is explicitly given up (the install contract stays Linux-tested here). +Dependencies install natively on Linux with `supportedArchitectures` extended to win32-x64, which materializes the Windows platform packages in the same store; the cmd-shim layer is bypassed by invoking each tool's JavaScript entrypoint directly, the same processes `run-gates` ultimately spawns. `nodeLinker: hoisted` is load-bearing, not stylistic: an independent prototype kept pnpm's default isolated layout — including a faithful offline Windows-pnpm re-install over a Linux-prefetched store — and Windows Node under Wine still could not resolve `@esbuild/win32-x64` or load the koffi prebuild through the isolated symlink chain, failing before any repository gate ran. A flat layout with real files is what makes the gates reachable at all; the prototype's checksum pinning is adopted, while its Windows-pnpm-installs-the-tree goal is explicitly given up (the install contract stays Linux-tested here). The lane holds the wall clock of the Linux CI jobs through four levers: the master-refreshed pnpm store cache (restore-only, same key as the Linux jobs), Wine provisioning (apt install, Windows Node download, `wineboot`) running concurrently with `pnpm install`, the two blocking surfaces running concurrently — the same shape `run-gates` gives them on native Windows — and an apt-archive cache keyed on the runner image, seeded from master by the `wine apt cache` job so every pull request restores from the default-branch scope. @@ -32,7 +32,7 @@ Measured on 2026-07-27, warm caches, pull-request trigger, standard 2-core `ubun **A full Windows guest under QEMU/KVM inside the Linux runner.** Real NT kernel, so full fidelity including case-insensitive NTFS and ConPTY — but tens of minutes of image download and unattended install before the first gate runs (40m19s measured end-to-end on the sibling experiment branch `exp/kvm-windows-ci`). Promotable only with disk-image caching that pressures the Actions cache budget. -**Windows pnpm performing the install under Wine ([PR #689](https://github.com/deepseek-harness/deepseek-harness/pull/689)).** The higher-fidelity variant of this same idea: MinGit and pnpm staged into the prefix, a Linux prefetch filling the store, then `pnpm install --offline` run by Windows Node so the install contract itself executes as win32. It reached the install but not the gates — Wine's networking could not reach the registry directly, and the isolated `node_modules` layout defeated resolution of the Windows platform packages even after a clean offline install. This lane trades that fidelity away (hoisted layout, Linux-side install) to reach the gates; the two records are complementary halves of the same verdict. +**Windows pnpm performing the install under Wine.** The higher-fidelity variant of this same idea: MinGit and pnpm staged into the prefix, a Linux prefetch filling the store, then `pnpm install --offline` run by Windows Node so the install contract itself executes as win32. It reached the install but not the gates — Wine's networking could not reach the registry directly, and the isolated `node_modules` layout defeated resolution of the Windows platform packages even after a clean offline install. This lane trades that fidelity away (hoisted layout, Linux-side install) to reach the gates; the two records are complementary halves of the same verdict. **Filesystem-semantics lanes on Linux (casefolded ext4, filename lint).** Catches the highest-frequency Windows breakage class for near-zero cost but proves nothing about win32 binaries. Explored as the sibling experiment branch `exp/casefold-windows-ci`; complementary to, not competitive with, this lane. diff --git a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md index 67f59a93d1..b50739f5b6 100644 --- a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md +++ b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md @@ -14,7 +14,7 @@ Pull request 的 Windows 通道旨在验证两个阻断性 win32 表面,即 wo [ci.yml](../../../../.github/workflows/ci.yml) 中必需的 pull request `windows` 作业(`windows node 24 / wine blocking`)在 `ubuntu-latest` 上通过 Wine 用真实 Windows 二进制运行阻断门禁命令:校验和验证过的 win-x64 Node.js 执行 `tsc -b`、`tsdown` 与 VitePress 生产构建,因此工具链的 win32 分支——反斜杠路径处理、`CreateProcess` 派生语义、`@esbuild/win32-x64` 的 PE 加载、以及 rolldown/rollup 的 MSVC `.node` 插件——都真正执行。master 的 `serial-windows` 作业原封不动:完整的原生内核清单,包括本通道不运行的观察性可移植性门禁,仍在每次 master push 时于真实 `windows-2025` 上执行。 -依赖在 Linux 上原生安装,`supportedArchitectures` 扩展到 win32-x64,使 Windows 平台包物化进同一个 store;通过直接调用各工具的 JavaScript 入口绕开 cmd-shim 层,这正是 `run-gates` 最终派生的那些进程。`nodeLinker: hoisted` 是承重的,不是风格问题:[PR #689](https://github.com/deepseek-harness/deepseek-harness/pull/689) 的独立原型保留了 pnpm 默认的 isolated 布局——包括在 Linux 预取的 store 上忠实地用 Windows pnpm 离线重装——而 Wine 下的 Windows Node 依然无法穿过 isolated 符号链接链解析 `@esbuild/win32-x64` 或加载 koffi 预编译产物,在任何仓库门禁运行前就失败了。扁平的真实文件布局才让门禁变得可达;本通道采纳了 #689 的校验和固定,同时明确放弃其「Windows pnpm 安装依赖树」的目标(安装契约在此仍由 Linux 侧验证)。 +依赖在 Linux 上原生安装,`supportedArchitectures` 扩展到 win32-x64,使 Windows 平台包物化进同一个 store;通过直接调用各工具的 JavaScript 入口绕开 cmd-shim 层,这正是 `run-gates` 最终派生的那些进程。`nodeLinker: hoisted` 是承重的,不是风格问题:一个独立原型保留了 pnpm 默认的 isolated 布局——包括在 Linux 预取的 store 上忠实地用 Windows pnpm 离线重装——而 Wine 下的 Windows Node 依然无法穿过 isolated 符号链接链解析 `@esbuild/win32-x64` 或加载 koffi 预编译产物,在任何仓库门禁运行前就失败了。扁平的真实文件布局才让门禁变得可达;本通道采纳了该原型的校验和固定,同时明确放弃其「Windows pnpm 安装依赖树」的目标(安装契约在此仍由 Linux 侧验证)。 该通道靠四个杠杆把墙钟时间保持在与 Linux CI 作业相当的水平:master 刷新的 pnpm store 缓存(只恢复,与 Linux 作业同键)、Wine 供给(apt 安装、Windows Node 下载、`wineboot`)与 `pnpm install` 并发运行、两个阻断表面并发运行——与 `run-gates` 在原生 Windows 上给它们的形状相同——以及按 runner 镜像为键的 apt 归档缓存,由 master 的 `wine apt cache` 作业播种,使每个 pull request 都能从默认分支作用域恢复。 @@ -32,7 +32,7 @@ Pull request 的 Windows 通道旨在验证两个阻断性 win32 表面,即 wo **在 Linux runner 内用 QEMU/KVM 跑完整 Windows 客户机。** 真实 NT 内核,保真度完整,包括大小写不敏感的 NTFS 与 ConPTY——但首个门禁运行前要花数十分钟下载镜像并做无人值守安装(兄弟实验分支 `exp/kvm-windows-ci` 实测端到端 40 分 19 秒)。只有配上会挤压 Actions 缓存预算的磁盘镜像缓存才可投入使用。 -**在 Wine 下由 Windows pnpm 执行安装([PR #689](https://github.com/deepseek-harness/deepseek-harness/pull/689))。** 同一想法的更高保真度变体:把 MinGit 与 pnpm 放进 prefix,用 Linux 预取填充 store,再由 Windows Node 运行 `pnpm install --offline`,让安装契约本身以 win32 身份执行。它到达了安装但没到达门禁——Wine 的网络无法直接访问 registry,且 isolated 的 `node_modules` 布局即便在干净的离线安装后也挫败了 Windows 平台包的解析。本通道牺牲这份保真度(hoisted 布局、Linux 侧安装)来换取门禁可达;两份记录是同一裁决互补的两半。 +**在 Wine 下由 Windows pnpm 执行安装。** 同一想法的更高保真度变体:把 MinGit 与 pnpm 放进 prefix,用 Linux 预取填充 store,再由 Windows Node 运行 `pnpm install --offline`,让安装契约本身以 win32 身份执行。它到达了安装但没到达门禁——Wine 的网络无法直接访问 registry,且 isolated 的 `node_modules` 布局即便在干净的离线安装后也挫败了 Windows 平台包的解析。本通道牺牲这份保真度(hoisted 布局、Linux 侧安装)来换取门禁可达;两份记录是同一裁决互补的两半。 **Linux 上的文件系统语义通道(casefold ext4、文件名 lint)。** 以近零成本捕获最高频的 Windows 故障类别,但对 win32 二进制什么也证明不了。作为兄弟实验分支 `exp/casefold-windows-ci` 探索;与本通道互补而非竞争。 diff --git a/.agents/notes/implemented/process/2026-07-29-oxlint-linter.i18n.yaml b/.agents/notes/implemented/process/2026-07-29-oxlint-linter.i18n.yaml index ae7c705d5d..4aec3f6a4f 100644 --- a/.agents/notes/implemented/process/2026-07-29-oxlint-linter.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-29-oxlint-linter.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-29-oxlint-linter.md -2026-07-29-oxlint-linter.md: 41a50a9d08819809f954aa99007081f270692f38 -2026-07-29-oxlint-linter.zh.md: 85d053af867353ff1b4c53822013f55660c89cca +2026-07-29-oxlint-linter.md: 3e3de24be33b8f9461cea2967b0fa18f59ddb52a +2026-07-29-oxlint-linter.zh.md: 5388416b54a5ff44b9ab9551d726705a84a8b1a1 diff --git a/.agents/notes/implemented/process/2026-07-29-oxlint-linter.md b/.agents/notes/implemented/process/2026-07-29-oxlint-linter.md index 41a50a9d08..3e3de24be3 100644 --- a/.agents/notes/implemented/process/2026-07-29-oxlint-linter.md +++ b/.agents/notes/implemented/process/2026-07-29-oxlint-linter.md @@ -12,19 +12,19 @@ A faster runner cannot justify losing rules. The migration must preserve the str ## Decision -The root [`.oxlintrc.json`](../../../../.oxlintrc.json) is the authoritative repository lint configuration. The `lint` package script, gate scheduler, CI, and lefthook invoke Oxlint through [`scripts/run-oxlint.ts`](../../../../scripts/run-oxlint.ts) for repository-wide, type-aware, or staged validation. The `lint:fix` script and lefthook first invoke the formatting-only [`eslint.format.config.mjs`](../../../../eslint.format.config.mjs), then run Oxlint. The direct `eslint` and `@typescript-eslint/parser` development dependencies exist only for this parser-without-project formatting pass; their exact versions pin the tested parser/fixer pairing, and that config contains no correctness or type-aware rules. +The root [`.oxlintrc.json`](../../../../.oxlintrc.json) is the authoritative type-aware repository lint configuration. The project-free [`.oxlintrc.staged.json`](../../../../.oxlintrc.staged.json) profile inherits its source rules but disables type analysis for the bounded pre-commit path. The `lint` package script, gate scheduler, CI, and lefthook invoke Oxlint through [`scripts/run-oxlint.ts`](../../../../scripts/run-oxlint.ts); `lint:fix` and lefthook first invoke the formatting-only [`eslint.format.config.mjs`](../../../../eslint.format.config.mjs). The direct `eslint` and `@typescript-eslint/parser` development dependencies exist only for this parser-without-project formatting pass; their exact versions pin the tested parser/fixer pairing, and that config contains no correctness or type-aware rules. `options.typeAware` enables `oxlint-tsgolint`. Its backend performs per-file TypeScript-project discovery: package sources use their package projects, host tests/examples/website use `tsconfig.host.json`, and client tests plus `scripts/client-bundle-purity.spec.ts` use `tsconfig.client.json`. The program-less root solution is never flattened. Oxlint's `--tsconfig` override affects import resolution but is ignored by type-aware linting, so this repository does not set it. The configuration explicitly carries the migrated strict-type-checked rules and repository overrides instead of enabling broad Oxlint categories whose contents may change. `typescript/no-unnecessary-condition` remains enabled from Oxlint's nursery set because it was an enforced repository rule before migration. Oxlint's JavaScript-plugin compatibility layer runs `@stylistic/eslint-plugin` and `eslint-plugin-sonarjs` so the existing formatting and file-local duplicate-logic rules remain enforced. The compatibility layer reports `@stylistic` violations but does not execute their fixers, so the formatting-only ESLint pass owns only the corresponding auto-fixes; an executable parity check keeps those fixable rule definitions aligned while `max-len` remains validation-only. Owned-source suppressions use `oxlint-*` directives and the `typescript/*` namespace, and unused directives remain warnings; vendored sources keep their upstream directives because Oxlint excludes `vendor/**`. -CI does not restore or save a lint-result cache. `DSH_OXLINT_THREADS` makes the shared runner pass the same bound to Oxlint's `--threads` option and the type-aware backend's `GOMAXPROCS` environment variable; ordinary local runs use both defaults. Pre-commit applies the formatting-only ESLint fixes, runs Oxlint validation and native safe fixes, accepts selections containing only ignored files, and re-stages the result through lefthook. +CI does not restore or save a lint-result cache. `DSH_OXLINT_THREADS` makes the shared runner pass the same bound to Oxlint's `--threads` option and the type-aware backend's `GOMAXPROCS` environment variable; ordinary local runs use both defaults. Pre-commit applies the formatting-only ESLint fixes, runs project-free Oxlint validation and native safe fixes, accepts selections containing only ignored files, and re-stages the result through lefthook. Public `lint` and CI retain the complete type-aware rules after preparing generated declarations. ## Verification The migrated configuration reports the same clean owned-source baseline after resolving two analyzer differences: one redundant test assertion was removed, while one structural cast required by `tsc` carries a narrow Oxlint suppression. A one-time audit against the exact deleted ESLint configuration blob established source 88-to-88, examples 87-to-87, and tests 83-to-83 after the rule-name translations. The committed fingerprint pins those audited Oxlint profiles and the complete override shape; it neither executes the deleted configuration nor propagates later upstream preset changes. Evaluating `typescript-eslint@8.61.0` also confirms that `strictTypeChecked` did not enable `@typescript-eslint/no-empty-function`; the deleted tests-only `off` entry was inert. -Executable contract tests require type-aware diagnostics from the package, host, and client projects; assert the client-only script's project; reject unmatched fallback analysis; and exercise the Stylistic, SonarJS, and nursery compatibility paths. They also pin unused-suppression reporting, ignored-only staged selections, formatter/validator rule parity, and final formatted bytes. Runner tests pin both worker controls, and typecheck confirms that migration-driven source edits preserve the TypeScript programs. +Executable contract tests require type-aware diagnostics from the package, host, and client projects; assert the client-only script's project; reject unmatched fallback analysis; and exercise the Stylistic, SonarJS, and nursery compatibility paths. They also pin the staged profile's project-free inheritance, unused-suppression reporting, ignored-only staged selections, formatter/validator rule parity, and final formatted bytes. Runner tests pin both worker controls, and typecheck confirms that migration-driven source edits preserve the TypeScript programs. ## Alternatives considered @@ -42,4 +42,4 @@ Local migration measurements reduced a clean type-aware lint run from about 61 s Type-aware diagnostics now come from the TypeScript Go analyzer bundled through `oxlint-tsgolint`, so edge-case inference can differ from typescript-eslint even when `tsc` accepts the same program. Lint and typecheck remain separate required evidence. -The JavaScript-plugin compatibility API and staged formatter are additional boundaries to maintain. Commits pay one project-free ESLint startup before Oxlint, and the root development graph retains ESLint plus the TypeScript parser. Repository-wide validation, type-aware analysis, cache policy, worker control, and inline directives remain Oxlint-owned. +The JavaScript-plugin compatibility API, staged profile, and staged formatter are additional boundaries to maintain. Commits defer type-aware diagnostics to public lint and CI, pay one project-free ESLint startup before Oxlint, and avoid depending on generated declarations. The root development graph retains ESLint plus the TypeScript parser. Repository-wide validation, type-aware analysis, cache policy, worker control, and inline directives remain Oxlint-owned. diff --git a/.agents/notes/implemented/process/2026-07-29-oxlint-linter.zh.md b/.agents/notes/implemented/process/2026-07-29-oxlint-linter.zh.md index 85d053af86..5388416b54 100644 --- a/.agents/notes/implemented/process/2026-07-29-oxlint-linter.zh.md +++ b/.agents/notes/implemented/process/2026-07-29-oxlint-linter.zh.md @@ -12,19 +12,19 @@ Status: implemented ## 决策 -根目录的 [`.oxlintrc.json`](../../../../.oxlintrc.json) 是仓库 lint 配置的权威来源。`lint` 包脚本、门禁调度器、CI 和 lefthook 通过 [`scripts/run-oxlint.ts`](../../../../scripts/run-oxlint.ts) 调用 Oxlint,进行全仓库、类型感知或暂存验证。`lint:fix` 脚本和 lefthook 先调用仅用于格式化的 [`eslint.format.config.mjs`](../../../../eslint.format.config.mjs),再运行 Oxlint。直接的 `eslint` 和 `@typescript-eslint/parser` 开发依赖仅用于这次不加载项目的格式化流程;其精确版本锁定经过测试的解析器与修复器配对,该配置不包含正确性规则或类型感知规则。 +根目录的 [`.oxlintrc.json`](../../../../.oxlintrc.json) 是仓库类型感知 lint 配置的权威来源。不加载项目的 [`.oxlintrc.staged.json`](../../../../.oxlintrc.staged.json) 配置继承其源码规则,但会为有界的 pre-commit 路径禁用类型分析。`lint` 包脚本、门禁调度器、CI 和 lefthook 通过 [`scripts/run-oxlint.ts`](../../../../scripts/run-oxlint.ts) 调用 Oxlint;`lint:fix` 和 lefthook 会先调用仅用于格式化的 [`eslint.format.config.mjs`](../../../../eslint.format.config.mjs)。直接的 `eslint` 和 `@typescript-eslint/parser` 开发依赖仅用于这次不加载项目的格式化流程;其精确版本锁定经过测试的解析器与修复器配对,该配置不包含正确性规则或类型感知规则。 `options.typeAware` 启用 `oxlint-tsgolint`。其后端按文件发现 TypeScript 项目:包源码使用各自的包项目,host 测试、示例和网站使用 `tsconfig.host.json`,client 测试及 `scripts/client-bundle-purity.spec.ts` 使用 `tsconfig.client.json`。不含程序的根解决方案绝不会被扁平化。Oxlint 的 `--tsconfig` 覆盖项会影响导入解析,但类型感知 lint 会忽略它,因此本仓库不设置该选项。该配置显式载入迁移后的严格类型检查规则和仓库覆盖配置,而不启用内容可能发生变化的 Oxlint 宽泛类别。`typescript/no-unnecessary-condition` 仍从 Oxlint 的 nursery 规则集中启用,因为它在迁移前就是仓库强制执行的规则。 Oxlint 的 JavaScript 插件兼容层运行 `@stylistic/eslint-plugin` 和 `eslint-plugin-sonarjs`,从而继续强制执行现有的格式和文件内重复逻辑规则。兼容层会报告 `@stylistic` 违规,但不会执行其修复器,因此仅用于格式化的 ESLint 流程只负责相应的自动修复;一项可执行检查确保这些可修复规则定义保持一致,而 `max-len` 仅用于验证。自有源码中的抑制指令使用 `oxlint-*` 指令和 `typescript/*` 命名空间,未使用的指令仍作为警告报告;vendor 源码保留其上游指令,因为 Oxlint 会排除 `vendor/**`。 -CI 不恢复或保存 lint 结果缓存。`DSH_OXLINT_THREADS` 使共享运行器将同一上限传给 Oxlint 的 `--threads` 选项和类型感知后端的 `GOMAXPROCS` 环境变量;普通本地运行对两者均采用默认值。Pre-commit 应用仅用于格式化的 ESLint 修复,运行 Oxlint 验证和原生安全修复,接受仅含已忽略文件的文件选择,并通过 lefthook 重新暂存结果。 +CI 不恢复或保存 lint 结果缓存。`DSH_OXLINT_THREADS` 使共享运行器将同一上限传给 Oxlint 的 `--threads` 选项和类型感知后端的 `GOMAXPROCS` 环境变量;普通本地运行对两者均采用默认值。Pre-commit 应用仅用于格式化的 ESLint 修复,运行不加载项目的 Oxlint 验证并应用原生安全修复,接受仅含已忽略文件的文件选择,并通过 lefthook 重新暂存结果。公共 `lint` 和 CI 会先准备生成的声明,并保留完整的类型感知规则。 ## 验证 解决两处分析器差异后,迁移后的配置报告与迁移前一致的自有源码无问题基线:移除了一项冗余测试断言,而 `tsc` 要求的一处结构性类型转换使用了窄范围的 Oxlint 抑制指令。以已删除 ESLint 配置的精确 blob 为基准进行的一次性审核在完成规则名映射后确认:源码为 88 项对 88 项,示例为 87 项对 87 项,测试为 83 项对 83 项。已提交的指纹锁定这些经审核的 Oxlint 规则配置及完整的覆盖结构;它既不执行已删除的配置,也不纳入后续的上游预设变更。对 `typescript-eslint@8.61.0` 的评估还确认,`strictTypeChecked` 并未启用 `@typescript-eslint/no-empty-function`;已删除、仅用于测试的 `off` 条目不起作用。 -可执行契约测试要求包、host 和 client 项目产生类型感知诊断,断言 client 专用脚本所用的项目,拒绝未匹配的回退分析,并检验 Stylistic、SonarJS 和 nursery 兼容路径。它们还锁定未使用抑制指令的报告行为、仅选择已忽略暂存文件的情况、格式化器与验证器之间的规则一致性,以及最终格式化后的字节。运行器测试锁定两项工作线程控制,类型检查则确认迁移引发的源码改动没有破坏 TypeScript 程序。 +可执行契约测试要求包、host 和 client 项目产生类型感知诊断,断言 client 专用脚本所用的项目,拒绝未匹配的回退分析,并检验 Stylistic、SonarJS 和 nursery 兼容路径。它们还锁定暂存配置不加载项目的继承行为、未使用抑制指令的报告行为、仅选择已忽略暂存文件的情况、格式化器与验证器之间的规则一致性,以及最终格式化后的字节。运行器测试锁定两项工作线程控制,类型检查则确认迁移引发的源码改动没有破坏 TypeScript 程序。 ## 考虑过的替代方案 @@ -42,4 +42,4 @@ CI 不恢复或保存 lint 结果缓存。`DSH_OXLINT_THREADS` 使共享运行 类型感知诊断现在来自通过 `oxlint-tsgolint` 捆绑的 TypeScript Go 分析器,因此即使 `tsc` 接受同一程序,边界场景下的类型推断也可能与 typescript-eslint 不同。lint 与类型检查仍是两项相互独立的必要证据。 -JavaScript 插件兼容 API 和暂存文件格式化器是需要维护的额外边界。每次提交在 Oxlint 之前需要启动一次不加载项目的 ESLint,根目录开发依赖图仍保留 ESLint 和 TypeScript 解析器。全仓库验证、类型感知分析、缓存政策、工作线程控制和内联指令仍由 Oxlint 负责。 +JavaScript 插件兼容 API、暂存配置和暂存文件格式化器是需要维护的额外边界。每次提交把类型感知诊断留给公共 lint 和 CI,在 Oxlint 之前启动一次不加载项目的 ESLint,并避免依赖生成的声明。根目录开发依赖图仍保留 ESLint 和 TypeScript 解析器。全仓库验证、类型感知分析、缓存政策、工作线程控制和内联指令仍由 Oxlint 负责。 diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml index da2fdfd101..2589170fc0 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md -2026-07-30-generated-third-party-notices.md: cbabd1142ad292f78a3184723182a834dfb234fc -2026-07-30-generated-third-party-notices.zh.md: b135c3626b661f1a5b0317a90b700a26d679bf1e +2026-07-30-generated-third-party-notices.md: f2591c8d0c7d4b83ecc96bf237ded667e322d55c +2026-07-30-generated-third-party-notices.zh.md: 74c95cc4349e9d1288d6635b0ae8617e7b929115 diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md index cbabd1142a..f2591c8d0c 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md @@ -24,7 +24,7 @@ The file discloses **direct** dependencies by default. The complete npm closure The runtime tier deliberately covers **every mountable plugin**, not just what the CLI, Web UI, and Python runtime load by default. `scripts/install.sh` installs the repository itself, so a user's `cordis.yml` can mount any plugin package; `@modelcontextprotocol/sdk` and the OpenTelemetry packages reach real users even though no default assembly imports them. Under-disclosure is the costly direction for a legal notice. -The manifest set is derived from the `packages:` members each `pnpm-workspace.yaml` declares — the root one and the nested Landlock workspace's — so a new member area is read the day it is declared rather than the day someone remembers to extend a list. License and repository metadata come from the installed pnpm stores, both the root one and the Landlock workspace's, so the generator requires an installed tree and fails loud when a package resolves to neither, rather than emitting an empty cell. `OVERRIDES` carries the packages whose published manifest cannot answer — Rust-built npm bins that omit `license`, and the `modelcontextprotocol/servers` packages whose repository is mid MIT→Apache-2.0 relicensing, so their effective terms are per-contribution. A runtime dependency whose license is not on the permissive list is a hard error: shipping copyleft is a distribution decision, not something a regenerated table may absorb silently. Vendored packages are cross-checked against `vendor/README.md` and rejected if any is not MIT, and `pnpm-workspace.yaml`'s `patchedDependencies` are listed under the runtime table because pnpm applies those patches at install time — shipped artifacts carry modified copies of `@earendil-works/pi-tui` and `node-pty`, and the patch files are the record of what changed. +The manifest set is derived from the `packages:` members the root `pnpm-workspace.yaml` declares, including the Landlock workspace and its public packages, so a new member area is read the day it is declared rather than the day someone remembers to extend a list. License and repository metadata come from the root workspace's installed pnpm store and package-local link farms, so the generator requires an installed tree and fails loud when a package resolves to neither, rather than emitting an empty cell. `OVERRIDES` carries the packages whose published manifest cannot answer — Rust-built npm bins that omit `license`, and the `modelcontextprotocol/servers` packages whose repository is mid MIT→Apache-2.0 relicensing, so their effective terms are per-contribution. A runtime dependency whose license is not on the permissive list is a hard error: shipping copyleft is a distribution decision, not something a regenerated table may absorb silently. Vendored packages are cross-checked against `vendor/README.md` and rejected if any is not MIT, and `pnpm-workspace.yaml`'s `patchedDependencies` are listed under the runtime table because pnpm applies those patches at install time — shipped artifacts carry modified copies of `@earendil-works/pi-tui` and `node-pty`, and the patch files are the record of what changed. The project owner separately authorizes distribution of every official `@anthropic-ai/claude-agent-sdk` version and the official Claude Code CLI/platform payloads that version declares through `optionalDependencies`. The generator represents this as one exact direct-package identity exception, not as a permissive-license override: `SEE LICENSE IN README.md` and `SEE LICENSE IN LICENSE.md` remain non-permissive classifications, and every unrelated non-permissive runtime still fails closed. When the SDK is present, the generator reads its installed manifest, rejects optional identities outside the official SDK payload prefix, derives the current SDK, CLI, and payload versions, verifies the installed host payload's identity, version, and declared-license field, and renders the complete SDK-declared payload set in a separate notices section. Version, declared-license, and payload-set changes do not require new identity authorization, but they still require ordinary dependency, lockfile, compatibility, terms, and notices review. diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md index b135c3626b..74c95cc434 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md @@ -24,7 +24,7 @@ Status: implemented 运行时层刻意覆盖**所有可挂载的插件**,而不止 CLI、Web UI 与 Python 运行时默认加载的那些。`scripts/install.sh` 安装的就是仓库本身,用户的 `cordis.yml` 可以挂载任何插件包;`@modelcontextprotocol/sdk` 与 OpenTelemetry 系列即使没有任何默认装配引入,也会触达真实用户。对法务披露而言,披露不足才是代价更高的那个方向。 -清单集合由两个 `pnpm-workspace.yaml`——根工作区与嵌套的 Landlock 工作区——各自声明的 `packages:` 成员派生,因此新增成员区域在声明当天就会被读取,而不必等谁想起来去补一份列表。许可证与仓库地址取自已安装的 pnpm store,根 store 与 Landlock 工作区的 store 都会查;某个包两处都解析不到时直接失败,而不是留下空单元格。`OVERRIDES` 收录已发布清单答不上来的包:用 Rust 构建、发布时省略 `license` 字段的 npm 可执行包,以及 `modelcontextprotocol/servers` 系列——该仓库正处在 MIT 向 Apache-2.0 的重新许可过程中,实际条款按贡献逐条而定。运行时依赖的许可证若不在宽松清单内即为硬失败:交付 copyleft 是一项分发决策,不该被一次重新生成悄悄吸收。被源码收编的包会与 `vendor/README.md` 交叉核对,出现非 MIT 即报错;`pnpm-workspace.yaml` 的 `patchedDependencies` 列在运行时表格之后,因为 pnpm 在安装期就会打上这些补丁——交付产物携带的是改动过的 `@earendil-works/pi-tui` 与 `node-pty`,补丁文件本身就是改动的完整记录。 +清单集合由根 `pnpm-workspace.yaml` 声明的 `packages:` 成员派生,其中包括 Landlock 工作区及其公开包,因此新增成员区域在声明当天就会被读取,而不必等谁想起来去补一份列表。许可证与仓库地址取自根工作区已安装的 pnpm store 和包本地链接场;某个包两处都解析不到时直接失败,而不是留下空单元格。`OVERRIDES` 收录已发布清单答不上来的包:用 Rust 构建、发布时省略 `license` 字段的 npm 可执行包,以及 `modelcontextprotocol/servers` 系列——该仓库正处在 MIT 向 Apache-2.0 的重新许可过程中,实际条款按贡献逐条而定。运行时依赖的许可证若不在宽松清单内即为硬失败:交付 copyleft 是一项分发决策,不该被一次重新生成悄悄吸收。被源码收编的包会与 `vendor/README.md` 交叉核对,出现非 MIT 即报错;`pnpm-workspace.yaml` 的 `patchedDependencies` 列在运行时表格之后,因为 pnpm 在安装期就会打上这些补丁——交付产物携带的是改动过的 `@earendil-works/pi-tui` 与 `node-pty`,补丁文件本身就是改动的完整记录。 项目所有者另行授权分发每个官方 `@anthropic-ai/claude-agent-sdk` 版本,以及该版本通过 `optionalDependencies` 声明的官方 Claude Code CLI 与平台载荷。生成器将其表示为一项精确匹配直接包身份的例外,而非宽松许可证覆盖项:`SEE LICENSE IN README.md` 与 `SEE LICENSE IN LICENSE.md` 仍归类为非宽松,所有无关的非宽松运行时依赖仍以默认拒绝方式失败。存在该 SDK 时,生成器会读取其已安装清单,拒绝不符合官方 SDK 载荷前缀的可选包身份,推导当前 SDK、CLI 与载荷版本,核验已安装宿主载荷的身份、版本和声明许可证字段,并在单独的声明章节中渲染 SDK 声明的完整载荷集合。版本、声明许可证和载荷集合发生变化时无需新的身份授权,但仍须经过常规的依赖、锁文件、兼容性、条款和声明评审。 diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml index 1a179748f9..77003539ac 100644 --- a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md -2026-07-31-installer-adopts-existing-checkout.md: de3cd052f94a0d5256c7687e9a1a38ee69fd2caf -2026-07-31-installer-adopts-existing-checkout.zh.md: 2e8be804b4af6151e77e36f8b109616aab3a18e9 +2026-07-31-installer-adopts-existing-checkout.md: 3a213a6232e57f305910240505983421dcd288ad +2026-07-31-installer-adopts-existing-checkout.zh.md: 7cdcd5549fb2bca2a6cb11f0cf2867687565ae79 diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md index de3cd052f9..3a213a6232 100644 --- a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md @@ -46,6 +46,6 @@ A container adopting an outside clone is also no longer self-contained: deleting ## Testing -`scripts/install.sh` has no automated test, and this change does not add one: the user directed that `install.spec.ts` be left out of scope. That is a known gap on a shipped user-facing path, and the `/var` resolution defect above is exactly the class of bug a test would have caught first. The standing [`FIXME(install-ts)`](../../../../scripts/install.sh) asking for this workflow to move into a tested TypeScript entrypoint is correspondingly more pressing. +`scripts/install.sh` now has a real-shell PTY regression suite in `apps/cli/tests/install-script.spec.ts`, covering adoption and curl-style paths with stubbed dependencies. Curl-style installs default to the public `deepseek-ai/deepseek-harness-sdk` source, while replacing the installer with pnpm/npx remains separate work. Verification was manual, through a throwaway harness driving the real script with a stubbed `pnpm`: adopting a standalone clone; adopting from a linked worktree into its existing container; an explicit `DSH_SOURCE` still opting back into cloning; a dirty tree adopting silently with no prompt or warning while its uncommitted file stays behind; a non-git checkout failing with guidance; and a `curl`-style clone install asserting the built layout, which is the regression that caught the unresolved-`REPO_ROOT` defect. The interactive path was exercised under tmux from a dirty checkout, confirming the run reaches the launcher with no adoption prompt and ends with `dsh` running from the new staging worktree while the original checkout keeps its branch and its uncommitted file. diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md index 2e8be804b4..7cdcd5549f 100644 --- a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md @@ -46,6 +46,6 @@ Status: implemented ## Testing -`scripts/install.sh`没有自动化测试,本次变更也未添加:用户明确要求把`install.spec.ts`排除在范围之外。这是一条已交付的、面向用户的安装路径上的已知缺口,而上文那个`/var`解析缺陷,恰恰属于测试本应最先捕获的那类 bug。相应地,要求把这套流程迁移到有测试覆盖的 TypeScript 入口的既有[`FIXME(install-ts)`](../../../../scripts/install.sh)也变得更为紧迫。 +`scripts/install.sh` 现有一套位于 `apps/cli/tests/install-script.spec.ts` 的真实 shell PTY 回归测试,使用 stub 依赖覆盖接管路径和 curl 风格路径。curl 风格安装默认使用公开的 `deepseek-ai/deepseek-harness-sdk` 源,而以 pnpm/npx 替换安装器仍是另一项工作。 验证是手工完成的,通过一个一次性测试装置以打桩的`pnpm`驱动真实脚本:接管独立克隆;从 linked worktree 接管进其已有容器;显式`DSH_SOURCE`仍回到克隆路径;工作树不干净时静默接管、既不提示也不警告,且其未提交文件留在原处;非 git 检出失败并给出指引;以及`curl`式克隆安装断言所构建的布局——正是这项回归测试捕获了`REPO_ROOT`未解析的缺陷。交互路径在 tmux 下从一个不干净的检出走通,确认整个过程不出现接管提示即可到达启动器,最终`dsh`从新的 staging worktree 运行,而原检出保持其分支不变、未提交文件仍在。 diff --git a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.i18n.yaml b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.i18n.yaml index 32b51699e2..8ba8380bf1 100644 --- a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.md -2026-08-06-doc-site-carries-its-images.md: 9109808874579b79d85c2e22b0987110f41ddc42 -2026-08-06-doc-site-carries-its-images.zh.md: d601112e8870150c363d8533e85ef86e7f3f8ffc +2026-08-06-doc-site-carries-its-images.md: 4078a9b6251cf67456590ae25602a9f288c88dc1 +2026-08-06-doc-site-carries-its-images.zh.md: a9afb138d45d1ab991963b997e408477cf88110b diff --git a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.md b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.md index 9109808874..4078a9b625 100644 --- a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.md +++ b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.md @@ -20,7 +20,7 @@ Only a regular file whose real path stays inside the repository is copied; anyth `docsSourceFiles()` reports the placed images alongside the Markdown, so the dev server's watcher re-projects when a screenshot is replaced instead of serving the previous copy until something touches the page. -`placeImage` is optional because `rewriteMarkdown` is also called directly by its spec, where no generated tree exists. Without it the old GitHub-raw behavior stands, which keeps that seam honest: the fallback is still the correct answer for a consumer that only rewrites text. +`placeImage` is optional because `rewriteMarkdown` is also called directly by its spec, where no generated tree exists. Without it the GitHub-raw fallback points at the public source home, which keeps that seam honest for a consumer that only rewrites text. Canonical Markdown keeps writing ordinary repository-relative image paths, so the same file renders on GitHub and on the site. No document carries a site-absolute URL to satisfy VitePress. @@ -36,7 +36,7 @@ Canonical Markdown keeps writing ordinary repository-relative image paths, so th Images in published documentation now work regardless of who is reading or whether the repository is public, and the site build has no runtime dependency on GitHub for them. The generated tree grows by one copy of each referenced image per locale — the four screenshots in the model-provider guide add roughly 270 KB per locale. -Images referenced from *unpublished* documents are untouched: they still resolve to GitHub raw, and still fail for a private repository. Nothing consumes them today, and a document that is not on the site has no site build to carry its assets. +Images referenced from *unpublished* documents are untouched. A text-only projection resolves them against the public source home; a document that is not on the site has no site build to carry its assets. ## Testing diff --git a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.zh.md b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.zh.md index d601112e88..a9afb138d4 100644 --- a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.zh.md +++ b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.zh.md @@ -20,7 +20,7 @@ Status: implemented `docsSourceFiles()` 会连同被安置的图片一起上报,于是替换截图时开发服务器的 watcher 会重新投影,而不是一直服务旧副本直到有人碰一下页面。 -`placeImage` 之所以可选,是因为 `rewriteMarkdown` 也被它自己的 spec 直接调用,而那里并不存在生成树。不传它时保持原有的 GitHub raw 行为,这也让该接缝保持诚实:对只改写文本的消费方而言,这个回退仍是正确答案。 +`placeImage` 之所以可选,是因为 `rewriteMarkdown` 也被它自己的 spec 直接调用,而那里并不存在生成树。不传它时,GitHub raw 回退会指向公开源主页;这让该 seam 对只改写文本的消费方保持诚实。 正本 Markdown 照旧写普通的仓库相对图片路径,因此同一份文件在 GitHub 上和站点上都能正常显示。没有任何文档为了迁就 VitePress 而写站内绝对 URL。 @@ -36,7 +36,7 @@ Status: implemented 已发布文档中的图片,现在无论谁在阅读、无论仓库是否公开都能显示,站点构建也不再为图片依赖 GitHub 的运行时可达性。生成树会为每个 locale 各增加一份被引用图片的副本——配置模型指南里的四张截图,每个 locale 约 270 KB。 -**未发布**文档引用的图片不受影响:它们仍解析到 GitHub raw,对私有仓库仍然失败。今天没有任何消费方用到它们,而不在站点上的文档也没有站点构建可以承载其资源。 +**未发布**文档引用的图片不受影响。纯文本投影会相对于公开源主页解析它们;不在站点上的文档没有站点构建可以承载其资源。 ## Testing diff --git a/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.i18n.yaml b/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.i18n.yaml new file mode 100644 index 0000000000..2a4a389174 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.md +2026-08-06-in-repository-landlock-release.md: 3ae9e9c3c50a1d0202a345e419cb2b7079e29ffa +2026-08-06-in-repository-landlock-release.zh.md: 9f2233f6ae95620d7221f83648b5dc9b4cf8c7d2 diff --git a/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.md b/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.md new file mode 100644 index 0000000000..3ae9e9c3c5 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.md @@ -0,0 +1,44 @@ +# Agent Note: In-repository Landlock release + +Status: implemented + +English | [中文](2026-08-06-in-repository-landlock-release.zh.md) + +## Problem + +The `@deepseek-ai/node-addon-landlock-run` source already lives beside its DeepSeek Harness consumers under `native/landlock-run`, but it previously kept a separate pnpm workspace and lockfile and depended on a standalone repository for npm publication. Harness packages consumed a fixed registry version, so one pull request could change the launcher contract and its consumer without testing those changes together. The source repository's native workflow could rehearse the package, but it did not publish the artifact it tested. + +The mirror also duplicated release coordination: export the source, update another lockfile, run another release workflow, publish the native family, then return to this repository to bump registry dependencies. That split made source-to-binary provenance, rollback, and security-fix coordination harder without changing what npm users actually needed. + +The existing unscoped npm names are owned by the standalone publisher account rather than the `@deepseek-ai` organization. Moving only the workflow would therefore leave publication dependent on a personal credential outside the repository's release ownership. + +The consolidation must preserve platform selection. The public distribution is deliberately one JavaScript entry package plus separate Linux x64 and arm64 binary packages; merging repository ownership does not imply putting every binary into one tarball or publishing every DeepSeek Harness package at the launcher version. + +## Decision + +`native/landlock-run` and `native/landlock-run/packages/*` belong to the repository's root pnpm workspace and use the root `pnpm-lock.yaml`. Harness consumers declare `@deepseek-ai/node-addon-landlock-run` with `workspace:*`, so development, type checking, builds, and pull-request tests resolve the entry package from the same checkout. The root TypeScript project graph builds that entry package before consumers, and the repository cleaner owns its direct `lib/` output. + +The public npm boundary is three organization-owned packages with one launcher-family version: `@deepseek-ai/node-addon-landlock-run`, `@deepseek-ai/node-addon-landlock-run-linux-x64`, and `@deepseek-ai/node-addon-landlock-run-linux-arm64`. The entry package retains both platform packages as `optionalDependencies`; their `os` and `cpu` manifest fields let npm install only the compatible package. Repository constraints allow public publication only for those three names, require `publishConfig.access: public`, and require their versions to match the private launcher workspace root. The former unscoped names are not release targets of this repository; other repository workspaces remain private under the existing constraint. + +The main repository owns both native CI and publication. `Landlock Run` runs for relevant pull requests and `master` pushes and builds each platform on its matching native runner. The manually dispatched `Landlock Run Release` workflow builds both platform binaries, transfers them as workflow artifacts, assembles and verifies the complete package family, packs immutable npm tarballs, installs and exercises those tarballs, and only then permits the protected publish job. Platform tarballs publish before the entry tarball that optionally depends on them. Publication uses `landlock-run-vX.Y.Z` tags so launcher releases cannot collide with other release families in the monorepo; prereleases use the npm `next` dist-tag. + +The sandbox packed-install rehearsal no longer permits the npm registry to supply the launcher. It packs the current checkout's entry and matching native package alongside the harness dependency closure, installs those local tarballs into an external plain-Node consumer, and proves that the installed launcher is executable, byte-identical to the native build, and the correct ELF architecture before testing confinement or fail-closed behavior. + +## Alternatives considered + +- **Keep the standalone repository as a release mirror** — rejected because it preserves the split lockfiles, source export, stale-registry test window, and cross-repository release sequence after the source of record has already moved here. +- **Publish one npm package containing every platform binary** — rejected because users would download binaries they cannot run and npm could no longer use package-level `os`/`cpu` filtering. Repository ownership and npm package layout are separate choices. +- **Give the launcher the root DeepSeek Harness version and publish the complete monorepo recursively** — rejected because this change owns one three-package public family, not the independent `@deepseek-ai/dsh-*` baseline. The [artifact-first npm baseline proposal](../../proposed/process/2026-08-04-artifact-first-npm-baseline-publication.md) explicitly keeps native workspaces outside its target set. +- **Cross-compile both binaries in one release job** — rejected because the checked-in package matrix already assigns each architecture a native GitHub runner and avoids adding a cross-toolchain trust surface. + +## Consequences + +Launcher protocol, TypeScript entry code, native source, harness consumption, and publish-path tests can change in one pull request and resolve from one lockfile. A release tag now identifies the source, consumer integration, build instructions, and tarballs tested by the main repository. The standalone mirror is no longer part of the release path and can be archived after the first successful in-repository publication. + +npm consumers install `@deepseek-ai/node-addon-landlock-run`; the old unscoped package names are not silently redirected. A supported Linux host downloads the scoped entry package and its matching architecture package; the other architecture package is skipped. An unsupported host receives no platform binary and follows the existing deterministic fail-closed probe path. + +The implementation touches more files than a dependency-line edit because the repository must also own workspace constraints, TypeScript build order, cleanup, CI triggers, release tags, lockfile generation, packed-install provenance, release documentation, and generated notices. The behavioral boundary stays narrow: it changes only the Landlock package family and its three direct workspace consumers, not the version or publication state of other DeepSeek Harness packages. + +The first scoped release must use an `@deepseek-ai` organization token through the `npm-publish` environment's `NPM_TOKEN`, because npm cannot configure trusted publishing until a package exists. After bootstrap, all three packages must authorize this repository's release workflow before the fallback token can be removed. npm still publishes packages sequentially and offers no cross-package transaction, so a failed publish can leave a partial version. Because npm rejects an already-published name and version, an operator must inspect the registry and publish only the missing tarballs rather than rerunning the workflow unchanged. Linux x64 and arm64 runners remain the authoritative binary and real-kernel checks; a macOS checkout can verify the entry package and unsupported-platform behavior but cannot replace those jobs. + +This note supersedes only the release-mirror and registry-pinned source-development statements in the [sandbox Agent Note](../feature/2026-07-06-sandbox.md); that note continues to own sandbox behavior, runner selection, and enforcement semantics. diff --git a/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.zh.md b/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.zh.md new file mode 100644 index 0000000000..9f2233f6ae --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.zh.md @@ -0,0 +1,44 @@ +# Agent Note: 仓库内 Landlock 发布 + +Status: implemented + +[English](2026-08-06-in-repository-landlock-release.md) | 中文 + +## 问题 + +`@deepseek-ai/node-addon-landlock-run` 源码已经与其 DeepSeek Harness 消费方一同位于 `native/landlock-run` 下,但此前仍保留独立的 pnpm workspace 和锁文件,并依赖一个独立仓库发布到 npm。Harness 包使用 npm 注册表中的固定版本,因此同一个 PR(Pull Request)可以同时修改启动器契约及其消费方,却无法一起测试这些改动。源码仓库的原生工作流可以演练打包流程,但不会发布它实际测试过的产物。 + +发布镜像还造成重复的发布协调工作:导出源码、更新另一份锁文件、运行另一套发布工作流、发布原生包家族,然后回到本仓库更新注册表依赖。npm 用户的实际需求并未改变,这种拆分却增加了从源码到二进制的溯源、回滚和安全修复协调难度。 + +现有的非 scoped npm 包名归独立发布账号所有,而不属于 `@deepseek-ai` 组织。因此,仅迁移工作流仍会让发布依赖仓库发布归属之外的个人凭证。 + +此次整合必须保留平台选择机制。公开分发有意采用一个 JavaScript 入口包,并为 Linux x64 和 arm64 分别提供二进制包;合并仓库归属并不意味着要把所有二进制文件放进同一个 tarball,也不意味着要按照启动器版本发布所有 DeepSeek Harness 包。 + +## 决策 + +`native/landlock-run` 和 `native/landlock-run/packages/*` 属于仓库根 pnpm workspace,并使用根 `pnpm-lock.yaml`。Harness 消费方将 `@deepseek-ai/node-addon-landlock-run` 声明为 `workspace:*`,因此开发、类型检查、构建和 PR 测试都会从同一个 checkout 解析入口包。根 TypeScript 项目图会先构建该入口包,再构建消费方;仓库清理器负责清理其直接生成的 `lib/` 输出目录。 + +公开 npm 分发边界由 3 个归组织所有的包组成,它们共用一个启动器包家族版本:`@deepseek-ai/node-addon-landlock-run`、`@deepseek-ai/node-addon-landlock-run-linux-x64` 和 `@deepseek-ai/node-addon-landlock-run-linux-arm64`。入口包继续通过 `optionalDependencies` 声明两个平台包;它们在 manifest(元数据清单)中的 `os` 和 `cpu` 字段让 npm 只安装兼容的包。仓库约束只允许公开发布这 3 个包名,要求设置 `publishConfig.access: public`,并要求其版本与私有启动器 workspace 根包一致。原先的非 scoped 包名不属于本仓库的发布目标;仓库中的其他 workspace 仍受现有约束保护,保持私有状态。 + +主仓库同时负责原生 CI 和发布。`Landlock Run` 会为相关 PR 和 `master` 推送运行,并在各自匹配的原生 runner 上构建每个平台包。手动触发的 `Landlock Run Release` 工作流会构建两个平台的二进制文件,将其作为工作流产物传递,组装并验证完整的包家族,打包出内容不可变的 npm tarball,安装并实际运行这些 tarball,之后才允许受保护的发布作业执行。发布顺序是平台 tarball 在前,最后发布将它们列为可选依赖的入口 tarball。发布使用 `landlock-run-vX.Y.Z` tag,避免启动器版本与 monorepo 中其他发布家族发生冲突;预发布版本使用 npm 的 `next` dist-tag。 + +沙箱打包安装演练不再允许 npm 注册表提供启动器。它会将当前 checkout 的入口包、匹配的原生包和 harness 依赖闭包一起打包,把这些本地 tarball 安装到仓库外部的纯 Node 消费方中,并在测试约束效果或失败闭合行为之前,证明所安装的启动器可执行、与原生构建产物字节完全一致,且具有正确的 ELF 架构。 + +## 曾考虑的替代方案 + +- **保留独立仓库作为发布镜像**:不予采纳,因为在权威源码已经迁入本仓库后,这仍会保留拆分的锁文件、源码导出、测试使用陈旧注册表版本的时间窗,以及跨仓库发布序列。 +- **发布一个包含所有平台二进制文件的 npm 包**:不予采纳,因为用户会下载无法在其主机上运行的二进制文件,而且 npm 无法再利用包级 `os`/`cpu` 筛选。仓库归属与 npm 包布局是两个彼此独立的选择。 +- **让启动器使用 DeepSeek Harness 根版本,并递归发布整个 monorepo**:不予采纳,因为本次改动负责的是一个由 3 个包组成的公开包家族,而不是独立的 `@deepseek-ai/dsh-*` 基线。[产物优先的 npm 基线提案](../../proposed/process/2026-08-04-artifact-first-npm-baseline-publication.md)明确将原生 workspace 排除在其目标集合之外。 +- **在一个发布作业中交叉编译两个二进制文件**:不予采纳,因为仓库内已提交的包矩阵已经为每种架构分配了原生 GitHub runner,无需再把交叉工具链纳入信任边界。 + +## 后果 + +同一个 PR 可以同时修改启动器协议、TypeScript 入口代码、原生源码、harness 消费方式和发布路径测试,并从同一份锁文件解析这些内容。发布 tag 现在标识源码、消费方集成、构建指令,以及主仓库测试过的 tarball。第一次成功从本仓库发布后,独立镜像便不再属于发布路径,可以归档。 + +npm 消费方改为安装 `@deepseek-ai/node-addon-landlock-run`;原先的非 scoped 包名不会被静默重定向。受支持的 Linux 主机会下载 scoped 入口包及与其架构匹配的包,并跳过另一架构的包。不受支持的主机不会收到平台二进制文件,并继续沿用现有的确定性失败闭合探测路径。 + +实现涉及的文件比只修改一行依赖更多,因为仓库还必须负责 workspace 约束、TypeScript 构建顺序、清理、CI 触发条件、发布 tag、锁文件生成、打包安装来源证明、发布文档和生成的第三方声明。行为边界仍然很窄:此次改动只影响 Landlock 包家族及其 3 个直接 workspace 消费方,不改变其他 DeepSeek Harness 包的版本或发布状态。 + +第一次发布 scoped 包时,必须通过 `npm-publish` 环境的 `NPM_TOKEN` 使用 `@deepseek-ai` 组织 token,因为 npm 只有在包已经存在后才能配置 trusted publishing。完成 bootstrap 后,必须让 3 个包都授权本仓库的发布工作流,才能移除后备 token。npm 仍会按顺序发布各个包,且不提供跨包事务,因此发布失败可能留下只完成了一部分的版本。由于 npm 会拒绝已经发布的同名同版本包,操作人员必须检查注册表并只发布缺失的 tarball,而不能原样重新运行工作流。Linux x64 和 arm64 runner 仍提供权威的二进制构建与真实内核检查;macOS checkout 可以验证入口包和不受支持平台上的行为,但不能取代这些作业。 + +本说明仅取代[沙箱 Agent Note](../feature/2026-07-06-sandbox.md)中有关发布镜像和开发源码时依赖注册表固定版本的表述;该 Agent Note 仍负责沙箱行为、runner 选择和强制执行语义。 diff --git a/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.i18n.yaml new file mode 100644 index 0000000000..dec1b79d6a --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md +2026-08-08-api-remotes-generated-contract-build.md: 947465b19a7c399038ae8a3106f7563592365a8d +2026-08-08-api-remotes-generated-contract-build.zh.md: 7b559cd966c4acc055d41379c15f46fdfc649a00 diff --git a/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md new file mode 100644 index 0000000000..947465b19a --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md @@ -0,0 +1,80 @@ +# Agent Note: Ordered Build for API Remotes Generated Contracts + +Status: implemented + +English | [中文](2026-08-08-api-remotes-generated-contract-build.zh.md) + +## Problem + +TypeRT must generate `/remote` declarations and runtime contributions from the Host's `@Remote` methods before the Client's `api-remotes/src/client/index.ts` can typecheck and bundle those contributions. If the root build hands both the Host and Client Project Reference graphs to tsc together, the Client compiles before the generated artifacts exist. Adding a separate contracts preprocessing step would instead compile the generator again outside the normal Host graph and let stale artifacts hide incorrect dependencies. + +This ordering dependency must not change the repository's ordinary package rule. A normal package belongs to exactly one TypeScript face: Host packages are registered in `tsconfig.host.json`, and Client packages in `tsconfig.client.json`. A Client plugin having both a Node loader entry and a browser entry describes its bundled artifact shapes, not a reason to split its TypeScript project. + +## Decision + +The root build completes Host tsc and Host tsdown first, with Host tsdown running TypeRT and generating the Remote Client contract. It then completes Client tsc, Client tsdown, and the Web build: + +~~~text +tsc -b tsconfig.host.json +tsdown --env.DSH_BUILD_FACE host +tsc -b tsconfig.client.json +tsdown --env.DSH_BUILD_FACE client +Vite Web build +~~~ + +`build:lib:host` owns the first two steps, `build:lib:client` owns the middle two, and `build:web` runs last. `typecheck` must also run the complete Host lib phase first because Client tsc requires declarations generated by Host tsdown; it does not need Client tsdown or the Web build. + +Each tsc phase is the sole TypeScript compiler path and emits JavaScript, declarations, and incremental state to `lib/types`. Tsdown reads only that JavaScript and produces published bundles; it neither reads source nor emits declarations. + +## The sole package exception + +`api/remotes` is the only package with both Host and Client composite projects. The Host project contains the Agent/Session lookup policy, Host plugin entry, and invariant; the Client project contains only `src/client/index.ts`, which must wait for the generated contract: + +~~~text +packages/api/remotes/ +├─ tsconfig.json +├─ tsconfig.host.json +├─ tsconfig.client.json +└─ src/ + ├─ index.ts + ├─ agent-lookup.ts + ├─ invariant.ts + └─ client/ + └─ index.ts +~~~ + +The package-root `tsconfig.json` is a solution that only references the two concrete projects; it enters neither aggregate nor any direct consumer's dependency graph. The root Host aggregate and `host/apiproxy` reference `api/remotes/tsconfig.host.json`, while the root Client aggregate and `client/ui-goal` reference `api/remotes/tsconfig.client.json`. `ui-goal` itself remains an ordinary single Client project. The workspace constraints gate walks the reachable Project Reference graph and rejects any face-declared project that references a split package's solution root or opposite leaf; targets with only `tsconfig.json` remain valid from either face. + +The two projects use disjoint `files` and separate `.tsbuildinfo` files, so they can share `lib/types` without emitting any source file twice. If both sides later need a shared implementation, move that implementation into a neutral package instead of giving the same source to two emitting projects. + +This exception follows from the real generated-contract ordering and is not a template available to ordinary packages. New packages remain restricted to one aggregate; adding another exception requires changing this decision and proving another generated dependency that cannot be eliminated. + +## TypeRT and tsdown + +Host tsdown enables `typertPlugin({ mode: 'workspace', faces: ['host'] })` in the normal root config. The generator uses only `tsconfig.host.json` as its program seed and produces both `typert.host.*` and the `typert.remote-client.*` projection of Host contracts; Client tsdown neither starts TypeRT nor analyzes the Client aggregate. + +The TypeRT analyzer distinguishes compiler faces from runtime faces. Direct Project References in the aggregate determine which compiler face analyzes a project; only a split project explicitly referenced through `tsconfig.host.json` or `tsconfig.client.json` is restricted to that corresponding face. Runtime models follow package subpath contributions instead, so an ordinary single-project `dshClient` package may contribute both Host and Client runtime models. Consequently, Host analysis of `api-remotes` does not also register its Client entry, while an ordinary dual-entry package does not lose its Host model. + +Both the Host and Client tsdown passes receive the same complete workspace of `vendor/*`, `packages/*/*`, and `apps/cli`. The root config does not scan `lib/types/client/index.js`, maintain a package classification table, or use a tsdown filter; package-local configs return entries for the current phase according to `DSH_BUILD_FACE`. + +An ordinary Client plugin returns an empty config during the Host pass and produces both its Node loader entry and browser bundle during the Client pass. The `clientBundle(..., { hostPhase: true })` used by `api-remotes` is the only phase exception: the Host pass produces its Host entry, and the Client pass produces only its browser bundle. Package-local tsdown without `DSH_BUILD_FACE` still returns that package's normal entries together for local single-package development. + +## Alternatives considered + +**Keep a separate contracts preprocessing step.** This would compile the generator again outside the normal Host Project Reference graph and let residual generated artifacts hide the Client entering the Host graph too early. + +**Run the root `tsc -b tsconfig.json` once before tsdown.** Client tsc would run before Host tsdown and could not obtain `/remote` declarations from a clean worktree. + +**Split every package containing `src/client/index.ts`.** Separate Node and browser entries are the normal Client plugin bundling convention and do not create a compilation ordering dependency; splitting them universally would only increase the maintenance cost of references and incremental state. + +**Scan Client compilation artifacts or maintain two workspace lists.** Artifact scanning would make package participation depend on residual files, while hand-maintained lists and package-name filters would drift as directories change. A complete workspace with package-local face selection already provides deterministic behavior. + +**Run TypeRT again during the Client pass.** Remote Client is a projection of the Host contract and has no independent Client reflection source; a second TypeRT program would only duplicate work and increase the risk of mixing both sides' declarations into one analysis. + +## Consequences + +A clean build is the authoritative check of ordering correctness: with no existing `/remote` artifacts, Host tsc must succeed first, Host tsdown must generate the contract, and then Client tsc, Client tsdown, and the Web build must succeed. No phase may write artifacts into `src`. + +The tsc-first ownership established by the [TypeScript build config note](2026-06-17-ts-build-config.md) remains unchanged, but this note replaces its command shape of one whole-graph tsc pass followed by bundling with ordered phases. The ordinary-package single-aggregate rule established by the [two-aggregate solution note](2026-07-22-tsconfig-solution-root-two-aggregates.md) also remains unchanged; this note creates one explicit exception for `api/remotes`. + +An independent Client build is no longer a self-contained entry on a clean worktree; repository commands, CI, and release flows must run the Host lib phase first. Developers of ordinary packages do not need to understand or copy this exception and continue to choose one aggregate according to the package's runtime environment. diff --git a/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.zh.md b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.zh.md new file mode 100644 index 0000000000..7b559cd966 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.zh.md @@ -0,0 +1,80 @@ +# Agent Note: API Remotes 生成契约的有序构建 + +Status: implemented + +[English](2026-08-08-api-remotes-generated-contract-build.md) | 中文 + +## 问题 + +Host 的 `@Remote` 方法需要先由 TypeRT 生成 `/remote` 声明和运行时贡献,Client 的 `api-remotes/src/client/index.ts` 才能通过类型检查并打包这些贡献。若根构建先把 Host 与 Client 两张 Project Reference 图一起交给 tsc,Client 会在生成产物存在之前编译;若增加独立 contracts 预处理,又会让 generator 脱离正常 Host 图重复编译,并允许陈旧产物掩盖错误依赖。 + +该顺序依赖不能改变仓库的普通 package 规则。正常 package 只属于一个 TypeScript face:Host package 登记在 `tsconfig.host.json`,Client package 登记在 `tsconfig.client.json`。一个 Client plugin 同时具有 Node loader 入口与 browser 入口,只是打包产物形态,不是拆分 TypeScript project 的理由。 + +## 决策 + +根构建先完成 Host tsc 和 Host tsdown,由 Host tsdown 运行 TypeRT 并生成 Remote Client 契约;随后完成 Client tsc、Client tsdown 和 Web 构建: + +~~~text +tsc -b tsconfig.host.json +tsdown --env.DSH_BUILD_FACE host +tsc -b tsconfig.client.json +tsdown --env.DSH_BUILD_FACE client +Vite Web build +~~~ + +`build:lib:host` 负责前两步,`build:lib:client` 负责中间两步,`build:web` 最后运行。`typecheck` 也必须先执行完整 Host lib 阶段,因为 Client tsc 需要 Host tsdown 生成的声明;它不需要运行 Client tsdown 或 Web build。 + +每个 tsc 阶段都是唯一的 TypeScript 编译器路径,负责向 `lib/types` 发射 JavaScript、声明和增量状态。tsdown 只读取这些 JavaScript 并生成发布 bundle,不读取源码,也不生成声明。 + +## 唯一的 package 特例 + +`api/remotes` 是唯一同时拥有 Host 与 Client composite project 的 package。Host project 包含 Agent/Session lookup 策略、Host 插件入口和 invariant;Client project 只包含需要等待生成契约的 `src/client/index.ts`: + +~~~text +packages/api/remotes/ +├─ tsconfig.json +├─ tsconfig.host.json +├─ tsconfig.client.json +└─ src/ + ├─ index.ts + ├─ agent-lookup.ts + ├─ invariant.ts + └─ client/ + └─ index.ts +~~~ + +包根 `tsconfig.json` 是只引用两个具体 project 的 solution,不进入任何 aggregate 或直接消费方的依赖图。根 Host aggregate 与 `host/apiproxy` 引用 `api/remotes/tsconfig.host.json`;根 Client aggregate 与 `client/ui-goal` 引用 `api/remotes/tsconfig.client.json`。`ui-goal` 本身仍是普通的单一 Client project。workspace constraints 门禁遍历可达的 Project Reference 图;凡已声明 face 的 project 引用了拆分包的 solution 根或另一侧 leaf,门禁都会拒绝,而只有 `tsconfig.json` 的目标仍可由任一 face 引用。 + +两个 project 使用互不重叠的 `files` 和不同的 `.tsbuildinfo`,因此可以共享 `lib/types` 而不重复发射任何源码。若未来需要两侧共用一份实现,应把实现移入中立 package,不能把同一源码同时交给两个 emitting project。 + +这个例外由生成契约的真实先后关系决定,不是可供普通 package 选择的模板。新增 package 仍只能登记进一个 aggregate;只有修改本决策并证明存在另一条不可消除的生成依赖,才能增加例外。 + +## TypeRT 与 tsdown + +Host tsdown 在普通根配置中启用 `typertPlugin({ mode: 'workspace', faces: ['host'] })`。generator 只以 `tsconfig.host.json` 为 program 种子,生成 `typert.host.*` 以及 Host 契约投影出的 `typert.remote-client.*`;Client tsdown 不启动 TypeRT,也不分析 Client aggregate。 + +TypeScript compiler face 与 TypeRT 运行时产物 face 是两层概念。普通 `dshClient` package 即使只有一个 compiler project,也可以按公开 subpath 同时贡献 Host 与 Client 运行时模型;aggregate 显式引用 `tsconfig.host.json` 或 `tsconfig.client.json` 时,analyzer 才把该 project 限定到对应 face。因此 `api-remotes` 的 Host 分析不会顺带注册其 Client 入口,普通双入口 package 的 Host 模型也不会丢失。 + +Host 与 Client 两次 tsdown 都接收 `vendor/*`、`packages/*/*` 和 `apps/cli` 这组完整 workspace。根配置不扫描 `lib/types/client/index.js`,不维护 package 分类表,也不使用 tsdown filter;包内配置根据 `DSH_BUILD_FACE` 返回本阶段入口。 + +普通 Client plugin 在 Host pass 返回空配置,在 Client pass 同时生成 Node loader 入口与 browser bundle。`api-remotes` 的 `clientBundle(..., { hostPhase: true })` 是唯一阶段例外:Host pass 生成其 Host 入口,Client pass 只生成 browser bundle。未指定 `DSH_BUILD_FACE` 的 package-local tsdown 仍同时返回该 package 的正常入口,供本地单包开发使用。 + +## 考虑过的替代方案 + +**保留独立 contracts 预处理。** 这会在正常 Host Project Reference 图之外额外编译 generator,并让残留生成物掩盖 Client 过早进入 Host 图的问题。 + +**一次执行根 `tsc -b tsconfig.json` 后再运行 tsdown。** Client tsc 在 Host tsdown 之前发生,无法从干净工作树获得 `/remote` 声明。 + +**拆分所有包含 `src/client/index.ts` 的 package。** Node 与 browser 双入口是普通 Client plugin 的打包约定,不形成编译顺序依赖;普遍拆分只会增加 references 和增量状态的维护成本。 + +**扫描 Client 编译产物或维护两份 workspace 清单。** 产物扫描会让 package 是否参与构建取决于残留文件,手工清单和 package 名过滤则会随目录调整产生漂移。完整 workspace 加包内 face 选择已经提供确定行为。 + +**在 Client pass 再运行 TypeRT。** Remote Client 是 Host 契约的投影,没有独立 Client 反射源;第二个 TypeRT program 只会重复工作并增加两侧声明混入同一分析的风险。 + +## 后果 + +干净构建成为顺序正确性的权威验证:没有任何既存 `/remote` 产物时,Host tsc 必须先成功,Host tsdown 必须生成契约,随后 Client tsc、Client tsdown 与 Web build 必须成功。任何阶段都不得把产物写进 `src`。 + +[TypeScript 构建配置 Note](2026-06-17-ts-build-config.md)确定的 tsc-first 职责保持不变,但其单次全图 tsc 后再打包的命令形态由本文的有序阶段取代。[双 aggregate solution Note](2026-07-22-tsconfig-solution-root-two-aggregates.md)确定的普通 package 单 aggregate 规则保持不变,本文只为 `api/remotes` 建立一个显式例外。 + +Client 的独立构建不再是干净工作树上的自足入口;仓库命令、CI 和发布流程必须先运行 Host lib 阶段。普通 package 的开发者无需理解或复制该例外,仍按所属运行环境选择一个 aggregate。 diff --git a/.agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.i18n.yaml new file mode 100644 index 0000000000..a82d54640c --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.md +2026-08-08-review-driven-issue-lifecycle-triggers.md: 8a2d48ee23da4c20bb832ae0109e2ea9912dac83 +2026-08-08-review-driven-issue-lifecycle-triggers.zh.md: 004739ff471815b0fe12e111eba0ec7aaaef9507 diff --git a/.agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.md b/.agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.md new file mode 100644 index 0000000000..8a2d48ee23 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.md @@ -0,0 +1,31 @@ +# Agent Note: Review-driven Issue lifecycle triggers + +Status: implemented + +English | [中文](2026-08-08-review-driven-issue-lifecycle-triggers.zh.md) + +## Problem + +The Issue lifecycle workflow reads the current pull request after each subscribed repository event and projects resolving Issues forward to `In progress` or `In review`. A resolving draft already reaches `In progress` from its `opened` event. Changing that draft to ready creates no new lifecycle outcome until a reviewer is requested or submits a review, yet subscribing to `ready_for_review` launches another hosted job and creates another GitHub App token. + +Draft-to-ready automation commonly submits a review moments later. In that sequence the ready job cannot advance the Issue, while the review job is still required to observe the `In review` phase. + +## Decision + +[Issue lifecycle](../../../../.github/workflows/issue-lifecycle.yml) does not subscribe to `pull_request.ready_for_review`. It retains `pull_request.review_requested` and `pull_request_review.submitted`, so either a requested reviewer or a submitted review can advance a resolving Issue to `In review`. The handler continues to fetch the live pull request instead of deriving phase from the triggering payload. + +[Issue policy](../../../../.github/workflows/issue-policy.yml) still subscribes to `ready_for_review`. That workflow owns the required check when a human pull request enters review; removing a lifecycle trigger does not weaken policy enforcement. + +The workflow test parses both files and pins this split. The lifecycle policy tests separately pin that draft and open resolving pull requests reach `In progress`, while a review request or submitted review reaches `In review`. + +## Alternatives considered + +- **Keep both events and cancel an in-progress run** - rejected because concurrency can discard a pending run but cannot combine two webhook payloads into one execution. Cancelling the earlier mutation also makes correctness depend on arrival order, while a completed ready job still consumes the full runner setup. +- **Remove the submitted-review event** - rejected because a review may arrive without an explicit review request. In that path `pull_request_review.submitted` is the only repository event that exposes the transition to `In review`. +- **Delay every pull request event behind a debounce dispatcher** - rejected because another queue or scheduled workflow adds latency and control-plane state to eliminate a trigger that carries no lifecycle information. + +## Consequences + +A draft becoming ready no longer launches Issue lifecycle work. The resolving Issue remains `In progress` from an earlier pull request event until a review is requested or submitted, at which point one review-driven run can advance it to `In review`. The required Issue policy check still runs at the ready boundary. + +If a future lifecycle phase depends on ready status itself, that change must restore the trigger and update the workflow test and this decision. Until then, omitting `ready_for_review` saves one hosted run from the common ready-then-review sequence without dropping a status transition. diff --git a/.agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.zh.md b/.agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.zh.md new file mode 100644 index 0000000000..004739ff47 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 由评审驱动的 Issue 生命周期触发器 + +Status: implemented + +[English](2026-08-08-review-driven-issue-lifecycle-triggers.md) | 中文 + +## 问题 + +Issue 生命周期工作流会在每个已订阅的仓库事件发生后读取当前 PR(Pull Request),并将解决型 Issue 的状态向前推进到 `In progress` 或 `In review`。解决型草稿 PR 已通过其 `opened` 事件进入 `In progress`。在请求评审人或评审人提交评审之前,把该草稿转为可评审状态不会产生新的生命周期结果;但订阅 `ready_for_review` 仍会启动另一个托管作业,并创建另一个 GitHub App token。 + +草稿转为可评审状态的自动化通常会在片刻后提交评审。在这一事件序列中,转为可评审状态的作业无法推进 Issue,而要观察到 `In review` 阶段,仍必须运行评审作业。 + +## 决策 + +[Issue 生命周期](../../../../.github/workflows/issue-lifecycle.yml)不订阅 `pull_request.ready_for_review`。它保留 `pull_request.review_requested` 和 `pull_request_review.submitted`,因此无论是请求评审人还是提交评审,都可以将解决型 Issue 推进至 `In review`。处理程序仍会获取实时 PR,而不是根据触发事件的载荷推导阶段。 + +[Issue 政策](../../../../.github/workflows/issue-policy.yml)仍订阅 `ready_for_review`。该工作流负责在由人类发起的 PR 进入评审时执行必需检查;移除生命周期触发器不会削弱政策执行。 + +工作流测试会解析这两个文件,并固定这种划分。生命周期政策测试另行固定以下行为:草稿及开放状态的解决型 PR 会进入 `In progress`,评审请求或已提交评审则会使其进入 `In review`。 + +## 考虑过的替代方案 + +- **保留两个事件并取消正在进行的工作流运行**:不予采纳,因为并发控制可以丢弃待处理的工作流运行,却无法把两个 webhook 载荷合并为一次执行。取消较早的状态变更操作也会使正确性依赖事件到达顺序;而已经完成的转为可评审状态作业仍会产生完整的运行器初始化开销。 +- **移除已提交评审事件**:不予采纳,因为评审可能在没有明确评审请求的情况下直接提交。在这条路径中,`pull_request_review.submitted` 是唯一能让系统观察到进入 `In review` 这一状态转换的仓库事件。 +- **让每个 PR 事件都先经过防抖分派器再处理**:不予采纳,因为新增一条队列或一个定时工作流会引入延迟和控制平面状态,只为消除一个不携带生命周期信息的触发器。 + +## 后果 + +草稿转为可评审状态后,不再启动 Issue 生命周期工作。解决型 Issue 会保持在更早的 PR 事件所设定的 `In progress`,直到请求或提交评审;届时,一次由评审驱动的工作流运行即可将其推进至 `In review`。必需的 Issue 政策检查仍会在转为可评审状态的边界运行。 + +如果未来某个生命周期阶段依赖可评审状态本身,相关变更必须恢复该触发器,并更新工作流测试和本决策。在此之前,省略 `ready_for_review` 可使常见的先转为可评审状态、再提交评审这一序列少启动一次托管工作流运行,而不会遗漏状态转换。 diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml index 463285cfb1..a21ac5cfe9 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md -2026-07-20-remove-stdio-and-echo-agents.md: 1c7ff4b5337341ea7678c1455dd7bbd3365df6ba -2026-07-20-remove-stdio-and-echo-agents.zh.md: 67445d25f6a0534c92c50568da898a5bffc73a52 +2026-07-20-remove-stdio-and-echo-agents.md: 256e626f4ff41016d0227eef7cc3e4e51e15058b +2026-07-20-remove-stdio-and-echo-agents.zh.md: bb7a2abd8be73ac3ea089121592b3f26162bf619 diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md index 1c7ff4b533..256e626f4f 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md @@ -19,18 +19,18 @@ The stdio and Echo agents are removed without compatibility packages, modes, com The remaining application roles are explicit: - `@deepseek-ai/dsh-tui` owns terminal-interactive execution. It rejects non-TTY streams before Loader boot; `apps/cli/config/base.cordis.yml` plus the `tui.cordis.yml` overlay own the complete coding composition, with PTY plus terminal-snapshot coverage in `apps/cli/tests/`. -- [`@deepseek-ai/dsh-cli-demo`](../../../../packages/examples/cli-demo/README.md) owns non-interactive execution, including pipes. `examples/headless-agent` owns the real-model one-shot composition, replay snapshots, generic real-agent suites, and test-only keyless Loader fixtures. +- [`dsh run`](../../../../apps/cli/README.md) owns non-interactive execution. Its `headless` profile is the product composition; `examples/headless-agent` owns replay snapshots, generic real-agent suites, and an unexported keyless Loader driver. - [`@deepseek-ai/dsh-acp-demo`](../../../../packages/examples/acp-demo/README.md) and `@deepseek-ai/dsh-jsonrpc` own their framed protocol integrations. The SDK project model and create/config workflows replace the `stdio` run-interface option with `tui`; generated TUI projects compose `@deepseek-ai/dsh-tui` and create or resume one exact session. Repository-facing demo documentation requires a DeepSeek API key and leads with the real Headless or TUI agents. -Keyless validation is test-owned. The Headless Loader smoke uses a fixture adapter to exercise a real tool round trip, the CLI built-bin suite pins output, persistence, failure, and signal semantics, and package-specific Loader tests keep deterministic adapters beside their scenarios. None is exposed as a runnable mock agent. +Keyless validation is test-owned. The Headless Loader smoke uses a fixture adapter to exercise a real tool round trip, the `dsh` built-bin suite pins the published one-shot entry and output, the product Headless snapshot pins persistence, and the Headless PTY shutdown e2e pins signal escalation. Package-specific Loader tests keep deterministic adapters beside their scenarios. None is exposed as a runnable mock agent. ## Verification TUI and Headless Loader coverage run the real app packages in source and built modes. PTY-driven subprocess coverage is reserved for the TUI lifecycle; other entry-point smokes use the one-shot pipe protocol. Headless proves its task/result and tool-call contracts. Generated graphs and repository searches reject stale package, command, leaf, SDK-interface, `createStdioChat`, and `StdioRuntime` references. -The built `dsh` bin rejects a piped TUI launch before Loader boot and points at its one-shot `-p` mode; `apps/cli/tests/built-bin.e2e.ts` pins that path, while `cli-demo`'s built-bin suite runs text, JSON, and structurally parsed `stream-json` output under plain Node, persists fresh sessions, and rejects invalid arguments and missing config without contaminating stdout. Code Mode has programmatic TUI snapshots and an ACP overlay demo. Time-context integration uses the real Headless composition for two ordered turns, while its package tests own finer elapsed-time behavior. +The built `dsh` bin rejects a piped TUI launch before Loader boot and points at `dsh run`; `apps/cli/tests/built-bin.e2e.ts` pins the product one-shot entry under plain Node, including output and invalid arguments. `examples/headless-agent/tests/headless.snapshot.ts` pins product persistence, while `apps/cli/tests/headless-shutdown.e2e.ts` owns bounded signal escalation. The headless example's test-only JSONL driver preserves assembled canonical-event snapshots without creating a second CLI contract. Code Mode has programmatic TUI snapshots and an ACP overlay demo. Time-context integration uses the explicit Headless test composition for two ordered turns, while its package tests own finer elapsed-time behavior. ## Alternatives considered diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md index 67445d25f6..bb7a2abd8b 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md @@ -19,18 +19,18 @@ DeepSeek Harness 在 TUI 和 Headless coding agent 之外,还提供了两个 保留的应用角色均有明确归属: - `@deepseek-ai/dsh-tui` 负责终端交互式执行。它会在 Loader 启动前拒绝非 TTY 流;`apps/cli/config/base.cordis.yml` 与 `tui.cordis.yml` overlay 拥有完整 coding 组装,PTY 与终端快照覆盖则位于 `apps/cli/tests/`。 -- [`@deepseek-ai/dsh-cli-demo`](../../../../packages/examples/cli-demo/README.md) 负责非交互式执行,包括管道方式。`examples/headless-agent` 拥有真实模型的单次任务组装、回放快照、通用真实 agent 测试套件,以及仅供测试使用的无密钥 Loader fixture。 +- [`dsh run`](../../../../apps/cli/README.md) 负责非交互式执行。其 `headless` profile 是产品组装;`examples/headless-agent` 负责回放快照、通用真实 agent 测试套件和未导出的无密钥 Loader driver。 - [`@deepseek-ai/dsh-acp-demo`](../../../../packages/examples/acp-demo/README.md) 和 `@deepseek-ai/dsh-jsonrpc` 负责各自的分帧协议集成。 SDK 工程模型与 create/config 工作流将 `stdio` 运行接口选项替换为 `tui`;生成的 TUI 工程组合 `@deepseek-ai/dsh-tui`,并创建或恢复一个确切会话。仓库中的演示文档要求 DeepSeek API key,并优先引导到真实的 Headless 或 TUI agent。 -无密钥验证由测试负责。Headless Loader 冒烟测试使用 fixture 适配器验证真实工具往返;CLI built-bin 测试套件固定输出、持久化、失败和信号语义;各包专属的 Loader 测试则将确定性适配器放在对应场景旁。其中任何一项都不会作为可运行的 mock agent 对外暴露。 +无密钥验证由测试负责。Headless Loader 冒烟测试使用 fixture 适配器验证真实工具往返;`dsh` built-bin 测试套件固定已发布的一次性入口和输出;产品 Headless 快照固定持久化;Headless PTY 关闭 e2e 固定信号升级。各包专属的 Loader 测试则将确定性适配器放在对应场景旁。其中任何一项都不会作为可运行的 mock agent 对外暴露。 ## 验证 TUI 与 Headless 的 Loader 覆盖以源码和构建产物两种模式运行真实 app 包。由 PTY 驱动的子进程覆盖仅用于 TUI 生命周期;其他入口冒烟测试使用单次管道协议。Headless 验证任务/结果契约和工具调用契约。生成图谱与仓库搜索会拒绝陈旧的包、命令、叶节点、SDK 接口、`createStdioChat` 和 `StdioRuntime` 引用。 -构建后的 `dsh` 可执行文件会在 Loader 启动前拒绝通过管道启动 TUI,并指向其单次 `-p` 模式;`apps/cli/tests/built-bin.e2e.ts` 固定了该执行路径,而 `cli-demo` 的 built-bin 套件在普通 Node 下运行文本、JSON 和经过结构化解析的 `stream-json` 输出,持久化新建会话,并在不污染 stdout 的情况下拒绝无效参数和缺失配置。Code Mode 由程序化 TUI 快照与 ACP overlay demo 覆盖。时间上下文集成通过真实 Headless 组装执行两个有序轮次,而更细粒度的耗时行为由时间上下文的包级测试负责。 +构建后的 `dsh` 可执行文件会在 Loader 启动前拒绝通过管道启动 TUI,并指向 `dsh run`;`apps/cli/tests/built-bin.e2e.ts` 在普通 Node 下固定产品的一次性入口,包括输出和无效参数。`examples/headless-agent/tests/headless.snapshot.ts` 固定产品持久化,`apps/cli/tests/headless-shutdown.e2e.ts` 则负责有界信号升级。headless 示例仅供测试的 JSONL driver 保留组装后的规范事件快照,而不会创建第二套 CLI(命令行界面)契约。Code Mode 由程序化 TUI 快照与 ACP overlay demo 覆盖。时间上下文集成通过显式的 Headless 测试组装执行两个有序轮次,而更细粒度的耗时行为由时间上下文的包级测试负责。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.i18n.yaml index a570637be3..45d89e4af1 100644 --- a/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.md -2026-07-23-collapse-persistence-flush-state.md: a9b0f6847712f47d46adb6b01c57563033738964 -2026-07-23-collapse-persistence-flush-state.zh.md: e665d164fb66b2bce97a4f6ded88f2ee07324e61 +2026-07-23-collapse-persistence-flush-state.md: 2801079e5b322d076eced3a0113e7958d9b4c3b9 +2026-07-23-collapse-persistence-flush-state.zh.md: dfc8fc9576416b1f51f4f9c978f49aee4f39f938 diff --git a/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.md b/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.md index a9b0f68477..2801079e5b 100644 --- a/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.md +++ b/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.md @@ -4,15 +4,17 @@ Status: implemented English | [中文](2026-07-23-collapse-persistence-flush-state.zh.md) +The [bounded write-batching decision](../architecture/2026-08-08-bounded-session-persistence-write-batching.md) supersedes this note's immediate scheduling cadence. The single-controller ownership, failure retention, per-id serialization, retirement, and quiescent-disposal decisions remain current. + ## Problem The persistence coordinator represented one live session's write lifecycle with separate buffer, initialization, and retirement containers plus the per-id operation chain. Those structures mirrored the same fact: whether that exact `Session` still had initialization or events that must settle before its state could be released. The checkpoint-only drain also kept every event volatile until another plugin requested `session/flush`, even though the backend could begin durability work without blocking the synchronous producer. ## Decision -Each live `Session` has one controller containing `pending`, `init`, and the optional current `flush` promise. A `session/event` listener copies the frozen event into `pending` and immediately schedules `ensureFlush()`. Calls during an active write reuse the same promise. The drain snapshots one stable pending prefix and removes it only after `appendBatch` commits; events admitted during the write remain after that prefix and schedule one follow-up batch. +Each live `Session` has one lifecycle entry containing initialization and one package-private write controller. The controller owns `pending`, the fixed batching timer, the optional active write, automatic-retry pause, and the shared flush barrier. A `session/event` listener copies the frozen event into `pending`; the first event starts a fixed deadline, and later events join without resetting it. A write takes one stable pending prefix; events admitted while it runs remain pending for a separately bounded follow-up batch. -`session/flush` is an observation barrier. It waits for initialization and repeatedly awaits or starts the controller's flush until neither a current promise nor pending events remain. An eager failure is logged without rejecting the synchronous event producer, retains the complete batch, and is retried by the next explicit flush, retirement attempt, or backend teardown. Explicit flush and teardown still surface the failure if that retry rejects. +`session/flush` is an immediate quiescence barrier. It waits for initialization, cancels the batching timer, joins any active attempt, and drains pending events, including events admitted while the barrier runs. A background failure is logged without rejecting the synchronous event producer, restores the complete ordered batch, and pauses automatic retry. A new event starts a fresh fixed window; explicit flush, retirement, or backend teardown retries immediately and surfaces a repeated failure. Initialization now enters the existing per-id operation chain once and calls the unserialized core operations while it owns that turn. The chain remains separate from the live controller because detached public `create`/`append`/`load` calls can race without a `Session` object and still require identity-level serialization. @@ -22,17 +24,17 @@ The live-controller map is also the retirement registry. Successful retirement d ## Alternatives considered -**Keep checkpoint-only write-behind.** This can form larger batches, but makes durability depend on a separately mounted checkpoint policy and maximizes the crash-loss window between checkpoints. Eager scheduling still coalesces synchronous bursts and events arriving during an active write. +**Keep checkpoint-only write-behind.** This can form larger batches, but makes durability depend on a separately mounted checkpoint policy and maximizes the crash-loss window between checkpoints. Bounded background scheduling still coalesces bursts and persists progress between mandatory barriers. **Use one coordinator-wide flush promise.** The attachment pattern works for one file, but a global promise would serialize unrelated sessions. One controller per live session preserves independent backend progress while the per-id chain protects same-identity operations. -**Latch the first eager error permanently.** This makes every later flush deterministic, but prevents the existing teardown retry from recovering a transient storage failure. Retaining the batch without latching the error preserves both observability and retry. +**Latch the first background error permanently.** This makes every later flush deterministic, but prevents the existing teardown retry from recovering a transient storage failure. Retaining the batch without latching the error preserves both observability and retry. **Reject every live load.** This is safe but removes established balanced live snapshots used by persistence consumers and tests. Snapshot-before-flush gives the call a stable linearization point: successful flush proves exactly that snapshot is durable, while the live path never invokes crash repair. ## Verification -- A focused coordinator test gates the first append, admits another event during that write, and observes an automatic second durable batch without calling `session/flush`. +- Focused controller tests use a fake clock to prove the non-resetting fixed window, gate the first append, admit another event during that write, and observe an automatic second durable batch without calling `session/flush`. - The shared coordinator contract still covers live adoption, collisions, crash repair, and session/backend disposal over the in-memory, JSONL, and SQLite backends. - Failure and teardown tests keep rejected batches pending, retry them before close, and prove an in-flight controller delays backend close. - The shared backend contract persists an open live turn, proves `load` rejects without writing synthetic closers, completes and retires the owner, then reloads the exact completed turn. @@ -42,6 +44,6 @@ The live-controller map is also the retirement registry. Successful retirement d ## Consequences -The coordinator has three long-lived containers: persisted identity state, live-session controllers, and per-id operation chains. Eager writes reduce the ordinary crash-loss window and remove separate buffer, initialization, and retirement registries. They can produce more backend batches than checkpoint-only draining; same-tick bursts and events admitted during one write still coalesce. +The live-session entry keeps initialization beside one controller for pending events, its timer, active write, retry pause, and flush barrier. Separate coordinator containers retain persisted identity state, prepared cold Sessions, retiring identity waiters, and per-id operation chains because those lifecycles also exist without one writable live Session. Bounded writes reduce the ordinary crash-loss window and produce fewer backend batches than immediate scheduling, while mandatory barriers remain unchanged. `session/flush` no longer chooses when ordinary persistence begins. It remains the ordering and error-observation boundary used by the loop and checkpoint policy, so a successful checkpoint still means every event admitted before its completion is durable. diff --git a/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.zh.md b/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.zh.md index e665d164fb..dfc8fc9576 100644 --- a/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.zh.md @@ -4,15 +4,17 @@ Status: implemented [English](2026-07-23-collapse-persistence-flush-state.md) | 中文 +[有界写入批处理决策](../architecture/2026-08-08-bounded-session-persistence-write-batching.md)取代了本 Agent Note 中的即时调度节奏。单控制器归属、失败保留、按 id 串行化、退役和完全停稳的资源释放决策仍然有效。 + ## 问题 持久化协调器使用彼此独立的缓冲区、初始化容器和退役容器,以及按 id 划分的操作链,表示一个活跃会话的写入生命周期。这些结构反映的是同一个事实:该 `Session` 是否仍有初始化操作或事件必须完成,之后才能释放其状态。仅由检查点触发的排空还会让每个事件都停留在易失状态,直至另一个插件请求 `session/flush`,尽管后端可以在不阻塞同步生产方的情况下开始持久化工作。 ## 决策 -每个活跃的 `Session` 都有一个控制器,其中包含 `pending`、`init` 和可选的当前 `flush` promise。`session/event` 监听器将冻结的事件复制到 `pending`,并立即调度 `ensureFlush()`。活跃写入期间的调用复用同一个 promise。排空操作会对待处理事件中一个稳定的前缀生成快照,并且只在 `appendBatch` 提交后移除该前缀;写入期间接纳的事件保留在该前缀之后,并调度一个后续批次。 +每个活跃的 `Session` 都有一个生命周期条目,其中包含初始化和一个包私有写入控制器。该控制器负责 `pending`、固定批处理计时器、可选的活跃写入、自动重试暂停和共享 flush 屏障。`session/event` 监听器将冻结的事件复制到 `pending`;第一个事件设定固定截止时间,后续事件加入但不会重置。一次写入会取出一个稳定的待处理前缀;写入期间接纳的事件仍留在待处理队列中,并形成另一个独立有界的后续批次。 -`session/flush` 是观测屏障。它等待初始化完成,并反复等待或启动控制器的刷新,直至当前 promise 和待处理事件均不存在。即时写入失败会被记录,但不会拒绝同步事件生产方;完整批次会保留下来,由下一次显式刷新、退役尝试或后端资源销毁重试。若该次重试仍失败,显式刷新和资源销毁仍会向调用方暴露失败。 +`session/flush` 是即时完全停稳屏障。它等待初始化完成、取消批处理计时器、等待任何活跃尝试完成,并排空待处理事件,包括屏障运行期间接纳的事件。后台写入失败会被记录,但不会拒绝同步事件生产方;系统会恢复完整且顺序不变的批次,并暂停自动重试。新事件会开启新的固定窗口;显式 flush、退役或后端资源销毁会立即重试,并在失败再次发生时向调用方暴露失败。 初始化只进入现有的按 id 操作链一次,并在占有该轮执行权时调用未串行化的核心操作。该操作链与活跃控制器保持分离,因为公共 `create`、`append`、`load` 调用即使没有 `Session` 对象仍可能发生竞态,依然需要按标识串行执行。 @@ -22,17 +24,17 @@ Status: implemented ## 备选方案 -**保留仅由检查点触发的延后写入。** 这种方式可以形成更大的批次,但会让持久性依赖另行挂载的检查点策略,并使检查点之间因崩溃而丢失数据的窗口达到最大。即时调度仍会合并同步突发事件,以及活跃写入期间到达的事件。 +**保留仅由检查点触发的延后写入。** 这种方式可以形成更大的批次,但会让持久性依赖另行挂载的检查点策略,并使检查点之间因崩溃而丢失数据的窗口达到最大。有界后台调度仍会合并突发事件,并在强制屏障之间持久化进度。 **在整个协调器范围内使用一个刷新 promise。** 这种挂接方式适用于单个文件,但全局 promise 会串行化互不相关的会话。每个活跃会话各有一个控制器,既能让不同会话的后端操作独立推进,又由按 id 操作链保护同一标识的操作。 -**永久锁存首次即时写入错误。** 这会让后续每次刷新都得到确定的结果,却会阻止现有的资源销毁重试从暂时性存储故障中恢复。保留批次但不锁存错误,可以同时保留可观测性和重试能力。 +**永久锁存首次后台写入错误。** 这会让后续每次刷新都得到确定的结果,却会阻止现有的资源销毁重试从暂时性存储故障中恢复。保留批次但不锁存错误,可以同时保留可观测性和重试能力。 **拒绝对所有活跃会话的加载。** 这样做很安全,但会让持久化消费方和测试无法再使用既有的闭合活跃会话快照。先生成快照再刷新,为调用提供了稳定的线性化点:刷新成功即可证明正是该快照已持久化,而活跃路径绝不调用崩溃修复。 ## 验证 -- 一个针对协调器的测试会阻塞第一次追加,在该次写入期间接纳另一个事件,并在不调用 `session/flush` 的情况下观测到自动执行的第二个持久批次。 +- 针对控制器的测试使用假时钟证明固定窗口不会重置,随后阻塞第一次追加,在该次写入期间接纳另一个事件,并在不调用 `session/flush` 的情况下观测到自动执行的第二个持久批次。 - 共享协调器契约仍覆盖内存、JSONL 和 SQLite 后端上的活跃会话接管、冲突、崩溃修复,以及会话和后端的资源释放。 - 失败和资源销毁测试会让写入失败的批次保持待处理,在关闭前重试这些批次,并证明尚在执行的控制器会延迟后端关闭。 - 共享后端契约会持久化一个仍打开的活跃轮次,证明 `load` 会拒绝且不会写入合成闭合事件,随后完成该轮次并让其所有者退役,最后重新加载完全相同的已完成轮次。 @@ -42,6 +44,6 @@ Status: implemented ## 后果 -协调器有三个长生命周期容器:持久化的标识状态、活跃会话控制器和按 id 操作链。即时写入缩短了通常情况下因崩溃而丢失数据的窗口,并移除了彼此独立的缓冲区、初始化注册表和退役注册表。与仅由检查点触发的排空相比,这种方式可能产生更多后端批次;同一轮事件循环内的突发事件和一次写入期间接纳的事件仍会合并。 +活跃会话条目把初始化与一个控制器放在一起;该控制器负责待处理事件、计时器、活跃写入、重试暂停和 flush 屏障。协调器仍以独立容器保存已持久化的标识状态、准备好的冷态 Session、标识退役等待方和按 id 操作链,因为即使不存在可写的活跃 Session,这些生命周期仍然存在。有界写入缩短了通常情况下因崩溃而丢失数据的窗口,相比即时调度产生更少的后端批次,同时不改变强制屏障。 `session/flush` 不再决定普通持久化何时开始。它仍是循环和检查点策略使用的顺序与错误观测边界,因此检查点成功仍表示在其完成前接纳的每个事件都已持久化。 diff --git a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml index b4df206f13..7b65dc0445 100644 --- a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md -2026-07-28-remove-synthetic-log-only-turns.md: 720aa6bbe246ea960a61f6d6f67d36d1f85af08a -2026-07-28-remove-synthetic-log-only-turns.zh.md: 587165a58ae322192b9ed1a9b3312f61e0b21ce6 +2026-07-28-remove-synthetic-log-only-turns.md: 61c1aa4b1599fe020e0cd96c32daea01d419e95a +2026-07-28-remove-synthetic-log-only-turns.zh.md: 1661045ca9fb340ebffc9b749263262cb178eae4 diff --git a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md index 720aa6bbe2..61c1aa4b15 100644 --- a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md +++ b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md @@ -18,7 +18,7 @@ The generic seam also duplicated domain policy. Its marker map said which plugin Core session invariants continue to enforce core-owned execution relations: turn and step numbering, enclosure of steering, assistant, tool, todo, and request-header events, and same-step tool call/result pairing. Core permits merge-extensible events between turns because only their declaring plugin knows whether they are execution-scoped or standalone. Plugin invariant companions remain responsible for their own event relations. -The title service appends `session/title` directly after its existing service, revision, cancellation, and live-session checks. The bundled model helper appends its literal `session/title-llm-request` record before dispatch. Persistence observes both through the eager `session/event` path and drains them at ordinary checkpoints and lifecycle teardown; neither append forces a flush merely because it is between turns. A fallback, auxiliary request record, or accepted provider title may therefore appear after `turn/end` and before the next `turn/start`. Manual compaction uses the same between-turn capability for a `compact/* { turn: null }` bracket, but explicitly flushes the closed attempt because `/compact` promises durability before releasing queued prompt admission. +The title service appends `session/title` directly after its existing service, revision, cancellation, and live-session checks. The bundled model helper appends its literal `session/title-llm-request` record before dispatch. Persistence admits both through the bounded `session/event` path and drains them at ordinary checkpoints and lifecycle teardown; neither append forces a flush merely because it is between turns. A fallback, auxiliary request record, or accepted provider title may therefore appear after `turn/end` and before the next `turn/start`. Manual compaction uses the same between-turn capability for a `compact/* { turn: null }` bracket, but explicitly flushes the closed attempt because `/compact` promises durability before releasing queued prompt admission. A session fork may end at any stable event position outside an open turn, not only at `turn/end`. This preserves standalone title and other plugin-owned log-only records in a default fork while still rejecting a prefix cut through active execution. @@ -40,4 +40,4 @@ Core invariant tests accept an unknown plugin event between turns while continui ## Consequences -Turn counts and outcomes again describe model-loop executions only. Standalone events and manual compaction brackets consume session seqs without consuming a turn number, start eager persistence like every other append, and require owners to request an explicit durability barrier only when their operation promises one. Generic plugin mistakes no longer fail under a core default enclosure rule, so each plugin that needs an execution relation must state and test that relation itself. The title capability keeps revision ordering and lifecycle persistence with less core state, and manual compaction gains durable control with no synthetic-turn or turn-number collision. +Turn counts and outcomes again describe model-loop executions only. Standalone events and manual compaction brackets consume session seqs without consuming a turn number, enter bounded persistence like every other append, and require owners to request an explicit durability barrier only when their operation promises one. Generic plugin mistakes no longer fail under a core default enclosure rule, so each plugin that needs an execution relation must state and test that relation itself. The title capability keeps revision ordering and lifecycle persistence with less core state, and manual compaction gains durable control with no synthetic-turn or turn-number collision. diff --git a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md index 587165a58a..1661045ca9 100644 --- a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md @@ -18,7 +18,7 @@ Status: implemented 核心会话不变量继续强制核心所属的执行关系:轮次与步骤编号、steering、助手、工具、待办和请求头事件的封闭,以及同一步骤内的工具调用/结果配对。核心允许可合并扩展事件位于轮次之间,因为只有声明它们的插件知道这些事件受执行作用域约束,还是可以独立存在。插件的不变量配套组件仍负责其自身的事件关系。 -标题服务会在完成既有的服务状态、修订、取消和实时会话检查后,直接追加 `session/title`。随附模型辅助函数会在发起调用前追加其字面量 `session/title-llm-request` 记录。持久化通过尽快处理的 `session/event` 路径观察两者,并在常规检查点与生命周期 teardown 时排空;二者都不会仅因为位于轮次之间就强制 flush。因此,回退标题、辅助请求记录或已接受的提供方标题可以出现在 `turn/end` 之后、下一个 `turn/start` 之前。手动压缩(compaction)利用同一项轮次间能力记录 `compact/* { turn: null }` 标记对,但会显式 flush 已闭合的尝试,因为 `/compact` 承诺在释放排队提示词接纳预留前完成持久化。 +标题服务会在完成既有的服务状态、修订、取消和实时会话检查后,直接追加 `session/title`。随附模型辅助函数会在发起调用前追加其字面量 `session/title-llm-request` 记录。持久化通过有界 `session/event` 路径接纳两者,并在常规检查点与生命周期 teardown 时排空;二者都不会仅因为位于轮次之间就强制 flush。因此,回退标题、辅助请求记录或已接受的提供方标题可以出现在 `turn/end` 之后、下一个 `turn/start` 之前。手动压缩(compaction)利用同一项轮次间能力记录 `compact/* { turn: null }` 标记对,但会显式 flush 已闭合的尝试,因为 `/compact` 承诺在释放排队提示词接纳预留前完成持久化。 会话 fork 可以结束于开放轮次之外的任意稳定事件位置,而不限于 `turn/end`。这样,默认 fork 会保留独立标题和其他插件所属的纯日志记录,同时仍拒绝在活跃执行过程中截断前缀。 @@ -40,4 +40,4 @@ Status: implemented ## 后果 -轮次计数和结果重新只描述模型循环执行。独立事件和手动压缩标记对会占用会话 seq,但不占用轮次编号;它们像其他追加一样启动尽快持久化,并且仅当操作承诺持久性时,才要求事件所有方请求显式持久性屏障。通用插件错误不再因核心默认的封闭规则而失败,因此每个需要执行关系的插件都必须自行声明并测试该关系。标题功能保留修订排序和生命周期持久化,同时减少了核心状态;手动压缩则获得持久控制,不产生合成轮次或轮次编号冲突。 +轮次计数和结果重新只描述模型循环执行。独立事件和手动压缩标记对会占用会话 seq,但不占用轮次编号;它们像其他追加一样进入有界持久化,并且仅当操作承诺持久性时,才要求事件所有方请求显式持久性屏障。通用插件错误不再因核心默认的封闭规则而失败,因此每个需要执行关系的插件都必须自行声明并测试该关系。标题功能保留修订排序和生命周期持久化,同时减少了核心状态;手动压缩则获得持久控制,不产生合成轮次或轮次编号冲突。 diff --git a/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.i18n.yaml index cd1133483c..e71be9bcf0 100644 --- a/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-04-remove-tui-package.md -2026-08-04-remove-tui-package.md: 7f7a0dd86ddd36e940ed8b7d6154185d9740341c -2026-08-04-remove-tui-package.zh.md: 36cb4b6a5e4eddd152eccee92c913bcca5b7fbae +2026-08-04-remove-tui-package.md: 1057243c70f6f2775a5d0c5f5eddcb72cbad699e +2026-08-04-remove-tui-package.zh.md: 0e03d6913aafaa3ce01c0f5732935d2c304c8e0e diff --git a/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.md b/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.md index 7f7a0dd86d..1057243c70 100644 --- a/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.md +++ b/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.md @@ -16,7 +16,7 @@ The `packages/ui/tui` package is deleted without a compatibility package or alia The SDK run-interface union now contains only `acp` and `embed`. `create-sdk` defaults to ACP, generated templates contain no terminal startup, resume, session-environment, or model-argument branch, and the builtin `ask-user` feature is removed because neither remaining generated interface supplies a `UserInteractionProvider`. Host applications may still mount the provider-neutral `dsh-user-interaction`, `dsh-commands`, and presentation seams directly. -This decision supersedes the reusable-package retention in [the explicit-config `dsh` entrypoint decision](2026-08-03-explicit-config-dsh-entrypoint.md) and the current applicability of the archived TUI implementation notes. Their historical records remain frozen, but they are not authority for the supported package or application inventory. +This decision supersedes the reusable-package retention in [the explicit-config `dsh` entrypoint decision](../../archived/simplification/2026-08-03-explicit-config-dsh-entrypoint.md) and the current applicability of the archived TUI implementation notes. Their historical records remain frozen, but they are not authority for the supported package or application inventory. This note consolidates the deleted package-only records that could not remain current after removal. The terminal UI had kept session identity visible during long conversations, removed duplicate model labels, attached elapsed timing and phase status to messages, showed workspace and branch context beside the prompt, and conservatively parsed complete XML wrappers for human-readable fallback output. Those choices improved one terminal frontend but do not justify retaining it without a deployment. A future XML fallback must still use a real parser rather than regular expressions. diff --git a/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.zh.md b/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.zh.md index 36cb4b6a5e..0e03d6913a 100644 --- a/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.zh.md +++ b/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.zh.md @@ -16,7 +16,7 @@ Status: implemented SDK 的运行接口联合类型现在只包含 `acp` 与 `embed`。`create-sdk` 默认使用 ACP,生成的模板不再包含终端启动、恢复、会话环境或模型参数分支;内置的 `ask-user` 功能也被移除,因为剩余两个生成接口都不提供 `UserInteractionProvider`。宿主应用仍可直接挂载提供方无关的 `dsh-user-interaction`、`dsh-commands` 和呈现 seam。 -本决策取代[显式配置 `dsh` 入口决策](2026-08-03-explicit-config-dsh-entrypoint.md)中保留可复用包的决定,也使已归档 TUI 实现记录不再适用于当前状态。这些历史记录继续保持冻结,但不再作为受支持包或应用清单的依据。 +本决策取代[显式配置 `dsh` 入口决策](../../archived/simplification/2026-08-03-explicit-config-dsh-entrypoint.md)中保留可复用包的决定,也使已归档 TUI 实现记录不再适用于当前状态。这些历史记录继续保持冻结,但不再作为受支持包或应用清单的依据。 本记录汇总了删除后无法继续保持当前状态的仅限包记录。终端 UI 曾在长对话期间保持会话身份可见、移除重复模型标签、为消息附加耗时与阶段状态、在提示词旁显示 workspace 与分支上下文,并保守地解析完整 XML 包装层,以生成人类可读的回退输出。这些选择改善了一个终端前端,但没有部署时不足以证明应保留它。未来的 XML 回退仍必须使用真实解析器而非正则表达式。 diff --git a/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.i18n.yaml new file mode 100644 index 0000000000..cd43c3a27b --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.md +2026-08-08-remove-cli-demo.md: c1153f5e9fcc89585e926f8f088829c849d1f6c1 +2026-08-08-remove-cli-demo.zh.md: 1f9719b176c05009900a268a5bb2d2d7fcc79df3 diff --git a/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.md b/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.md new file mode 100644 index 0000000000..c1153f5e9f --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.md @@ -0,0 +1,34 @@ +# Agent Note: Remove the separate CLI demo + +Status: implemented + +English | [中文](2026-08-08-remove-cli-demo.zh.md) + +## Problem + +After [`dsh run`](../feature/2026-08-08-dsh-run-headless-command.md) became the product one-shot command, `@deepseek-ai/dsh-cli-demo` remained a second application package for the same job. It carried another executable, argument grammar, app composition, cancellation lifecycle, text/JSON/stream-JSON output contract, built artifact, documentation surface, and test suite. The two front doors also assembled different trees, so a successful demo did not prove the shipped `headless` profile and users had to choose between overlapping commands. + +The replay suites still need canonical session events to pin assembled backend behavior. That testing need does not require a published command or compatibility contract. + +## Decision + +Delete `@deepseek-ai/dsh-cli-demo` completely: its package, bin, parser, app plugin, output formats, tests, workspace references, generated-catalog entries, and active documentation. No alias or compatibility package remains. The root `demo:headless` script is retained only as a direct alias of `dsh run`; the product command owns final-text stdout, the observation URL on stderr, persistence, exit status, and shutdown. + +`examples/headless-agent` becomes an explicit test composition. Its Loader configs mount `@deepseek-ai/dsh-agent-spine-demo`, one root agent, JSONL persistence, and checkpoint policy as separate rows instead of hiding them behind an app bundle. The support-tier `@deepseek-ai/dsh-loader-smoke` package owns the shared direct-agent turn helper; unexported example-local drivers select their Loader configuration and render canonical events as JSONL. They are launched only by tests, have no bin, and do not define a supported product output format. + +## Alternatives considered + +- **Keep `dsh-cli-demo` as an alias or wrapper around `dsh run`.** Rejected because a second bin and package would preserve two discoverable owners without adding capability. +- **Move JSON and stream-JSON flags onto `dsh run`.** Rejected because no current product consumer requires them; adopting the old demo protocol would enlarge the canonical CLI contract solely to save test machinery. +- **Delete the canonical-event snapshots with the package.** Rejected because they pin model-visible assembled behavior that final-text product acceptance cannot observe. +- **Keep the app plugin but delete only its bin.** Rejected because the hidden composition would still duplicate the explicit headless profile and conceal which services the test leaf mounts. + +## Consequences + +This is intentionally breaking. `dsh-cli-demo`, its `--output-format` choices, and imports from `@deepseek-ai/dsh-cli-demo/src/cli.ts` no longer resolve. There is no public event-stream replacement in this change; callers use `dsh run` for one-shot execution and must choose an existing protocol surface when they need structured automation. + +The repository retains backend replay coverage through test-only infrastructure, while product smoke and built-bin acceptance exercise `dsh run`. A separate one-shot package may return only if it owns a genuinely independent, versioned protocol that cannot belong to the product launcher; a second spelling or output shim is not enough. + +## Verification + +Focused Loader smokes cover the explicit composition in source and plain-Node built modes, snapshot tests diff its canonical JSONL and persisted logs, product acceptance covers `dsh run`, and documentation plus generated graph/catalog gates reject live references to the removed package. The frozen Agent Note archive remains historical evidence and is not rewritten. diff --git a/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.zh.md b/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.zh.md new file mode 100644 index 0000000000..1f9719b176 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.zh.md @@ -0,0 +1,34 @@ +# Agent Note: 移除独立的 CLI demo + +Status: implemented + +[English](2026-08-08-remove-cli-demo.md) | 中文 + +## 问题 + +在 [`dsh run`](../feature/2026-08-08-dsh-run-headless-command.md) 成为产品的一次性命令后,`@deepseek-ai/dsh-cli-demo` 仍是承担同一工作的第二个应用包。它另行拥有一套可执行文件、参数语法、应用组装、取消生命周期、文本/JSON/stream-JSON 输出契约、构建产物、配套文档和测试套件。两个入口组装的树也不相同,因此 demo 成功不能证明已交付的 `headless` profile 可用,用户还必须在功能重叠的命令之间作出选择。 + +回放套件仍需要规范会话事件来固定组装后的后端行为。这一测试需求不需要已发布命令或兼容性契约。 + +## 决策 + +彻底删除 `@deepseek-ai/dsh-cli-demo`:包括它的包、bin、解析器、应用插件、输出格式、测试、workspace 引用、生成目录条目和现行文档。不保留别名或兼容包。根目录的 `demo:headless` 脚本仅作为 `dsh run` 的直接别名保留;stdout 上的最终文本、stderr 上的观察 URL、持久化、退出状态和关闭行为均由产品命令负责。 + +`examples/headless-agent` 成为显式测试组装。其 Loader 配置把 `@deepseek-ai/dsh-agent-spine-demo`、一个根 agent(智能体)、JSONL 持久化和检查点策略挂载为独立配置行,不再将其隐藏在应用组合包之后。支持层的 `@deepseek-ai/dsh-loader-smoke` 包负责共享的直接 agent 轮次 helper;未导出的示例本地 driver 选择各自的 Loader 配置,并将规范事件渲染为 JSONL。这些 driver 只由测试启动,不提供 bin,也不定义受支持的产品输出格式。 + +## 考虑过的替代方案 + +- **保留 `dsh-cli-demo` 作为 `dsh run` 的别名或包装层。** 不予采纳:第二个 bin 和包会让同一功能继续存在两个可发现的归属方,却没有增加任何能力。 +- **把 JSON 和 stream-JSON 标志移到 `dsh run`。** 不予采纳:当前没有产品消费方需要这些标志;沿用旧 demo 协议,只会为了保留测试机制而扩大规范 CLI(命令行界面)契约。 +- **随包一并删除规范事件快照。** 不予采纳:这些快照固定了模型可见的组装行为,而只检查最终文本的产品验收无法观察这些行为。 +- **保留应用插件,只删除它的 bin。** 不予采纳:隐藏的组装仍会重复显式的 headless profile,并掩盖测试叶节点挂载了哪些服务。 + +## 后果 + +这是有意为之的破坏性变更。`dsh-cli-demo`、它的 `--output-format` 选项以及对 `@deepseek-ai/dsh-cli-demo/src/cli.ts` 的导入都不再可解析。本变更不提供公开的事件流替代接口;调用方使用 `dsh run` 执行一次性任务,需要结构化自动化时则必须选择现有的协议接口。 + +仓库通过仅供测试的基础设施保留后端回放覆盖,产品冒烟测试和 built-bin 验收则运行 `dsh run`。只有当独立的一次性包负责一套真正独立、带版本且不能归产品启动器所有的协议时,它才可以重新引入;第二种命令写法或输出 shim 并不足以构成理由。 + +## 验证 + +聚焦的 Loader 冒烟测试在源码模式和由普通 Node 启动的构建模式下覆盖显式组装,快照测试对比其规范 JSONL 和持久化日志,产品验收覆盖 `dsh run`,文档检查及生成图谱/目录门禁则拒绝对已移除包的活跃引用。冻结的 Agent Note 归档保留为历史证据,不会被重写。 diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml index fe97a7b717..e5e6b3d67e 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md -2026-06-19-acp-snapshot-tests.md: e118ada58230fe31fbb2a6bffb83e5612757ab1f -2026-06-19-acp-snapshot-tests.zh.md: e292dbf3bf3c4c5198dc77d122bb6b8c36014ebf +2026-06-19-acp-snapshot-tests.md: c6e81dc4a51083ed37ad6f837fe7dbb08c212e23 +2026-06-19-acp-snapshot-tests.zh.md: 5a579c3373442dc4cfc7d661ea2f2f398b804dc7 diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md index e118ada582..c6e81dc4a5 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md @@ -24,7 +24,7 @@ Every committed session-format fixture uses the canonical packed physical layout ### Replay derives the model script from the log -`llm-replay` short-circuits the provider-agnostic `llm/stream` waterfall. `deriveReplayScript()` groups recorded chunks by `(turn, step)` and serves one group per model call. The loop makes one stream call per step, so the grouping is exact and includes error finish chunks without special handling. +`llm-replay` short-circuits the provider-agnostic `llm/stream` waterfall. `deriveReplayScript()` splits recorded `assistant/chunk` events at terminal `finish` chunks and uses `(turn, step)` changes to reject an unterminated prior call. A `compact/summary` with `llmStreamCall: true` contributes one call at its durable log position: replay reconstructs canonical block boundaries from `rawOutput`, retains recorded usage when present, and supplies a terminal `stop`. The marker distinguishes that local call from template or remote summaries whose retained `rawOutput` did not consume this context's adapter. ### The in-memory replay entry honors the full LLM contract @@ -36,7 +36,7 @@ Every committed session-format fixture uses the canonical packed physical layout | { kind: 'hang' } ``` -Logs derive chunk entries. Pre-stream throws and hangs have no reconstructable chunk representation, so those scenarios provide `replay.override.json`. A throw entry may include prefix chunks for mid-stream failure. Explicit overrides avoid inferring adapter behavior from lossy turn-end reasons. +Logs derive chunk entries from finished assistant streams and explicitly marked compaction calls. Pre-stream throws, hangs, and external summarizer calls have no reconstructable local chunk representation, so those scenarios provide `replay.override.json`. A throw entry may include prefix chunks for mid-stream failure. Explicit overrides avoid inferring adapter behavior from lossy turn-end reasons or provider output alone. ### Positional replay, one in-flight stream @@ -74,12 +74,13 @@ Tool determinism comes from a generated cwd, scrubbed environment, fresh non-log ## Alternatives considered - **A hand-authored `llm.json` of model chunks** — the earlier draft; reusing the real session log makes the fixture a genuine product of the system rather than a hand-built mock, and doubles it as a behavioral expected output. +- **A compulsory replay override for every compaction summary** — rejected: the durable summary event already fixes a successful local call's position, complete output, and optional usage. An explicit local-call marker preserves that single-source fixture without inventing a call for template or remote summarizers. - **A byte-level HTTP-record library (Polly/nock/MSW)** — rejected: adapter-specific, awkward with streaming SSE, and lower-level than the thing under test. - **Synthesizing throw/cancel entries from `turn/end {kind:'error'|'aborted'}`** — rejected: it couples `llm-replay` to loop-internal turn-closing semantics, and the `turn/end` reason is lossy (it cannot distinguish a thrown 401 from a finish-error); the explicit `replay.override.json` sidecar is the cleaner seam. - **Copying both request-header sidecars beside every class pin** — rejected: prompt and tool-schema composition vary independently, so a change to one shared component would churn byte-identical files across unrelated class pins. Explicit per-component sources retain one structural pin per class without duplicating content. ## Consequences -The tier adds reviewed per-scenario input, session, stdout, optional override, and optional workspace fixtures, plus one file for each distinct pinned prompt and tool-schema sequence. Workspace seeds are copied into the generated cwd for both record and replay. In return the tier provides deterministic keyless coverage through the real Loader and tool composition. Most retained scenarios exercise the assembled backend rather than ACP; the [automation-only ACP decision](../simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary) keeps that corpus here and defers any move to a transport-neutral headless suite as an independent testing change (the suite-level FIXME marks it). +The tier adds reviewed per-scenario input, session, stdout, optional override, and optional workspace fixtures, plus one file for each distinct pinned prompt and tool-schema sequence. Workspace seeds are copied into the generated cwd for both record and replay. In return the tier provides deterministic keyless coverage through the real Loader and tool composition, including an assembled context-overflow recovery whose marked compaction summary supplies the auxiliary call. Most retained scenarios exercise the assembled backend rather than ACP; the [automation-only ACP decision](../simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary) keeps that corpus here until it can move to a transport-neutral headless suite without losing coverage. This Agent Note relates to but does not supersede the [proposed determinism Agent Note](../../proposed/testing/2026-06-11-deterministic-and-stress-testing.md): that proposal's "universal replay fixture" re-derives session *message history* after every test (an internal-consistency invariant), whereas these snapshots pin assembled behavior plus the external automation output. They are complementary until the backend corpus moves off ACP. diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md index e292dbf3bf..5a579c3373 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md @@ -24,7 +24,7 @@ Status: implemented ### 回放从日志推导模型脚本 -`llm-replay` 短路了提供方无关的 `llm/stream` waterfall(瀑布式事件)。`deriveReplayScript()` 按 `(turn, step)` 对已录制的分片分组,每次模型调用服务一组。agent loop(智能体循环)每个步骤发起一次流调用,因此分组精确对应,错误结束分片也无需特殊处理。 +`llm-replay` 短路了提供方无关的 `llm/stream` waterfall(瀑布式事件)。`deriveReplayScript()` 在终止的 `finish` 分片处切分已记录的 `assistant/chunk` 事件,并用 `(turn, step)` 变化拒绝前一条未终止的调用。携带 `llmStreamCall: true` 的 `compact/summary` 会在其持久日志位置贡献一次调用:回放根据 `rawOutput` 重建规范块边界,保留已记录的 usage(如有),并提供终止的 `stop`。该标记将这次本地调用与模板摘要或远程摘要区分开;后两者即使保留了 `rawOutput`,也未使用此上下文的适配器。 ### 内存中的回放条目遵守完整的 LLM 契约 @@ -36,7 +36,7 @@ Status: implemented | { kind: 'hang' } ``` -日志推导出分片条目。流开始前的抛出和挂起没有可重建的分片表示,因此这些场景提供 `replay.override.json`。throw 条目可以包含前缀分片以模拟流中途失败。显式覆盖避免了从有损的轮次结束原因推断适配器行为。 +日志从已结束的 assistant 流和显式标记的压缩(compaction)调用推导分片条目。流开始前的抛出、挂起和外部摘要器调用没有可重建的本地分片表示,因此这些场景提供 `replay.override.json`。throw 条目可以包含前缀分片以模拟流中途失败。显式覆盖避免了从有损的轮次结束原因或单独的提供方输出推断适配器行为。 ### 位置式回放,单个在途流 @@ -74,12 +74,13 @@ Status: implemented ## 曾考虑的替代方案 - **手工编写包含模型分片的 `llm.json`**——早期草案;复用真实会话日志,使 fixture 成为系统的真实产物而非手工构建的 mock,并让它同时充当行为预期输出。 +- **为每个压缩摘要强制提供回放 override**——否决:持久摘要事件已经固定成功本地调用的位置、完整输出与可选 usage。显式的本地调用标记保留了这份单一来源 fixture,而不会为模板摘要器或远程摘要器凭空构造调用。 - **字节级 HTTP 录制库(Polly/nock/MSW)**:否决。与适配器耦合,处理流式 SSE(Server-Sent Events)时笨拙,且层级低于被测对象。 - **从 `turn/end {kind:'error'|'aborted'}` 合成抛错/取消条目**:否决。这会将 `llm-replay` 耦合到 loop 内部的轮次关闭语义,且 `turn/end` 原因是有损的(无法区分抛出的 401 与 finish-error);显式的 `replay.override.json` 伴随文件是更清晰的 seam。 - **在每个类别 pin 旁复制两个请求头伴随文件**:否决。提示词与工具 schema 的组合各自独立变化,因此一个共享组件发生变更,就会使不相关类别 pin 中字节完全相同的文件产生无意义改动。显式的分组件来源可在不重复内容的情况下,为每个类别保留一个结构性 pin。 ## 后果 -该测试层为每个场景增加经过评审的输入、会话、stdout、可选 override 和可选 workspace fixture,并为每个不同的已固定提示词序列、每个不同的已固定工具 schema 序列各增加一个文件。记录与回放都会把 workspace seed 复制到生成的 cwd。作为回报,该层通过真实 Loader 和工具组合提供确定性的无密钥覆盖。保留下来的大多数场景测试的是组装后的后端而非 ACP;[仅面向自动化的 ACP 决策](../simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary)将该语料保留在此处,并把向传输无关 headless 套件的任何迁移推迟为一项独立的测试变更(套件级 FIXME 标记了这一点)。 +该测试层为每个场景增加经过评审的输入、会话、stdout、可选 override 和可选 workspace fixture,并为每个不同的已固定提示词序列、每个不同的已固定工具 schema 序列各增加一个文件。记录与回放都会把 workspace seed 复制到生成的 cwd。作为回报,该层通过真实 Loader 和工具组合提供确定性的无密钥覆盖,其中包括一个组装后的上下文溢出恢复场景,其带标记的压缩摘要提供辅助调用。保留下来的大多数场景测试的是组装后的后端而非 ACP;[仅面向自动化的 ACP 决策](../simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary)将该语料保留在此处,直至它能够在不损失覆盖的情况下迁移到传输无关的 headless 套件。 本 Agent Note 与[拟议的确定性 Agent Note](../../proposed/testing/2026-06-11-deterministic-and-stress-testing.md)相关,但不取代它:该提案的“通用回放 fixture”在每次测试后重新派生会话*消息历史*(内部一致性不变量),而这些快照固定组装后的行为与外部自动化输出。在后端语料迁出 ACP 之前,两者相互补充。 diff --git a/.agents/notes/proposed/architecture/2026-08-08-semantic-composer-chain-phases.i18n.yaml b/.agents/notes/proposed/architecture/2026-08-08-semantic-composer-chain-phases.i18n.yaml new file mode 100644 index 0000000000..c70cbb870b --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-08-08-semantic-composer-chain-phases.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-08-08-semantic-composer-chain-phases.md +2026-08-08-semantic-composer-chain-phases.md: 7d85518e9d5e5aa3e44e443cb1b90b325b0ffc6a +2026-08-08-semantic-composer-chain-phases.zh.md: a202b9bfeb9276cdccaa7b438036cd1236a33317 diff --git a/.agents/notes/proposed/architecture/2026-08-08-semantic-composer-chain-phases.md b/.agents/notes/proposed/architecture/2026-08-08-semantic-composer-chain-phases.md new file mode 100644 index 0000000000..7d85518e9d --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-08-08-semantic-composer-chain-phases.md @@ -0,0 +1,46 @@ +# Agent Note: Semantic phases for composer-chain election + +Status: proposed + +English | [中文](2026-08-08-semantic-composer-chain-phases.zh.md) + +## Problem + +The browser's `conversation.composer` chain orders every candidate by one global numeric `priority`, then elects the first selector returning a match. Question uses the default priority `0`, approval uses `1`, and the one-shot or unavailable-parent read-only subagent composer uses `-10`. A selected one-shot history can therefore show the read-only explanation while an answerable question or approval is pending underneath it. + +The defect is not one incorrect number. The chain currently uses the same scalar for two different decisions: whether a candidate resolves an existing interaction or restricts starting new work, and the local preference between candidates of the same semantic kind. Any numeric repair preserves that hidden coupling and lets a later registrant recreate the bug. + +## Proposal + +A chain declaration may define an ordered tuple of domain-owned phases. `conversation.composer` declares `['interaction', 'restriction']`; every registration on that phased chain must name one phase, and its numeric `priority` orders entries only within that phase. `SlotCore` sorts by declared phase index, then local priority, then stable registration order. Registration fails immediately when a phased chain entry omits its phase or names one outside the declaration. Unphased chains retain their current numeric behavior. + +Question and approval register in `interaction`, retaining their current within-phase order of question before approval. `SubagentReadOnlyComposer` registers in `restriction` with an ordinary local priority. The domain rule is precise: an interaction resolves a live Host wait that already exists; a restriction prevents the user from initiating work through the ordinary composer. Resolving an existing wait is not a new follow-up to the one-shot child, so the interaction phase goes first. Once the wait resolves, the chain re-elects and the read-only restriction becomes visible again. + +The phase vocabulary belongs to the declaring slot, not to the slot framework globally. `SlotMap` carries the exact phase tuple for compile-time registration, and the runtime `SlotSpec` repeats that tuple as the sorting authority. Other chains acquire no composer terminology and need no migration unless they deliberately declare phases. + +This proposal extends the [Web subagent conversation](../../implemented/feature/2026-07-27-web-subagent-conversations.md), [Web permission and approval](../../implemented/feature/2026-07-23-web-permission-and-approval.md), and [plan-review presentation](../../implemented/feature/2026-07-30-plan-review-presentation-intent.md) contracts; it supersedes none of them. The [runtime-owned child guard](../../implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.md) remains the authority that prevents new child-owned human waits. No active Agent Note should be archived when this proposal lands. + +## Alternatives considered + +**Move the read-only priority after question and approval.** This is the smallest tactical fix, but it leaves semantic dominance encoded as undocumented number spacing and makes the next composer kind guess at the same global scale. + +**Make the read-only selector decline whenever `interactions` is non-empty.** This fixes the current pair but makes a restriction plugin understand every actionable domain and duplicates election policy across selectors. A new interaction kind would require edits in unrelated restrictions. + +**Rely only on the runtime child guard.** The guard fixes new model calls but cannot define browser ordering for already-pending waits, rolling-version overlap, or other interaction kinds such as approval. Runtime authority and presentation election are separate invariants. + +**Render all matching takeovers as a stack.** The composer has one action seat. Stacking question, approval, and read-only surfaces makes keyboard focus and answer ownership ambiguous instead of selecting one current action. + +## Acceptance criteria + +- `SlotCore` tests prove phase order dominates arbitrary local priorities, local priority and stable registration order still work within a phase, unknown or omitted phases fail loud, and unphased chains are unchanged. +- Composer tests cover question plus read-only, approval plus read-only, question plus approval plus read-only, resolution back to read-only, and the all-declined InputBar fallback. Question remains ahead of approval within `interaction`. +- Disposal, HMR re-registration, and reconnect replay cannot leave a stale elected phase; election remains a pure function of the current owner props and current registrations. +- A keyless assembled Web snapshot pins a one-shot addressed conversation with a pending interaction, the interaction surface winning, its resolution, and the read-only surface returning afterward. +- Slot, conversation, question, permission, and subagent README/JSDoc contracts describe phase ownership and the interaction-before-restriction rule together. +- The change modifies no model-visible tool definition, system-prompt section, request routing, or session event. Browser election therefore has no token cost and no KV-cache invalidation; tests compare the model request header before and after the client-only transition. + +## Risks + +Phase names can become a vague substitute for design. Each phased slot therefore owns a short ordering rule and rejects entries that cannot state which side they belong to. A future hard safety surface that must preempt answering should not be mislabeled `restriction`; it needs an explicit earlier phase or a boundary outside this composer chain. + +The generic slot types and stored-entry shape gain one conditional field, so an incomplete migration could compile in one face yet fail at runtime. The exact tuple is repeated in the runtime declaration specifically to make that drift mechanically rejectable. Concurrent questions and approvals remain a single-surface policy; this proposal preserves their current order rather than solving multi-interaction queueing. diff --git a/.agents/notes/proposed/architecture/2026-08-08-semantic-composer-chain-phases.zh.md b/.agents/notes/proposed/architecture/2026-08-08-semantic-composer-chain-phases.zh.md new file mode 100644 index 0000000000..a202b9bfeb --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-08-08-semantic-composer-chain-phases.zh.md @@ -0,0 +1,46 @@ +# Agent Note: composer 链选举的语义阶段 + +Status: proposed + +[English](2026-08-08-semantic-composer-chain-phases.md) | 中文 + +## 问题 + +浏览器的 `conversation.composer` 链先按一个全局数值 `priority` 对所有候选项排序,再选出第一个返回匹配项的选择器。问题采用默认优先级 `0`,审批采用 `1`,一次性或父级不可用时使用的只读 subagent composer 采用 `-10`。因此,选中一次性 subagent 历史记录后,即使其下方有等待应答的问题或审批,界面仍可能显示只读说明。 + +该缺陷并非某个数值有误。当前链用同一个标量作出两项不同的决策:候选项究竟用于解决现有交互,还是用于限制发起新工作;以及如何确定同一语义类别内各候选项的局部优先顺序。任何数值修复都会保留这种隐式耦合,让后续注册方可以再次引入同一缺陷。 + +## 提案 + +链声明可以定义由所属领域拥有的有序阶段元组。`conversation.composer` 声明 `['interaction', 'restriction']`;该分阶段链上的每项注册都必须指名一个阶段,其数值 `priority` 只在该阶段内排序。`SlotCore` 依次按声明的阶段索引、局部优先级和稳定注册顺序排序。如果分阶段链的注册项省略阶段,或指名声明外的阶段,注册会立即失败。未分阶段的链继续沿用当前的数值排序行为。 + +问题与审批注册到 `interaction`,并保留问题先于审批的现有阶段内顺序。`SubagentReadOnlyComposer` 以普通局部优先级注册到 `restriction`。领域规则定义明确:交互用于使 Host 上已经存在且仍有效的等待完成;限制则阻止用户通过普通 composer 发起工作。完成现有等待并非向一次性子级发送新的后续消息,因此交互阶段排在前面。等待完成后,链会重新选举,只读限制会再次出现。 + +阶段词汇归声明该 slot 的领域所有,而不属于全局 slot 框架。`SlotMap` 携带确切的阶段元组,用于编译期注册;运行时 `SlotSpec` 重复该元组,作为排序依据。其他链不会获得任何 composer 术语,也无需迁移,除非它们主动声明阶段。 + +本提案扩展 [Web subagent 对话](../../implemented/feature/2026-07-27-web-subagent-conversations.md)、[Web 权限与审批](../../implemented/feature/2026-07-23-web-permission-and-approval.md)和[计划审阅呈现](../../implemented/feature/2026-07-30-plan-review-presentation-intent.md)契约,但不取代其中任何一项。[运行时所有权子级守卫](../../implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.md)仍然是防止子级新建自己负责的人类等待的权威机制。本提案落地时,不应归档任何活跃 Agent Note。 + +## 备选方案 + +**把只读项的优先级移到问题和审批之后。** 这是最小的战术修复,但它仍以未记载的数值间距编码语义支配关系,并迫使下一种 composer 类型在同一个全局尺度上猜测自身位置。 + +**当 `interactions` 非空时,让只读选择器拒绝匹配。** 这可以修复当前这一对组件,但会迫使限制插件理解每个可操作领域,并在各选择器中重复选举策略。每新增一种交互类型,都需要修改与其无关的限制项。 + +**只依赖运行时子级守卫。** 该守卫可以修复新的模型调用,但无法定义浏览器对已有待处理等待、滚动升级中的版本重叠或审批等其他交互类型的排序。运行时权限与呈现选举是两项独立的不变量。 + +**把所有匹配的接管界面渲染成一个栈。** composer 只有一个操作席位。同时堆叠问题、审批和只读界面,会使键盘焦点与回答所有权含混不清,而不是选出一个当前操作。 + +## 验收标准 + +- `SlotCore` 测试证明阶段顺序优先于任意局部优先级;局部优先级和稳定注册顺序在阶段内仍然有效;未知或缺失的阶段会明确失败;未分阶段的链保持不变。 +- Composer 测试覆盖问题加只读项、审批加只读项、问题加审批加只读项、解决后回到只读项,以及所有选择器均拒绝匹配时回退到 InputBar。问题在 `interaction` 内仍排在审批之前。 +- dispose(资源释放)、HMR(热模块替换)重新注册和重新连接回放均不能留下陈旧的当选阶段;选举仍是当前所有者 props 与当前注册项的纯函数。 +- 一项无密钥组装 Web 快照固定已寻址的 one-shot 对话及其待处理交互:交互界面胜出,解决该交互后,只读界面再次出现。 +- slot、conversation、question、permission 和 subagent 的 README/JSDoc 契约共同描述阶段所有权以及交互先于限制的规则。 +- 该变更不修改任何模型可见的工具定义、系统提示词章节、请求路由或会话事件。因此,浏览器选举既不产生 token 开销,也不会使 KV Cache 失效;测试会比较仅在客户端发生状态转换前后的模型请求 header。 + +## 风险 + +阶段名称可能沦为含混的设计替代品。因此,每个分阶段 slot 都要拥有一条简短的排序规则,并拒绝无法说明自身归属哪一侧的注册项。未来如有必须优先于回答操作的硬性安全界面,不应将其误标为 `restriction`;它需要一个显式排在更前的阶段,或位于该 composer 链以外的边界。 + +通用 slot 类型与已存储条目形态会增加一个条件字段,因此迁移不完整时,代码可能在一个 face 中通过编译,却在运行时失败。之所以在运行时声明中重复确切元组,正是为了让系统能以机械方式拒绝这种漂移。并发问题与审批仍采用单界面策略;本提案保留其当前顺序,不解决多交互排队问题。 diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 81052d7c08..524d7912e2 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,20 +6,6 @@ updates: exclude-paths: # Vendored Cordis sources follow vendor/README.md instead of registry updates. - "vendor/**" - # This independently locked pnpm workspace has its own update entry below. - - "native/landlock-run/**" - schedule: - interval: "cron" - cronjob: "0 4 * * *" - timezone: "Asia/Shanghai" - cooldown: - default-days: 30 - labels: - - "cleanup" - - "area/infra" - - - package-ecosystem: "npm" - directory: "/native/landlock-run" schedule: interval: "cron" cronjob: "0 4 * * *" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 821948f873..46ab8e892f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ env: jobs: - # FIXME: Re-enable the three hosted serial reference jobs before cutting a release. + # TODO(hosted-serial-ci): Re-enable the three hosted serial reference jobs before release. # The self-hosted standby remains active on every master push. # Three enterprise jobs isolate coverage, static analysis, and the diff --git a/.github/workflows/issue-lifecycle.yml b/.github/workflows/issue-lifecycle.yml index 4dc6869e27..7a25b5223d 100644 --- a/.github/workflows/issue-lifecycle.yml +++ b/.github/workflows/issue-lifecycle.yml @@ -21,7 +21,6 @@ on: - reopened - labeled - unlabeled - - ready_for_review - review_requested pull_request_review: types: diff --git a/.github/workflows/landlock-run-release.yml b/.github/workflows/landlock-run-release.yml new file mode 100644 index 0000000000..7d78e98bc6 --- /dev/null +++ b/.github/workflows/landlock-run-release.yml @@ -0,0 +1,176 @@ +# Build and publish the @deepseek-ai/node-addon-landlock-run package family from the +# harness source of record. Rehearsal and publication consume the same packed +# tarballs; each native binary is built on its matching architecture. +name: Landlock Run Release + +on: + workflow_dispatch: + inputs: + publish: + description: Publish packed tarballs to npm. Must run from a landlock-run-v* tag. + required: true + type: boolean + default: false + +permissions: + contents: read + +concurrency: + # Stable/prerelease dist-tags are shared registry state; serialize release + # runs so two versions cannot race the final tag assignment. + group: ${{ github.workflow }} + cancel-in-progress: false + +defaults: + run: + working-directory: native/landlock-run + +jobs: + matrix: + name: Matrix + runs-on: ubuntu-24.04 + outputs: + prebuilds: ${{ steps.matrix.outputs.prebuilds }} + steps: + - uses: actions/checkout@v4 + + - id: matrix + run: echo "prebuilds=$(node ./scripts/github-matrix.mjs release-prebuild)" >> "$GITHUB_OUTPUT" + + build-prebuilds: + name: ${{ matrix.package }} + needs: matrix + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.matrix.outputs.prebuilds) }} + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + package_json_file: package.json + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --filter @deepseek-ai/node-addon-landlock-run-workspace... --frozen-lockfile + + - name: Install musl toolchain + run: | + sudo apt-get update -q + sudo apt-get install -yq musl-tools + + - name: Build native binaries + run: pnpm build:native + + - name: Verify binary metadata + run: node ./scripts/verify-launcher-binary.mjs ${{ matrix.dir }} + + - name: Upload prebuild artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact }} + path: native/landlock-run/${{ matrix.dir }}/bin/* + if-no-files-found: error + retention-days: 7 + + pack: + name: Pack npm tarballs + needs: build-prebuilds + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + package_json_file: package.json + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --filter @deepseek-ai/node-addon-landlock-run-workspace... --frozen-lockfile + + - name: Build TypeScript + run: pnpm build:ts + + - name: Verify release version + run: node ./scripts/verify-release.mjs + env: + RELEASE_PUBLISH: ${{ inputs.publish }} + + - name: Download prebuild artifacts + uses: actions/download-artifact@v4 + with: + pattern: prebuild-* + path: native/landlock-run/.release/prebuild-artifacts + + - name: Assemble and verify prebuilds + run: node ./scripts/assemble-prebuilds.mjs .release/prebuild-artifacts + + - name: Verify release payload + run: node ./scripts/verify-release.mjs --prebuilds + env: + RELEASE_PUBLISH: ${{ inputs.publish }} + + - name: Pack release tarballs + run: node ./scripts/pack-release.mjs dist/npm + + - name: Verify packed install + run: node ./scripts/verify-packed-install.mjs dist/npm + env: + NALR_REQUIRE_LANDLOCK: 1 + + - name: Upload npm tarballs + uses: actions/upload-artifact@v4 + with: + name: npm-tarballs + path: native/landlock-run/dist/npm/* + if-no-files-found: error + retention-days: 7 + + publish: + name: Publish to npm + if: inputs.publish + needs: pack + runs-on: ubuntu-24.04 + environment: npm-publish + permissions: + contents: read + id-token: write + steps: + - uses: actions/setup-node@v4 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + + - name: Download npm tarballs + uses: actions/download-artifact@v4 + with: + name: npm-tarballs + path: native/landlock-run/dist/npm + + - name: Configure npm token fallback + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + if [[ -n "$NPM_TOKEN" ]]; then + echo "NODE_AUTH_TOKEN=$NPM_TOKEN" >> "$GITHUB_ENV" + fi + + - name: Publish tarballs + run: | + version="${GITHUB_REF#refs/tags/landlock-run-v}" + tag_args=() + case "$version" in *-*) tag_args=(--tag next);; esac + while IFS= read -r tarball; do + npm publish "dist/npm/${tarball}" --access public "${tag_args[@]}" + done < dist/npm/publish-order.txt diff --git a/.github/workflows/landlock-run.yml b/.github/workflows/landlock-run.yml index dad9638761..6379c8cdc1 100644 --- a/.github/workflows/landlock-run.yml +++ b/.github/workflows/landlock-run.yml @@ -1,15 +1,27 @@ -# Manually-dispatched CI for the landlock-run source of record -# (native/landlock-run). A separate workflow from ci.yml on purpose: the -# subtree is a self-contained pnpm workspace with its own gates, exercised on -# demand — per-architecture native legs (build + behavioral tests + pack -# rehearsal on real kernels) plus one darwin leg proving the documented -# degradation on hosts without a platform package. Legs derive from the -# subtree's checked-in package matrix (scripts/github-matrix.mjs). Packing -# for npm happens in the release mirror (node-addon-landlock-run) after an -# export — see native/README.md; this workflow never packs for release. +# CI for the landlock-run packages under native/landlock-run. A separate +# workflow from ci.yml keeps the native OS/architecture matrix independent of +# the harness Node matrix. Release assembly and publication use the companion +# Landlock Run Release workflow. name: Landlock Run on: + pull_request: + paths: + - '.github/workflows/landlock-run.yml' + - '.github/workflows/landlock-run-release.yml' + - 'native/landlock-run/**' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + push: + branches: [master] + paths: + - '.github/workflows/landlock-run.yml' + - '.github/workflows/landlock-run-release.yml' + - 'native/landlock-run/**' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' workflow_dispatch: concurrency: @@ -52,16 +64,16 @@ jobs: - uses: pnpm/action-setup@v4 with: - package_json_file: native/landlock-run/package.json + package_json_file: package.json - uses: actions/setup-node@v4 with: node-version: 24 cache: pnpm - cache-dependency-path: native/landlock-run/pnpm-lock.yaml + cache-dependency-path: pnpm-lock.yaml - name: Install dependencies - run: pnpm install --frozen-lockfile + run: pnpm install --filter @deepseek-ai/node-addon-landlock-run-workspace... --frozen-lockfile - name: Install musl toolchain run: | @@ -103,16 +115,16 @@ jobs: - uses: pnpm/action-setup@v4 with: - package_json_file: native/landlock-run/package.json + package_json_file: package.json - uses: actions/setup-node@v4 with: node-version: 24 cache: pnpm - cache-dependency-path: native/landlock-run/pnpm-lock.yaml + cache-dependency-path: pnpm-lock.yaml - name: Install dependencies - run: pnpm install --frozen-lockfile + run: pnpm install --filter @deepseek-ai/node-addon-landlock-run-workspace... --frozen-lockfile - name: Build TypeScript run: pnpm build:ts diff --git a/.github/workflows/sandbox.yml b/.github/workflows/sandbox.yml index 939ca2f6ab..2dfaf0e175 100644 --- a/.github/workflows/sandbox.yml +++ b/.github/workflows/sandbox.yml @@ -3,9 +3,8 @@ # .agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md. # A separate workflow from ci.yml because the axis is different — these jobs # fan out over OS×runner (kernel capabilities), not node versions. The Landlock -# launcher arrives from the registry with `pnpm install` (the npm package family -# `node-addon-landlock-run`, built and released from its own repository), so -# these legs exercise the true consumer path — nothing is compiled here. +# launcher is built from native/landlock-run on each Landlock leg and installed +# from the same tarballs the main-repository release workflow publishes. name: Sandbox on: @@ -30,7 +29,7 @@ jobs: # an OS×runner matrix — bwrap and Landlock on Linux (separate legs: the # Landlock files force the bwrap rung off, so each leg proves exactly one # rung; Landlock twice, once per architecture, each confining through the - # registry-installed launcher), Seatbelt on macOS (sandbox-exec ships with + # locally built launcher), Seatbelt on macOS (sandbox-exec ships with # the OS). One node # version only: kernel confinement does not vary by node, and ci.yml's # node matrix already covers the node axis. @@ -80,6 +79,14 @@ jobs: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 \ || echo "apparmor userns knob absent — the functional probe decides" + - name: Build Landlock launcher for this architecture + if: matrix.runner == 'landlock' + run: | + sudo apt-get update -q + sudo apt-get install -yq musl-tools + pnpm --dir native/landlock-run run build:ts + pnpm --dir native/landlock-run run build:native + # The unit suite runs on ubuntu in `checks`; this is the one darwin leg # in the workflow, so run it here too — the platform-dependent unit # expectations (Seatbelt path canonicalization: /tmp IS /private/tmp) @@ -105,14 +112,10 @@ jobs: # the very platform that exists to prove it) is a failure, not a pass. echo "$out" | grep -qE 'Test Files[[:space:]]+2 passed \(2\)' - # Publish-path rehearsal, Landlock legs only (the pack gates need built - # lib/). The e2e packs the workspace closure, installs the tarballs - # into a throwaway consumer — npm pulling `node-addon-landlock-run` - # and its platform package from the registry, the true consumer path — - # and confines through the INSTALLED launcher, asserting it executable - # apart (a mode-stripped binary must not masquerade as a non-enforcing - # kernel). Same no-silent-skip guard as above. - - name: Build packages (lib/ for the pack rehearsal) + # Publish-path rehearsal, Landlock legs only. Build the launcher on its + # native architecture, then install the local native and harness tarballs + # together so registry state cannot mask source/package drift. + - name: Build packages for the pack rehearsal if: matrix.runner == 'landlock' run: pnpm run build diff --git a/.oxlintrc.staged.json b/.oxlintrc.staged.json new file mode 100644 index 0000000000..db79a933fa --- /dev/null +++ b/.oxlintrc.staged.json @@ -0,0 +1,7 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "extends": ["./.oxlintrc.json"], + "options": { + "typeAware": false + } +} diff --git a/AGENTS.md b/AGENTS.md index c265d3cf32..64f2e944a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,7 @@ packages/ @deepseek-ai/dsh- workspaces at packages/// support/ dev/test infrastructure util/ zero-dependency utilities python/ Python SDK and bundled runtime (see python/README.md) -native/ node-addon-landlock-run source of record (see native/README.md) +native/ @deepseek-ai/node-addon-landlock-run source of record (see native/README.md) examples/ Runnable cordis.yml leaves over packages/examples bundles (see examples/AGENTS.md) .agents/ Agent workflows and Agent Notes (`notes/`) docs/ architecture, generated catalogs, postmortems, cookbook (see docs/AGENTS.md) @@ -109,7 +109,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **Opaque cross-boundary ids are branded** (`Branded` from `dsh-brand`), never bare `string`. - **Trust TypeScript at typed same-process seams.** Do not add runtime validation, fallback behavior, or hostile-input tests solely for values the static interface requires; validate at parser/config, queued, model/tool JSON, durable/file, worker, process, and wire boundaries. - **Source plane vs artifact plane, never mixed.** Static gates and tests resolve workspace imports through tsconfig `paths` to `src` and pass on a clean tree; gates consuming built `lib/` declare that dependency ([layout](docs/development.md#typescript-project-layout)). -- **`ts.Program` consumers seed `tsconfig.host.json` or `tsconfig.client.json`, never the root solution** — one program holding both sides collides the cordis `Context` merges ([layout](docs/development.md#typescript-project-layout)). +- **Keep compiler faces explicit.** Each package uses one aggregate except `api/remotes`; repo-wide programs seed a face config, never the root solution ([layout](docs/development.md#typescript-project-layout)). - **An empty `catch` names what it swallows** and why nothing else can reach it; keep the `try` to one statement. - **Prefer symmetry for parallel values**; unexplained asymmetry usually signals a missed extraction. - **Tests describe behavior, not correctness.** Change obsolete behavior with its tests; explain why in the PR. diff --git a/README.i18n.yaml b/README.i18n.yaml index 0a7c3e49fe..c4ad1f0fd9 100644 --- a/README.i18n.yaml +++ b/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write README.md -README.md: d8d3e767d5a9805f34f4df57a5b1f8ff7fdaa955 -README.zh.md: 89abf8d817deeed2bf4416035790c8696c8c8e33 +README.md: 64f06c0aec0905fa7deabbec0deea61e1c7a40d4 +README.zh.md: fee03118926028833c828809764ebb5f6375259e diff --git a/README.md b/README.md index d8d3e767d5..64f06c0aec 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ The [CLI contract](apps/cli/README.md#profiles) describes profile layout, layer Run one task, print the final answer, and exit: ```sh -dsh --profile headless "summarize this workspace" +dsh run "summarize this workspace" ``` ### Automation and SDKs diff --git a/README.zh.md b/README.zh.md index 89abf8d817..fee0311892 100644 --- a/README.zh.md +++ b/README.zh.md @@ -56,7 +56,7 @@ profile 布局、层语义与配置输出命令详见 [CLI(命令行界面) 运行一项任务,打印最终答案后退出: ```sh -dsh --profile headless "summarize this workspace" +dsh run "summarize this workspace" ``` ### 自动化与 SDK diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index e53dd292e4..1d169819b4 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -7,7 +7,7 @@ DeepSeek Harness is licensed under [BSD 3-Clause](LICENSE). It depends on the th This file lists **direct** dependencies declared by the workspace and the explicitly disclosed official Claude platform payload closure. It is generated from the workspace manifests by `scripts/gen-third-party-notices.ts`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and `scripts/gen-third-party-notices.spec.ts` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run `pnpm run verify-third-party-notices` for the standalone check. -The complete npm transitive closure, with exact pinned versions, is recorded in [`pnpm-lock.yaml`](pnpm-lock.yaml) — inspect it with `pnpm licenses list`. The Python closure is recorded in [`python/sdk/uv.lock`](python/sdk/uv.lock), and the Landlock launcher workspace keeps its own in [`native/landlock-run/pnpm-lock.yaml`](native/landlock-run/pnpm-lock.yaml). +The complete npm transitive closure, including the Landlock launcher workspace, is recorded with exact pinned versions in [`pnpm-lock.yaml`](pnpm-lock.yaml) — inspect it with `pnpm licenses list`. The Python closure is recorded separately in [`python/sdk/uv.lock`](python/sdk/uv.lock). ## Vendored source (`vendor/`) @@ -57,7 +57,6 @@ External packages that a workspace package resolves at runtime. `scripts/install | [`clsx`](https://github.com/lukeed/clsx) | MIT | | [`commander`](https://github.com/tj/commander.js) | MIT | | [`diff`](https://github.com/kpdecker/jsdiff) | BSD-3-Clause | -| [`dotenv`](https://github.com/motdotla/dotenv) | BSD-2-Clause | | [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT | | [`handlebars`](https://github.com/handlebars-lang/handlebars.js) | MIT | | [`immer`](https://github.com/immerjs/immer) | MIT | @@ -192,6 +191,6 @@ Direct dependencies of the `pyproject.toml` manifests, plus `uv` as the developm | --- | --- | --- | | [`@yao-pkg/pkg`](https://github.com/yao-pkg/pkg) | MIT | invoked by `scripts/build-exe-for-python-sdk.ts` to assemble the single-file SDK runtime executable | -## First-party sibling releases +## First-party native packages -`node-addon-landlock-run` (and its platform packages) is released from a DeepSeek Harness sibling repository under BSD 3-Clause. It is listed here for completeness; it is first-party, not third-party. +`@deepseek-ai/node-addon-landlock-run` (and its platform packages) is built and released from this repository under BSD 3-Clause. It is listed here for completeness; it is first-party, not third-party. diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index cbc74b6d0a..801560e7d7 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/README.md -README.md: f50f26ec5de54e094e17221f7cd355483483754e -README.zh.md: 242e64a0c42064b9f0b7621e665e85fe44523fe5 +README.md: 12108fcbff4e649d0bcb3e01e688fa334ab91b14 +README.zh.md: 9518feed1d5d40c3e5ec2d346b929118ccc08810 diff --git a/apps/cli/README.md b/apps/cli/README.md index f50f26ec5d..12108fcbff 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -9,11 +9,11 @@ The `dsh` command is the product launcher for profiles: ordered stacks of plugin | Command | Purpose | |---|---| | `dsh --profile ` | Boot the named profile under `$DSH_HOME/profiles/`. | -| `dsh --profile headless "task"` | Run one fresh persisted session, print the final answer, and exit. | +| `dsh run [--profile ] [--patch ...] "task"` | Run one fresh persisted session, print the final answer, and exit; the profile defaults to `headless`. | | `dsh web` | Alias of `--profile web` with the Web flag family (`--host`, `--port`, `--dev`, ...). | | `dsh plugin --profile ` | Manage a profile's plugins by forwarding to pnpm in the profile directory. | -The invoking directory is the default workspace root. The `web` and `headless` profiles auto-initialize on first use from shipped templates; any other profile must be created through `dsh plugin`. +The invoking directory is the default workspace root. `dsh run` requires non-blank task text and the selected profile must mount the `headless-runner` row; `--profile` preserves custom one-shot profiles. The `web` and `headless` profiles auto-initialize on first use from shipped templates; any other profile must be created through `dsh plugin`. ## Profiles diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 242e64a0c4..9518feed1d 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -9,11 +9,11 @@ | 命令 | 用途 | |---|---| | `dsh --profile ` | 启动位于 `$DSH_HOME/profiles/` 的指定 profile。 | -| `dsh --profile headless "task"` | 运行一个新的持久化会话,打印最终答案并退出。 | +| `dsh run [--profile ] [--patch ...] "task"` | 运行一个新的持久化会话,打印最终答案并退出;profile 默认为 `headless`。 | | `dsh web` | `--profile web` 的别名,附带 Web flag 系列(`--host`、`--port`、`--dev` 等)。 | | `dsh plugin --profile ` | 通过在 profile 目录中转发给 pnpm 来管理该 profile 的插件。 | -调用目录是默认 workspace 根目录。`web` 和 `headless` profile 在首次使用时会从随附模板自动初始化;其他任何 profile 都必须通过 `dsh plugin` 创建。 +调用目录是默认 workspace 根目录。`dsh run` 要求任务文本非空白,且所选 profile 必须挂载 `headless-runner` 行;`--profile` 保留对自定义一次性 profile 的支持。`web` 和 `headless` profile 在首次使用时会从随附模板自动初始化;其他任何 profile 都必须通过 `dsh plugin` 创建。 ## Profile diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 45dc52561a..8bebf18040 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -80,6 +80,8 @@ flowchart LR cfg --> plugin_dsh_base_skill plugin_dsh_base_skill_local["skill-local
@deepseek-ai/dsh-skill-local"] cfg --> plugin_dsh_base_skill_local + plugin_dsh_base_skill_badge["skill-badge
@deepseek-ai/dsh-skill-badge"] + cfg --> plugin_dsh_base_skill_badge plugin_dsh_base_tool_skill["tool-skill
@deepseek-ai/dsh-tool-skill"] cfg --> plugin_dsh_base_tool_skill plugin_dsh_base_commands["commands
@deepseek-ai/dsh-commands"] @@ -196,6 +198,7 @@ flowchart LR | `workspace-context` | `@deepseek-ai/dsh-workspace-context` | | `skill` | `@deepseek-ai/dsh-skill` | | `skill-local` | `@deepseek-ai/dsh-skill-local` | +| `skill-badge` | `@deepseek-ai/dsh-skill-badge` | | `tool-skill` | `@deepseek-ai/dsh-tool-skill` | | `commands` | `@deepseek-ai/dsh-commands` | | `command-feedback` | `@deepseek-ai/dsh-command-feedback` | diff --git a/apps/cli/package.json b/apps/cli/package.json index 87677ce1f9..a648901cb9 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -36,10 +36,14 @@ "node-addon-require-builtin": "^0.1.4" }, "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-frontend-static": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-llm-mock-server": "workspace:^", "@deepseek-ai/dsh-loader-smoke": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@types/js-yaml": "^4.0.9", diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 07d7810529..27e391320a 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/reference/README.md -README.md: 8b8a0e7dbebafedd6a4f8d988adb3fd11c7bd026 -README.zh.md: d1d6d5a594596a8be5db30021163f0fcea4a95bf +README.md: 496cecdb64e3254a2a77690f55f760b4cd90b521 +README.zh.md: 4673bf764347307a9b91e2a5474a8439cf67b481 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 8b8a0e7dbe..496cecdb64 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -This reference defines the profile, web-alias, plugin-management, and config-dump command modes. Argv is parsed once through [`src/args.ts`](../src/args.ts), and [`src/bin.ts`](../src/bin.ts) dynamically imports only the selected runner. +This reference defines the profile, one-shot run, web-alias, plugin-management, and config-dump command modes. Argv is parsed once through [`src/args.ts`](../src/args.ts), and [`src/bin.ts`](../src/bin.ts) dynamically imports only the selected runner. ## Profile boot @@ -12,7 +12,7 @@ Bundle names resolve from the dsh installation first, then from the profile dire The `web` and `headless` profiles auto-initialize from shipped templates on first use (`web`: base + web-app; `headless`: base + web-app + headless). Any other missing profile fails loud with a hint to run `dsh plugin --profile add `. -A positional task (`dsh --profile headless "run the tests"`) requires the composition to mount the one-shot runner row (`headless-runner`); the launcher patches the task text into that row, the runner drives one fresh persisted session through the in-process API carrier, prints the final assistant text on stdout, and exits 0 on a completed turn, else 1. The session's Web host runs on an OS-assigned port and is announced on stderr, so the run is observable in a browser. +Profile boot accepts no positional task. A profile that mounts the one-shot runner row (`headless-runner`) therefore fails loud with the canonical `dsh run --profile ""` command instead of reaching the row's raw required-field error. Inspect the composed tree without booting it: @@ -23,6 +23,12 @@ dsh --profile web --patch ./extra.yml --dump-config `--dump-default-config` prints only the bundle layers; `--dump-config` adds the profile's `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and `--patch` overlays. Both print provenance comments per layer; `!!js` expressions remain unevaluated, and unmatched patch targets are reported on stderr. +## One-shot run + +`dsh run [--profile ] [--patch ...] ` joins the task arguments with spaces, rejects a missing or blank task, and defaults `--profile` to `headless`. Repeatable `--patch` overlays occupy the same layer position as profile-boot overlays. A custom selected profile must mount `headless-runner`; otherwise launch fails before boot with a diagnostic naming that missing row. + +The launcher patches the task text into the runner row, which drives one fresh persisted session through the in-process API carrier, prints the final assistant text on stdout, and exits 0 on a completed turn, else 1. At the idle boundary, the runner waits until its mux consumer has observed the session's final event sequence before deriving that output and exit reason. The session's Web host runs on an OS-assigned port and is announced on stderr, so the run is observable in a browser. + ## Plugin management `dsh plugin --profile ` initializes the profile when missing (shipped template, or `@deepseek-ai/dsh-base` alone for other names), then forwards `` to `pnpm` with the profile directory as working directory — `add`, `remove`, `why`, `update`, and every other pnpm verb work unchanged; pnpm must be on PATH. Relative path specs (`.`, `../plugin`, and their `file:`/`link:` forms) are anchored to the invoking directory first, so `add .` from a plugin checkout installs that checkout, not the profile. After every successful run, `dsh.profile.bundles` is reconciled against the installed state: each dependency resolving to a package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` joins the layer stack (so an `update` that gains the declaration activates it), a bundle-less dependency stays plain with a one-time warning, and a removed dependency leaves the stack. @@ -59,7 +65,7 @@ New sessions default to the `workspace-write` permission preset. Bash and filesy ## Shared deployment behavior -The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search`, repository Plugin support, and session telemetry. Provider credentials live in `$DSH_HOME/.env` or the ambient environment and remain rotatable because the launcher never hoists the credential file into `process.env`. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`; `web_fetch` is disabled unless a patch layer inserts a provider and enables it. +The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search`, repository Plugin support, and session telemetry. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`; `web_fetch` is disabled unless a patch layer inserts a provider and enables it. Session events stream as OTLP/HTTP logs by default. `DSH_TELEMETRY_OTLP_URL` selects another collector. Any non-empty `DSH_TELEMETRY_DISABLED` disables the telemetry row before boot. The shipped base has no telemetry redaction rule, so exported records can contain message text, tool arguments and results, and workspace paths; the [telemetry Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md) owns that deployment decision. diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index d1d6d5a594..4673bf7643 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -本参考定义 profile、web 别名、插件管理和配置 dump 命令模式。参数由 [`src/args.ts`](../src/args.ts) 统一解析,[`src/bin.ts`](../src/bin.ts) 只动态导入选中的运行器。 +本参考定义 profile、一次性运行、web 别名、插件管理和配置 dump 命令模式。参数由 [`src/args.ts`](../src/args.ts) 统一解析,[`src/bin.ts`](../src/bin.ts) 只动态导入选中的运行器。 ## Profile 启动 @@ -12,7 +12,7 @@ `web` 和 `headless` profile 首次使用时会从随附模板自动初始化(`web`:base + web-app;`headless`:base + web-app + headless)。其他缺失的 profile 会显式报错,并提示运行 `dsh plugin --profile add `。 -位置参数任务(`dsh --profile headless "run the tests"`)要求组合挂载一次性运行器行(`headless-runner`);启动器把任务文本 patch 进该行,运行器通过进程内 API 载体驱动一个全新的持久化会话,在 stdout 打印最终 assistant 文本,并在轮次完成时以 0 退出,否则以 1 退出。会话的 Web 宿主运行在 OS 分配的端口上并公布到 stderr,因此该次运行可在浏览器中观察。 +Profile 启动不接受位置参数任务。因此,挂载了一次性运行器行(`headless-runner`)的 profile 会显式报错,并提示规范命令 `dsh run --profile ""`,而不会触发该行原始的必填字段错误。 可在不启动的情况下检查组合出的配置树: @@ -23,6 +23,12 @@ dsh --profile web --patch ./extra.yml --dump-config `--dump-default-config` 只打印组合包各层;`--dump-config` 额外加上 profile 的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 和 `--patch` overlay。两者都会按层打印来源注释;`!!js` 表达式保持未求值,找不到目标的 patch 会报告到 stderr。 +## 一次性运行 + +`dsh run [--profile ] [--patch ...] ` 会用空格拼接任务参数,拒绝缺失或空白任务,并让 `--profile` 默认为 `headless`。可重复使用的 `--patch` overlay 与 profile 启动的 overlay 位于同一层。所选的自定义 profile 必须挂载 `headless-runner`;否则启动器会在启动前失败,并在诊断中指明缺少该行。 + +启动器把任务文本 patch 进运行器行,运行器再通过进程内 API 载体驱动一个全新的持久化会话,在 stdout 打印最终 assistant 文本,并在轮次完成时以 0 退出,否则以 1 退出。到达 idle 边界时,运行器会等到 mux 消费方观察到会话的最终事件序号,再生成输出与退出原因。会话的 Web 宿主运行在 OS 分配的端口上并公布到 stderr,因此该次运行可在浏览器中观察。 + ## 插件管理 `dsh plugin --profile ` 在 profile 缺失时先初始化它(有随附模板的用模板,其他名称只装 `@deepseek-ai/dsh-base`),然后以 profile 目录为工作目录,把 `` 转发给 `pnpm`:`add`、`remove`、`why`、`update` 及其他所有 pnpm 子命令都照常可用;pnpm 必须在 PATH 上。相对路径 spec(`.`、`../plugin` 及其 `file:`/`link:` 形式)会先锚定到调用目录,因此在插件 checkout 中执行 `add .` 安装的是该 checkout,而不是 profile。每次成功运行后,`dsh.profile.bundles` 都会与已安装状态对齐:每个解析到 manifest 中声明了 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的包的依赖加入层栈(因此让包获得该声明的 `update` 会将其激活),没有组合包声明的依赖保持为普通依赖并给出一次性警告,已移除的依赖则退出层栈。 @@ -59,7 +65,7 @@ dsh web --dump-config ## 共享部署行为 -基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search`、repository Plugin 支持和会话遥测。提供方凭据存放在 `$DSH_HOME/.env` 或环境中;启动器从不把凭据文件提升到 `process.env`,因此凭据可以轮换。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`;只有 patch 层插入提供方并启用 `web_fetch` 后,该工具才可用。 +基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search`、repository Plugin 支持和会话遥测。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`;只有 patch 层插入提供方并启用 `web_fetch` 后,该工具才可用。 会话事件默认作为 OTLP/HTTP 日志流式发送。`DSH_TELEMETRY_OTLP_URL` 选择其他 collector。任何非空 `DSH_TELEMETRY_DISABLED` 都会在启动前禁用遥测配置行。随附基础配置没有遥测脱敏规则,因此导出的记录可能包含消息文本、工具参数与结果以及 workspace 路径;该部署决策由[遥测 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md)负责。 diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index 310b5b03a2..d96b7a503f 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -1,10 +1,10 @@ /** * Commander adapter for the `dsh` command-line entry. The default command * boots a named profile (`--profile `), optionally with extra `--patch` - * overlays and a positional task (one-shot mode for profiles mounting the - * headless runner). `web` is a hardcoded alias for `--profile web` that adds - * the Web flag family; `plugin` manages a profile's plugin dependencies by - * forwarding to pnpm. Commander owns help, version, and parse errors. + * overlays. `run` owns one-shot task execution, defaulting to the headless + * profile; `web` is a hardcoded alias for `--profile web` that adds the Web + * flag family; `plugin` manages a profile's plugin dependencies by forwarding + * to pnpm. Commander owns help, version, and parse errors. * @module @deepseek-ai/dsh/args */ @@ -16,8 +16,16 @@ interface ProfileInvocation { profile: string /** Extra patch-list overlays applied after the profile's own layer, in argv order. */ patches: string[] - /** Positional task text joined by spaces; non-empty only for one-shot runs. */ - task?: string +} + +/** Run one task through a profile mounting the headless runner. */ +interface RunInvocation { + mode: 'run' + profile: string + /** Extra patch-list overlays applied after the profile's own layer, in argv order. */ + patches: string[] + /** Non-blank task text joined from the variadic positional arguments. */ + task: string } /** Print a composed profile tree and exit without booting. */ @@ -54,7 +62,7 @@ interface PluginInvocation { } /** The resolved `dsh` invocation. Help, version, and errors exit inside {@link parseDshArgs}. */ -export type DshInvocation = ProfileInvocation | DumpConfigInvocation | WebInvocation | PluginInvocation +export type DshInvocation = ProfileInvocation | RunInvocation | DumpConfigInvocation | WebInvocation | PluginInvocation /** Raw web-subcommand options straight from Commander. */ interface WebOptions { @@ -68,6 +76,12 @@ interface WebOptions { dumpDefaultConfig?: boolean } +/** Raw run-subcommand options straight from Commander. */ +interface RunOptions { + profile: string + patch?: string[] +} + /** * Repeatable single-value collector: `--patch a.yml --patch b.yml`. Never * variadic — a variadic `--patch` would swallow a following positional task. @@ -90,19 +104,19 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc .addHelpText('after', ` Examples: dsh --profile web boot the web profile (same as: dsh web) - dsh --profile headless "run the tests" answer one task, print the result, and exit + dsh run "run the tests" answer one task, print the result, and exit + dsh run --profile custom "run the tests" run one task through a custom one-shot profile dsh --profile tui --patch ./extra.yml boot a custom profile with one extra overlay dsh plugin --profile tui add install a plugin into the tui profile dsh web --port 8080 the web alias with its flag family `) .exitOverride() .enablePositionalOptions() - .argument('[task...]', 'one-shot task text for profiles mounting the headless runner') .option('--profile ', 'the profile under $DSH_HOME/profiles to boot') .option('--patch ', 'extra patch-list overlay applied after the profile layer (repeatable)', collect) .option('--dump-config', 'print the composed profile tree and exit') .option('--dump-default-config', 'print the profile tree without its user layer or --patch overlays and exit') - .action((task: string[], options: { + .action((options: { profile?: string patch?: string[] dumpConfig?: boolean @@ -116,7 +130,6 @@ Examples: if (options.dumpConfig === true && options.dumpDefaultConfig === true) { program.error('error: --dump-config and --dump-default-config are mutually exclusive') } - if (task.length > 0) program.error('error: --dump-config/--dump-default-config take no task') const defaultOnly = options.dumpDefaultConfig === true if (defaultOnly && patches.length > 0) { program.error('error: --dump-default-config prints the bundle layers and takes no --patch') @@ -124,12 +137,7 @@ Examples: resolved = { mode: 'dump-config', profile, defaultOnly, patches } return } - resolved = { - mode: 'profile', - profile, - patches, - ...task.length > 0 ? { task: task.join(' ') } : {}, - } + resolved = { mode: 'profile', profile, patches } }) /** Reject parent options that crossed a subcommand boundary. */ @@ -146,6 +154,22 @@ Examples: } } + const run = program.command('run').description('run one task through a profile mounting the headless runner') + run + .option('--profile ', 'one-shot profile under $DSH_HOME/profiles', 'headless') + .option('--patch ', 'extra patch-list overlay applied after the profile layer (repeatable)', collect) + .argument('', 'task text') + .action((task: string[], options: RunOptions) => { + rejectParentOptions('run') + const profile = options.profile + if (profile === '') program.error('error: --profile needs a name') + const patches = options.patch ?? [] + if (patches.includes('')) program.error('error: --patch needs a path') + const joined = task.join(' ') + if (joined.trim() === '') program.error('error: run needs a non-blank task') + resolved = { mode: 'run', profile, patches, task: joined } + }) + const web = program.command('web').description('serve the browser UI (alias of --profile web) on the configured host and port') web .option('--patch ', 'extra patch-list overlay applied after the profile layer (repeatable)', collect) diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index 4783c2d175..b332a64615 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -10,7 +10,7 @@ import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' -import { loadEnv } from '@deepseek-ai/dsh-app-boot' +import { loadLayeredEnv } from '@deepseek-ai/dsh-app-boot' import { parseDshArgs } from './args.ts' // Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) sit @@ -24,22 +24,31 @@ function readVersion(): string { return typeof manifest.version === 'string' ? manifest.version : '0.0.0' } -loadEnv('dsh') const invocation = parseDshArgs(process.argv.slice(2), readVersion()) switch (invocation.mode) { case 'profile': { const { runProfile } = await import('./profile-boot.ts') await runProfile({ + environment: loadLayeredEnv('dsh'), profile: invocation.profile, patchFiles: invocation.patches, - ...invocation.task !== undefined && { task: invocation.task }, + }) + break + } + case 'run': { + const { runProfile } = await import('./profile-boot.ts') + await runProfile({ + environment: loadLayeredEnv('dsh'), + profile: invocation.profile, + patchFiles: invocation.patches, + task: invocation.task, }) break } case 'web': { const { runWeb } = await import('./web.ts') - await runWeb(invocation) + await runWeb(invocation, loadLayeredEnv('dsh')) break } case 'plugin': { diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index b69a938213..4730ec7073 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -25,6 +25,7 @@ import { type Profile, } from '@deepseek-ai/dsh-app-boot' import { resolveDshHome } from '@deepseek-ai/dsh-paths' +import { DSH_ENVIRONMENT_KEY, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment' import type { HeadlessIo } from '@deepseek-ai/dsh-headless' import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts' @@ -46,7 +47,7 @@ export const INSTALL_ANCHOR = fileURLToPath(new URL('../package.json', import.me /** The session-telemetry row id the DSH_TELEMETRY_DISABLED switch targets. */ const TELEMETRY_ROW_ID = 'telemetry-otel' -/** The one-shot runner row a positional task requires and configures. */ +/** The one-shot runner row a `dsh run` task requires and configures. */ const HEADLESS_ROW_ID = 'headless-runner' /** The empty root entry list every profile tree patches over. */ @@ -159,10 +160,12 @@ export interface RunProfileOptions { patchFiles: readonly string[] /** Launcher hook turning the pre-flag composed rows into flag patches (the web alias's flag family). */ deriveFlagPatches?: (rows: ProfileRows) => PatchOptions[] - /** One-shot task text; requires the composition to mount the headless runner row. */ + /** `dsh run` task text; requires the composition to mount the headless runner row. */ task?: string /** Surface setup registered after Loader installation and before any config-tree entry mounts. */ prepare?: (ctx: Context, rows: ProfileRows) => Promise | void + /** This run's frozen environment snapshot, provided to the tree before any entry mounts. */ + environment: EnvironmentSnapshot } /** @@ -187,7 +190,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con // error naming no fix. throw new Error( `dsh: profile ${JSON.stringify(options.profile)} mounts the one-shot runner and needs a task: ` - + `dsh --profile ${options.profile} ""`, + + `dsh run --profile ${options.profile} ""`, ) } @@ -226,6 +229,9 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con // application must not mutate the objects later reloads recompose from. const ctx = await boot(NAME, rootConfig, structuredClone(allPatches(composed)), async (hostCtx) => { app.current = hostCtx + // Before any config-tree entry mounts, so a plugin that resolves a + // user-facing value at construction already sees this run's layers. + hostCtx.provide(DSH_ENVIRONMENT_KEY, options.environment) if (options.task !== undefined) { const io: HeadlessIo = { stdout: process.stdout, diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 64051416c4..72abee67d0 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -13,6 +13,7 @@ import { fileURLToPath } from 'node:url' import type { Context } from 'cordis' import type { PatchOptions } from '@cordisjs/plugin-include' import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot' +import type { EnvironmentSnapshot } from '@deepseek-ai/dsh-environment' import { runProfile, type ProfileRows } from './profile-boot.ts' const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) @@ -124,9 +125,11 @@ export function webSurfaceContextEnabled(rows: ProfileRows): boolean { * every boot. The URL line is printed by the web-app bundle's runtime row * after Loader settlement. * @param flags - the parsed `dsh web` flag family. + * @param environment - this run's frozen environment snapshot. */ -export async function runWeb(flags: WebFlags): Promise { +export async function runWeb(flags: WebFlags, environment: EnvironmentSnapshot): Promise { await runProfile({ + environment, profile: 'web', patchFiles: flags.patches, deriveFlagPatches: rows => deriveWebFlagPatches(rows, flags), diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index 93bfb62cc6..c9b3dc18f1 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -21,12 +21,16 @@ function exitCode(argv: string[]): number { afterEach(() => { vi.restoreAllMocks() }) describe('parseDshArgs', () => { - it('routes profile boots, one-shot tasks, and the web alias', () => { + it('routes profile boots, one-shot runs, and the web alias', () => { expect(parse(['--profile', 'tui'])).toEqual({ mode: 'profile', profile: 'tui', patches: [] }) - expect(parse(['--profile', 'headless', 'run', 'the', 'tests'])) - .toEqual({ mode: 'profile', profile: 'headless', patches: [], task: 'run the tests' }) expect(parse(['--profile', 'tui', '--patch', 'a.yml', '--patch', 'b.yml'])) .toEqual({ mode: 'profile', profile: 'tui', patches: ['a.yml', 'b.yml'] }) + expect(parse(['run', 'run', 'the', 'tests'])) + .toEqual({ mode: 'run', profile: 'headless', patches: [], task: 'run the tests' }) + expect(parse(['run', '--profile', 'custom', '--patch', 'a.yml', '--patch', 'b.yml', 'run', 'the', 'tests'])) + .toEqual({ mode: 'run', profile: 'custom', patches: ['a.yml', 'b.yml'], task: 'run the tests' }) + expect(parse(['run', '--', '--profile', 'is', 'task', 'text'])) + .toEqual({ mode: 'run', profile: 'headless', patches: [], task: '--profile is task text' }) expect(parse(['web'])).toEqual({ mode: 'web', dev: false, patches: [] }) expect(parse(['web', '--patch', 'web.yml'])).toEqual({ mode: 'web', dev: false, patches: ['web.yml'] }) expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev', '--workspace-root', '/w'])) @@ -65,6 +69,13 @@ describe('parseDshArgs', () => { expect(exitCode(['tui'])).toBe(1) // a bare word is a task without --profile expect(exitCode(['--config', 'c.yml'])).toBe(1) // removed expect(exitCode(['-p', 'task'])).toBe(1) // removed + expect(exitCode(['--profile', 'headless', 'task'])).toBe(1) // tasks belong to `run` + expect(exitCode(['run'])).toBe(1) + expect(exitCode(['run', ''])).toBe(1) + expect(exitCode(['run', '--profile', '', 'task'])).toBe(1) + expect(exitCode(['run', '--patch=', 'task'])).toBe(1) + expect(exitCode(['--profile', 'headless', 'run', 'task'])).toBe(1) + expect(exitCode(['--patch', 'parent.yml', 'run', 'task'])).toBe(1) expect(exitCode(['--profile', ''])).toBe(1) expect(exitCode(['--profile', 'x', '--patch='])).toBe(1) expect(exitCode(['--dump-config'])).toBe(1) @@ -90,6 +101,7 @@ describe('parseDshArgs', () => { it('exits 0 for help and version', () => { expect(exitCode(['--help'])).toBe(0) + expect(exitCode(['run', '--help'])).toBe(0) expect(exitCode(['--version'])).toBe(0) }) }) diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index ede3d17134..a42780581f 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -2,6 +2,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' +import { startMockLlmServer } from '@deepseek-ai/dsh-llm-mock-server' import { execa } from 'execa' import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -13,14 +14,21 @@ const invalidProvider = fileURLToPath(new URL('./fixtures/invalid-provider.cordi async function runBuiltBin( args: readonly string[] = [], - env: Record = {}, + env: Readonly> = {}, + cwd?: string, ): Promise<{ stdout: string; code: number; stderr: string }> { + const childEnv = Object.fromEntries( + Object.entries({ ...process.env, ...env }) + .filter((entry): entry is [string, string] => entry[1] !== undefined), + ) const result = await execa(process.execPath, [dshBin, ...args], { input: '', timeout: 25_000, killSignal: 'SIGKILL', reject: false, - env, + env: childEnv, + extendEnv: false, + ...cwd === undefined ? {} : { cwd }, }) if (result.timedOut) { throw new Error(`dsh built bin did not exit within 25s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) @@ -127,6 +135,44 @@ function startProfileLifecycle(fixture: ProfileLifecycleFixture) { }) } +function createEnvironmentProbeProfile(home: string, project: string): void { + const pluginFile = join(project, 'environment-probe.mjs') + writeFileSync(pluginFile, [ + "export const name = 'environment-probe'", + "export const inject = ['llm']", + 'export function apply(ctx) {', + ' void ctx.loader.await().then(async () => {', + " let text = ''", + ' for await (const chunk of ctx.llm.stream({', + " provider: 'deepseek-official',", + " model: 'deepseek-v4-flash',", + ' messages: [],', + ' maxTokens: 32,', + ' })) {', + " if (chunk.type === 'text-delta') text += chunk.text", + ' }', + ' process.stdout.write(`${text}\\n`)', + " process.kill(process.pid, 'SIGTERM')", + ' })', + '}', + '', + ].join('\n')) + const profileDir = join(home, 'profiles', 'environment-probe') + mkdirSync(profileDir, { recursive: true }) + writeFileSync(join(profileDir, 'package.json'), JSON.stringify({ + name: 'dsh-profile-environment-probe', + private: true, + dependencies: {}, + dsh: { profile: { bundles: ['@deepseek-ai/dsh-base'] } }, + }, undefined, 2)) + writeFileSync(join(profileDir, 'cordis.patch.yml'), [ + '- insert:', + ' - id: environment-probe', + ` name: ${pathToFileURL(pluginFile).href}`, + '', + ].join('\n')) +} + describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => { it('requires --profile and rejects removed commands', async () => { const bare = await runBuiltBin() @@ -136,14 +182,67 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', const help = await runBuiltBin(['--help']) expect(help.code).toBe(0) expect(help.stdout).toContain('dsh --profile web') + expect(help.stdout).toContain('dsh run "run the tests"') expect(help.stdout).toContain('dsh plugin --profile') expect(help.stdout).not.toMatch(/^\s+(?:tui|meta|upgrade)\b/mu) - for (const removed of [['tui'], ['--config', 'x.yml'], ['-p', 'task']]) { + for (const removed of [['tui'], ['--config', 'x.yml'], ['-p', 'task'], ['--profile', 'headless', 'task']]) { const result = await runBuiltBin(removed) expect(result.code).toBe(1) } }, 30_000) + it('prints run help without initializing the selected profile', async () => { + const parent = mkdtempSync(join(tmpdir(), 'dsh-run-help-')) + const home = join(parent, 'not-created') + try { + const result = await runBuiltBin(['run', '--help'], { DSH_HOME: home }) + expect(result.code).toBe(0) + expect(result.stderr).toBe('') + expect(result.stdout).toContain('Usage: dsh run [options] ') + expect(existsSync(home)).toBe(false) + } finally { + rmSync(parent, { recursive: true, force: true }) + } + }) + + it('runs the default headless profile through the published run command', async () => { + const apiKey = 'built-dsh-run-key' + const server = await startMockLlmServer({ + sequence: ['success'], + apiKey, + successText: 'published dsh run reached the mock', + }) + const home = mkdtempSync(join(tmpdir(), 'dsh-built-run-')) + try { + const result = await runBuiltBin(['run', 'answer', 'from', 'the', 'published', 'entry'], { + DSH_HOME: home, + DSH_TELEMETRY_DISABLED: '1', + DEEPSEEK_API_KEY: apiKey, + DEEPSEEK_BASE_URL: server.baseURL, + }) + expect(result.code, result.stderr).toBe(0) + expect(result.stdout).toBe('published dsh run reached the mock') + expect(result.stderr).toMatch(/^dsh: observing at http:\/\/127\.0\.0\.1:\d+$/u) + expect(server.requests.length).toBeGreaterThan(0) + expect(server.requests.every(request => request.path === '/chat/completions')).toBe(true) + expect(JSON.stringify(server.requests.map(request => request.body))).toContain('answer from the published entry') + } finally { + await server.close() + rmSync(home, { recursive: true, force: true }) + } + }, 30_000) + + it('does not load a project environment for --version', async () => { + const project = mkdtempSync(join(tmpdir(), 'dsh-version-project-')) + writeFileSync(join(project, '.env'), 'PATH=/project-only-path\n') + try { + const result = await runBuiltBin(['--version'], {}, project) + expect(result).toEqual({ code: 0, stdout: '0.0.1', stderr: '' }) + } finally { + rmSync(project, { recursive: true, force: true }) + } + }) + it('fails loud on a nonexistent profile with the plugin-command hint', async () => { const home = mkdtempSync(join(tmpdir(), 'dsh-missing-profile-')) try { @@ -156,6 +255,47 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', } }, 30_000) + it('uses the Harness-home environment and managed credential through the published entry', async () => { + const apiKey = 'built-home-layer-key' + const server = await startMockLlmServer({ + sequence: ['success'], + apiKey, + successText: 'home environment reached the mock', + }) + const home = mkdtempSync(join(tmpdir(), 'dsh-home-environment-')) + const project = mkdtempSync(join(tmpdir(), 'dsh-home-project-')) + writeFileSync(join(home, '.env'), `DEEPSEEK_BASE_URL=${server.baseURL}\n`) + writeFileSync(join(home, '.credentials.yaml'), `DEEPSEEK_API_KEY: ${apiKey}\n`, { mode: 0o600 }) + createEnvironmentProbeProfile(home, project) + try { + const result = await runBuiltBin( + ['--profile', 'environment-probe'], + { + DSH_HOME: home, + DSH_TELEMETRY_DISABLED: '1', + DEEPSEEK_API_KEY: undefined, + DEEPSEEK_BASE_URL: undefined, + }, + project, + ) + expect( + result.code, + `${result.stderr}\nstdout:\n${result.stdout}\nmock requests: ${String(server.requests.length)}`, + ).toBe(0) + expect(result.stdout).toBe('home environment reached the mock') + expect(result.stdout).not.toContain(apiKey) + expect(result.stderr).not.toContain(apiKey) + expect(server.requests).toHaveLength(1) + expect(server.requests[0]?.path).toBe('/chat/completions') + expect(server.requests[0]?.headers.authorization).toBe(`Bearer ${apiKey}`) + expect(JSON.stringify(server.requests[0]?.body)).not.toContain(apiKey) + } finally { + await server.close() + rmSync(home, { recursive: true, force: true }) + rmSync(project, { recursive: true, force: true }) + } + }, 30_000) + it('reports a patch-overlay boot failure without hanging', async () => { // The HMR main watcher's initial scan once refreshed the include // mid-initial-apply, deadlocking the failing apply's rollback against the diff --git a/apps/cli/tests/dsh-badge.snapshot.ts b/apps/cli/tests/dsh-badge.snapshot.ts new file mode 100644 index 0000000000..00d5278f82 --- /dev/null +++ b/apps/cli/tests/dsh-badge.snapshot.ts @@ -0,0 +1,176 @@ +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' +const binScript = fileURLToPath(new URL('./fixtures/dsh-badge/snapshot.ts', import.meta.url)) +const configPath = fileURLToPath(new URL('./fixtures/dsh-badge/cordis.yml', import.meta.url)) +const defaultConfigPath = fileURLToPath(new URL('./fixtures/dsh-badge/default.cordis.yml', import.meta.url)) +const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) +const badgeAssetsPath = fileURLToPath(new URL('../../../packages/skill/skill-badge/assets/', import.meta.url)) + +describe('dsh badge assembled snapshot', () => { + it('advertises and loads the opt-in bundled skill through the shipped app', async () => { + const disabled = await runLoaderSmoke({ + label: 'disabled dsh badge skill snapshot', + tempDirPrefix: 'headless-snapshot-dsh-badge-disabled-', + binScript, + libBinScript: binScript, + configPath: defaultConfigPath, + tsconfigPath, + }) + const enabled = await runLoaderSmoke({ + label: 'dsh badge skill snapshot', + tempDirPrefix: 'headless-snapshot-dsh-badge-', + binScript, + libBinScript: binScript, + configPath, + tsconfigPath, + }) + const disabledSnapshot = JSON.parse(disabled.stdout) as unknown + const enabledSnapshot = JSON.parse( + enabled.stdout.replaceAll(badgeAssetsPath, '{{badgeAssetsPath}}'), + ) as unknown + + expect(disabled.stderr).toBe('') + expect(enabled.stderr).toBe('') + expect(disabledSnapshot).toMatchInlineSnapshot(` + { + "catalog": null, + "result": { + "content": [ + { + "text": "Error: skill "dsh-badge" is unknown or no longer available", + "type": "text", + }, + ], + "error": { + "message": "skill "dsh-badge" is unknown or no longer available", + }, + "isError": true, + }, + "summary": null, + } + `) + expect(enabledSnapshot).toMatchInlineSnapshot(` + { + "catalog": [ + { + "text": " + A skill is a reusable set of task-specific instructions. The following skills are available in this session: + + + - \`dsh-badge\`: Add the official “powered by dsh” badge to documents, pull requests, merge requests, and other content produced with DeepSeek Harness. Use whenever creating a pull request or merge request. Also use when the user asks for a dsh badge, powered-by-dsh attribution, or a reusable dsh badge asset or snippet. + + + If the user names a skill, or the task clearly matches a skill's description, call the \`skill\` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded. + A user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the \`skill\` tool again for that skill. + ", + "type": "text", + }, + ], + "result": { + "content": [ + { + "text": " + + Base directory for this skill: {{badgeAssetsPath}} + Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed. + + + + # dsh Badge + + Add the official “powered by dsh” badge without recreating or restyling it. + + ## Assets + + - Local PNG: [\`dsh-badge.png\`](dsh-badge.png), 726×120 source image; render at 121×20 + - Shields.io image URL: \`https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white\` + - Project URL: \`https://github.com/deepseek-ai/deepseek-harness-sdk\` + + ## Markdown + + Use this linked badge in Markdown: + + \`\`\`markdown + [![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) + \`\`\` + + If attribution should not be linked, use: + + \`\`\`markdown + ![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white) + \`\`\` + + ## Usage rules + + - For GitHub or GitLab Markdown, use the Shields.io URL and link it to the project URL unless the user asks for an unlinked image. + - For Feishu and other systems that import remote images unreliably, upload \`dsh-badge.png\` from this skill directory instead of generating another badge. + - Preserve the badge's 121×20 dimensions and aspect ratio. + - Place the badge at the end of the attributed document or section unless the user specifies another position. + - Do not substitute another color, logo, label, or project URL. + + + ", + "type": "text", + }, + ], + "isError": false, + "value": { + "content": "# dsh Badge + + Add the official “powered by dsh” badge without recreating or restyling it. + + ## Assets + + - Local PNG: [\`dsh-badge.png\`](dsh-badge.png), 726×120 source image; render at 121×20 + - Shields.io image URL: \`https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white\` + - Project URL: \`https://github.com/deepseek-ai/deepseek-harness-sdk\` + + ## Markdown + + Use this linked badge in Markdown: + + \`\`\`markdown + [![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) + \`\`\` + + If attribution should not be linked, use: + + \`\`\`markdown + ![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white) + \`\`\` + + ## Usage rules + + - For GitHub or GitLab Markdown, use the Shields.io URL and link it to the project URL unless the user asks for an unlinked image. + - For Feishu and other systems that import remote images unreliably, upload \`dsh-badge.png\` from this skill directory instead of generating another badge. + - Preserve the badge's 121×20 dimensions and aspect ratio. + - Place the badge at the end of the attributed document or section unless the user specifies another position. + - Do not substitute another color, logo, label, or project URL. + ", + "name": "dsh-badge", + "provider": "dsh-badge", + "resourceBase": { + "kind": "directory", + "path": "{{badgeAssetsPath}}", + }, + }, + }, + "summary": { + "description": "Add the official “powered by dsh” badge to documents, pull requests, merge requests, and other content produced with DeepSeek Harness. Use whenever creating a pull request or merge request. Also use when the user asks for a dsh badge, powered-by-dsh attribution, or a reusable dsh badge asset or snippet.", + "invocation": { + "modelInvocable": true, + "userInvocable": true, + }, + "name": "dsh-badge", + "provider": "dsh-badge", + "resourceBase": { + "kind": "directory", + "path": "{{badgeAssetsPath}}", + }, + "source": "bundled", + }, + } + `) + }, LOADER_SMOKE_TEST_TIMEOUT_MS * 2) +}) diff --git a/apps/cli/tests/fixtures/dsh-badge/cordis.yml b/apps/cli/tests/fixtures/dsh-badge/cordis.yml new file mode 100644 index 0000000000..b3bfdb1b04 --- /dev/null +++ b/apps/cli/tests/fixtures/dsh-badge/cordis.yml @@ -0,0 +1,9 @@ +- id: skill-badge + disabled: false + +- id: skill-local + config: + watch: false + +- id: telemetry-otel + disabled: true diff --git a/apps/cli/tests/fixtures/dsh-badge/default.cordis.yml b/apps/cli/tests/fixtures/dsh-badge/default.cordis.yml new file mode 100644 index 0000000000..ac3e48441a --- /dev/null +++ b/apps/cli/tests/fixtures/dsh-badge/default.cordis.yml @@ -0,0 +1,6 @@ +- id: skill-local + config: + watch: false + +- id: telemetry-otel + disabled: true diff --git a/apps/cli/tests/fixtures/dsh-badge/snapshot.ts b/apps/cli/tests/fixtures/dsh-badge/snapshot.ts new file mode 100644 index 0000000000..8d6019ab53 --- /dev/null +++ b/apps/cli/tests/fixtures/dsh-badge/snapshot.ts @@ -0,0 +1,56 @@ +import { fileURLToPath } from 'node:url' +import { Context } from 'cordis' +import { agentEvents, Inbox, type Agent } from '@deepseek-ai/dsh-agent' +import { CallId } from '@deepseek-ai/dsh-llm' +import { boot, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' +import { SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-skill' +import type {} from '@deepseek-ai/dsh-tools' + +const overlayPath = process.argv[2] +if (overlayPath === undefined) throw new Error('dsh-badge snapshot requires an overlay path') +const rootConfigPath = fileURLToPath(new URL('../../../../../packages/bundle/base/tests/fixtures/root.cordis.yml', import.meta.url)) +const basePatchPath = fileURLToPath(new URL('../../../../../packages/bundle/base/cordis.patch.yml', import.meta.url)) +const ctx = await boot('dsh-badge-snapshot', rootConfigPath, [ + ...loadOverlayPatches('dsh-badge-snapshot', basePatchPath), + ...loadOverlayPatches('dsh-badge-snapshot', overlayPath), +]) + +try { + const agentId = SessionId('dsh-badge-snapshot') + const session = ctx.sessions.create(agentId, { meta: { cwd: process.cwd() } }) + const agent: Agent = { + ctx: new Context(), + id: agentId, + options: {}, + session, + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + status: 'idle', + send: () => {}, + followup: () => {}, + steer: () => {}, + inject: () => { throw new Error('dsh-badge snapshot must receive the catalog at the step boundary') }, + cancel: () => {}, + runMaintenance: task => task(new AbortController().signal), + whenIdle: () => Promise.resolve(), + } + const decision = await agentEvents(ctx, agent).waterfall( + 'agent/pre-step', + { messages: [], turn: 1, step: 1, signal: new AbortController().signal }, + () => Promise.resolve({ kind: 'enter' as const, messages: [] }), + ) + const catalog = decision.kind === 'enter' + ? decision.messages.find(message => message.role === 'user' + && message.source.kind === 'skill-catalog')?.content + : undefined + const summary = (await ctx.skills.list()).find(skill => skill.name === 'dsh-badge') + const result = await ctx.tools.execute({ + callId: CallId('dsh-badge-snapshot'), + name: 'skill', + arguments: { name: 'dsh-badge' }, + signal: new AbortController().signal, + }) + process.stdout.write(`${JSON.stringify({ catalog: catalog ?? null, summary: summary ?? null, result })}\n`) +} finally { + await ctx.fiber.dispose() +} diff --git a/apps/cli/tests/headless-shutdown.e2e.ts b/apps/cli/tests/headless-shutdown.e2e.ts index cfa87b03de..237554864c 100644 --- a/apps/cli/tests/headless-shutdown.e2e.ts +++ b/apps/cli/tests/headless-shutdown.e2e.ts @@ -66,7 +66,7 @@ async function runHeadlessPtySmoke(): Promise { try { const home = join(cwd, '.dsh') // Pre-initialize the headless profile with the never-dispose row in its - // user patch layer (the same file `dsh --profile headless` hot-reloads). + // user patch layer (the same file a long-lived profile boot hot-reloads). const profileDir = join(home, 'profiles', 'headless') await mkdir(profileDir, { recursive: true }) await writeFile(join(profileDir, 'package.json'), JSON.stringify({ @@ -83,7 +83,7 @@ async function runHeadlessPtySmoke(): Promise { ].join('\n')) const launch = resolveExampleLaunch({ srcBin: dshBinScript, - configArgs: ['--profile', 'headless', 'never complete'], + configArgs: ['run', 'never complete'], tsconfigPath, env: { DSH_HOME: home, diff --git a/apps/web/tests/assembled-boot.ts b/apps/web/tests/assembled-boot.ts index 729428e47b..53f976af09 100644 --- a/apps/web/tests/assembled-boot.ts +++ b/apps/web/tests/assembled-boot.ts @@ -20,7 +20,7 @@ const PLUGINS: readonly (WebBootEntry & { bundlePath: string })[] = [ { id: '@deepseek-ai/dsh-client-connection', bundlePath: 'packages/client/connection/lib/client.js', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-api-gateway', bundlePath: 'packages/api/gateway/lib/client.js', url: '/plugins/api-gateway.js', rev: 'fx', inject: ['@deepseek-ai/dsh-typert-registry', '@deepseek-ai/dsh-client-connection'], immediately: true }, { id: '@deepseek-ai/dsh-api-remotes', bundlePath: 'packages/api/remotes/lib/client.js', url: '/plugins/api-remotes.js', rev: 'fx', inject: ['@deepseek-ai/dsh-api-gateway'], immediately: true }, - { id: '@deepseek-ai/dsh-client-runtime', bundlePath: 'packages/client/runtime/lib/client.js', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-api-remotes', '@deepseek-ai/dsh-typert-registry'], immediately: true }, + { id: '@deepseek-ai/dsh-client-runtime', bundlePath: 'packages/client/runtime/lib/client.js', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-typert-registry'], immediately: true }, { id: '@deepseek-ai/dsh-client-ui-theme', bundlePath: 'packages/client/ui-theme/lib/client.js', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-locale', bundlePath: 'packages/client/locale/lib/client.js', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-ui-layout', bundlePath: 'packages/client/ui-layout/lib/client.js', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] }, diff --git a/apps/web/tests/declared-reasoning.e2e.ts b/apps/web/tests/declared-reasoning.e2e.ts new file mode 100644 index 0000000000..664f20dfe6 --- /dev/null +++ b/apps/web/tests/declared-reasoning.e2e.ts @@ -0,0 +1,95 @@ +// Web e2e scenario: a hand-declared model's `reasoningEfforts` reaches the +// composer's effort pane — the levels a settings profile declares are exactly +// what the picker offers, and picking one records it with the default route. +// Zero model calls: declaring, describing, and switching are settings/llm +// traffic only, so there is no fixture and a stray stream would fail loud. +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { settingsNamespace } from '@deepseek-ai/dsh-settings' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts' + +/** Starts the shipped default on this scenario's declared reasoning model. */ +const OVERLAY = fileURLToPath(new URL('./declared-reasoning.overlay.yml', import.meta.url)) +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/declared-reasoning', import.meta.url)) +const UI_EXPECTED = fileURLToPath(new URL('./snapshots/declared-reasoning/ui.expected.md', import.meta.url)) +const MODE = webSnapshotMode() + +describe.skipIf(MODE === 'record')('web e2e: declared reasoning efforts reach the composer', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY }) + // The whole reasoning offer is the profile: key = selectable level, value + // = the wire spelling dispatch would send (`max: ultra` renames; the + // valueless `off` means "supported, send nothing"). The route sets no + // deployment default, so the pane leads with the provider-default entry. + await scaffold.ctx.settings.update(settingsNamespace('llm-pi-ai'), { + providers: { + 'acme-gateway': { + displayName: 'Acme Gateway', + api: 'openai-completions', + baseURL: 'https://gateway.acme.example/v1', + models: [{ + id: 'acme-think', + name: 'Acme Think', + reasoningEfforts: { off: null, high: 'high', max: 'ultra' }, + }], + }, + }, + }) + browser = await chromium.launch() + page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspaceZh(page, scaffold.workspaceCwd) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('offers exactly the declared levels and records the picked one', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-declared-reasoning')) + const trigger = page.getByRole('button', { name: /^选择模型/ }) + await trigger.waitFor({ timeout: 15_000 }) + await trigger.click() + await page.getByRole('menuitem', { name: /推理等级/ }).click() + + // Declared levels, nothing else: the provider-default entry (the route + // configures no `reasoning`), then Off/High/Max — minimal, low, medium, + // and xhigh were not declared and must not be offered. + const levels = page.getByRole('menuitemradio') + await expect.poll(async () => levels.allTextContents(), { timeout: 10_000 }) + .toEqual(['Default', 'Off', 'High', 'Max']) + const snapshot = await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + + // Picking a level is the same gesture that saves the default target, so + // the effort lands in the gateway's settings section beside the route. + await page.getByRole('menuitemradio', { name: 'High' }).click() + await expect.poll( + async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), + { timeout: 10_000 }, + ).toContain('reasoningEffort: high') + await expect.poll(() => trigger.getAttribute('aria-label'), { timeout: 10_000 }) + .toBe('选择模型,当前 Acme Think,推理等级 High') + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('keeps its snapshot inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) + }) +}) diff --git a/apps/web/tests/declared-reasoning.overlay.yml b/apps/web/tests/declared-reasoning.overlay.yml new file mode 100644 index 0000000000..d90452178c --- /dev/null +++ b/apps/web/tests/declared-reasoning.overlay.yml @@ -0,0 +1,8 @@ +# The fixture-less web scaffold registers no adapter, so the shipped +# deepseek-official default would be a route nothing serves. This scenario +# starts the default on its own declared reasoning model so the effort pane +# describes that model from the first open. +- id: api-gateway + config: + provider: acme-gateway + model: acme-think diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index d539c391c0..be3c6f18a1 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -134,7 +134,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => { await dialog.getByRole('textbox', { name: 'API 密钥', exact: true }).fill('sk-e2e-minimax') await dialog.getByRole('button', { name: '保存', exact: true }).click() // The profile lands in settings.yaml with only the derived reference, the - // key value lands in the harness home's .env, the dormant route + // key value lands in the harness home's .credentials.yaml, the dormant route // registers, and the topology frame invalidates the page into the row. await expect.poll( async () => dialog.getByRole('textbox', { name: 'API 密钥', exact: true }).count(), @@ -146,11 +146,11 @@ describe('web e2e: Models settings page configures a dormant provider', () => { expect(document).toContain('minimax-cn:') expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY') expect(document).not.toContain('sk-e2e-minimax') - const credentialFile = join(scaffold.harnessHome, '.env') + const credentialFile = join(scaffold.harnessHome, '.credentials.yaml') await expect.poll( async () => readFile(credentialFile, 'utf8').catch(() => ''), { timeout: 10_000 }, - ).toContain('MINIMAX_CN_API_KEY=sk-e2e-minimax') + ).toContain('MINIMAX_CN_API_KEY: sk-e2e-minimax') expect(await page.content()).not.toContain('sk-e2e-minimax') }, 60_000) @@ -230,7 +230,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => { async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 10_000 }, ).not.toContain('minimax-cn:') - expect(await readFile(join(scaffold.harnessHome, '.env'), 'utf8')) + expect(await readFile(join(scaffold.harnessHome, '.credentials.yaml'), 'utf8')) .not.toContain('MINIMAX_CN_API_KEY') await expect.poll( async () => page.getByRole('dialog', { name: '删除 minimax-cn?' }).count(), diff --git a/apps/web/tests/onboarding-deepseek-config.e2e.ts b/apps/web/tests/onboarding-deepseek-config.e2e.ts index 85eb76e3e3..14b741eb80 100644 --- a/apps/web/tests/onboarding-deepseek-config.e2e.ts +++ b/apps/web/tests/onboarding-deepseek-config.e2e.ts @@ -113,8 +113,8 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup await settings.getByRole('button', { name: '保存', exact: true }).click() await keyInput.waitFor({ state: 'detached', timeout: 15_000 }) - const stored = await readFile(join(scaffold.harnessHome, '.env'), 'utf8') - expect(stored.includes(`DEEPSEEK_API_KEY=${secret}`)).toBe(true) + const stored = await readFile(join(scaffold.harnessHome, '.credentials.yaml'), 'utf8') + expect(stored.includes(`DEEPSEEK_API_KEY: ${secret}`)).toBe(true) expect((await page.content()).includes(secret)).toBe(false) expect((await page.locator('body').ariaSnapshot()).includes(secret)).toBe(false) expect(browserConsole.some(line => line.includes(secret))).toBe(false) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 062594e528..985cfac92f 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -32,7 +32,7 @@ import { expect } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import Include, { type PatchOptions } from '@cordisjs/plugin-include' -import { scrubRequestHeaders } from '@deepseek-ai/dsh-acp-snapshot' +import { scrubRequestHeaders, stabilizeFixtureMessageIds } from '@deepseek-ai/dsh-acp-snapshot' import { addHarnessSourceSection, assertEntriesLoaded, @@ -530,11 +530,14 @@ function rawSessionLog(session: Session): string { export async function recordFixture(scaffold: WebScaffold, sessionId: SessionId, fixturePath: string): Promise { const agent = scaffold.ctx.agents.get(sessionId) if (agent === undefined) throw new Error(`record harvest: no live agent for ${sessionId}`) - const tokenized = scrubRequestHeaders(rawSessionLog(agent.session)) + const fresh = scrubRequestHeaders(rawSessionLog(agent.session)) .split(sessionId).join('{{sessionId}}') .split(scaffold.workspaceCwd).join('{{cwd}}') .replace(/"rpcId":"[^"]+"/g, '"rpcId":"{{rpcId}}"') - await writeFile(fixturePath, tokenized) + const existing = existsSync(fixturePath) ? await readFile(fixturePath, 'utf8') : '' + const stable = stabilizeFixtureMessageIds([fresh], [existing])[0] + if (stable === undefined) throw new Error('record harvest: no stabilized fixture') + await writeFile(fixturePath, stable) } /** @@ -619,8 +622,9 @@ export async function seedSession(scaffold: WebScaffold, fixtureText: string, id } /** - * Normalize an aria snapshot: uuid, cwd, workspace-basename, duration, and - * decode-throughput volatility collapse to stable tokens. + * Normalize an aria snapshot: uuid, cwd, workspace-basename, duration, + * decode-throughput, and path-sensitive compaction estimates collapse to + * stable tokens. * * Throughput needs a token for the same reason durations do, and no fixture * can supply one: the figure divides a replayed step's output tokens by the @@ -647,6 +651,9 @@ function normalizeAria(snapshot: string, workspaceCwd: string): string { duration => duration.startsWith('约') ? duration : '{{duration}}', ) .replace(/\d+(?:\.\d+)?(?= tok\/s(?!\w))/g, '{{throughput}}') + // Seeded compaction prices realized file paths, whose length differs + // between local worktrees and CI scratch directories. + .replace(/(Compacted \d+ history items \(~)\d+( tokens\))/g, '$1{{tokens}}$2') // Message IconActions clocks widen by calendar day/year; collapse every // shape so goldens stay stable across midnight and year boundaries. .replace(/\d{4}年\d{1,2}月\d{1,2}日 \d{2}:\d{2}/g, '{{clock}}') diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 799503ef1c..fac240fbe1 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -4,8 +4,9 @@ // history RPC, history-page tool views, and the client's log-ordered transcript // events — with ZERO model calls in replay (no replay fixture; a stray stream // fails loud on the open llm seam). The cold session also carries the one -// keyless command-row surface: an Access-chip pick runs `/permission` on the -// host, so the settled row's copy has a golden here. The seed is a recorded +// keyless command-row surfaces: the seeded manual `/compact` lifecycle folds +// into its checkpoint, while an Access-chip pick later runs `/permission` on +// the host. The seed is a recorded // fixture under the // same record discipline as every other: DSH_SNAPSHOT=record drives the turn // live through the composer (real read tool against seeded workspace files) @@ -39,18 +40,18 @@ const SEED_ID = 'seeded-history-web-e2e' const PROMPT = 'Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop.' /** - * Append a complete, valid compaction transaction over the recorded turn's own - * surface. The recording stays model-authentic and reusable; replay adds this - * deterministic condition before seeding it cold, so the scenario pins the bug - * this change fixes — a landed compaction must not erase history the reader - * already saw — through the real host and the real browser. + * Append a complete manual `/compact` lifecycle and valid compaction transaction + * over the recorded turn's own surface. The recording stays model-authentic and + * reusable; replay adds this deterministic condition before seeding it cold, so + * the scenario pins both the log-preserving marker and its single-card command + * presentation through the real host and browser. * @param raw - the seed fixture text, already realized (placeholder-free) so * the shadow price below is computed from the exact strings the host folds. * @param meter - the composed token meter; the appended `compact/summary`'s * shadow price must be the exact heuristic price of the shadowed nodes, the * way compact-basic derives it, because the token-meter projections subtract * it verbatim. - * @returns the fixture with a compacted turn appended. + * @returns the fixture with a manual compaction lifecycle appended. */ function withCompaction(raw: string, meter: TokenMeterService): string { const lines = raw.trimEnd().split('\n') @@ -73,14 +74,10 @@ function withCompaction(raw: string, meter: TokenMeterService): string { if (first === undefined || last === undefined || tail === undefined) { throw new Error('seeded-history compaction requires a non-empty closed surface') } - // The transaction opens the turn after the recording's last closed one; read - // it from the fixture so a re-recording with a different turn count stays - // valid instead of appending a duplicate turn number. const lastTurn = events.filter(event => event.type === 'turn/end').at(-1)?.data?.turn if (typeof lastTurn !== 'number') { throw new Error('seeded-history compaction requires a recording ending on a closed turn') } - const turn = lastTurn + 1 let seq = tail.seq + 1 let time = tail.time + 1 /** @@ -93,8 +90,12 @@ function withCompaction(raw: string, meter: TokenMeterService): string { lines.push(JSON.stringify({ ...event, seq: taken, time: time++ })) return taken } - at({ type: 'turn/start', data: { turn } }) - const startSeq = at({ type: 'compact/start', data: { turn } }) + const commandId = 'cmd-seeded-manual-compact' + at({ + type: 'command/run', + data: { commandId, name: 'compact', args: '', source: { kind: 'user' } }, + }) + const startSeq = at({ type: 'compact/start', data: { turn: null } }) // Load-bearing exactness: the projections subtract this count verbatim, so // it must equal what the host's fold prices for these nodes. The estimator // prices message CONTENT only, so a minimal wrapper per storage shape is @@ -146,8 +147,21 @@ function withCompaction(raw: string, meter: TokenMeterService): string { surfaceOp: { op: 'replace', start: first, end: last }, sourceEventSeqs: [startSeq, summarySeq, ...surfaceSeqs], }) - at({ type: 'compact/end', data: { turn } }) - at({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } }) + at({ type: 'compact/end', data: { turn: null } }) + at({ + type: 'command/done', + data: { + commandId, + kind: 'success', + text: `Compacted ${surfaceSeqs.length} history items (~${shadowedTokenCount} tokens).`, + sourceEventSeq: summarySeq, + }, + }) + // The persistence seed helper requires a terminal turn/end. Keep the manual + // command standalone, then add a closed zero-step fixture boundary after it. + const closureTurn = lastTurn + 1 + at({ type: 'turn/start', data: { turn: closureTurn } }) + at({ type: 'turn/end', data: { turn: closureTurn, reason: { kind: 'completed' } } }) return `${lines.join('\n')}\n` } @@ -239,7 +253,11 @@ describe('web e2e: seeded history renders through cold resume', () => { await sessionRow.click() // Settled barrier for history: the recorded final assistant text renders. await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1) - await expect.poll(() => page.getByText('Context compacted', { exact: true }).count(), { timeout: 10_000 }).toBe(1) + await expect.poll(() => page.getByText('compact', { exact: true }).count(), { timeout: 10_000 }).toBe(1) + await expect.poll(() => page.getByText(/^Compacted \d+ history items \(~\d+ tokens\)$/).count(), { + timeout: 10_000, + }).toBe(1) + expect(await page.getByText('Context compacted', { exact: true }).count()).toBe(0) // Tool cards render from logged tool/call + tool/result alone (views are // host-recomputed per page; the generic card is the documented default). const toolRows = page.locator('[data-variant], [data-sample]') @@ -363,7 +381,7 @@ describe('web e2e: seeded history renders through cold resume', () => { it.skipIf(MODE === 'record')('expands the cold-resumed compact summary', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-compaction')) - const marker = page.getByRole('button', { name: /Context compacted/ }) + const marker = page.getByRole('button', { name: /compact Compacted \d+ history items/ }) await marker.waitFor({ timeout: 10_000 }) expect(await marker.getAttribute('aria-expanded')).toBe('false') await marker.click() diff --git a/apps/web/tests/sidebar-subagent-activity.e2e.ts b/apps/web/tests/sidebar-subagent-activity.e2e.ts new file mode 100644 index 0000000000..4c37ea6fc1 --- /dev/null +++ b/apps/web/tests/sidebar-subagent-activity.e2e.ts @@ -0,0 +1,156 @@ +import { mkdir } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import type { AgentHandle } from '@deepseek-ai/dsh-agent' +import { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import { SessionId, type SessionId as SessionIdValue } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-subagent' +import type {} from '@deepseek-ai/dsh-workspace' +import { + assertFixtureInventory, + captureStableAria, + compareOrRefreshGolden, + launchWebScaffold, + watchConsole, + webSnapshotMode, + type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/sidebar-subagent-activity', import.meta.url)) +const RUNNING_OWNER_EXPECTED = join(SNAPSHOT_DIR, 'owner-running.expected.md') +const MODE = webSnapshotMode() +const HOLD_PROVIDER = 'web-test-hold' +const HOLD_MODEL = 'hold' + +/** Model seam that completes the owner turn, then holds its delegated child open. */ +class StagedAdapter extends LlmAdapter { + activeCalls = 0 + private calls = 0 + + override async *stream(options: GenerateOptions): AsyncIterable { + if (this.calls === 0) { + this.calls += 1 + yield { type: 'finish', reason: { kind: 'stop' } } + return + } + this.calls += 1 + const signal = options.signal + if (signal === undefined) throw new Error('staged Web adapter requires a turn signal') + this.activeCalls += 1 + try { + await new Promise((_resolve, reject) => { + const abort = (): void => { + reject(signal.reason instanceof Error ? signal.reason : new Error('holding Web adapter aborted')) + } + if (signal.aborted) abort() + else signal.addEventListener('abort', abort, { once: true }) + }) + } finally { + this.activeCalls -= 1 + } + } +} + +async function waitForRunningChild( + scaffold: WebScaffold, + adapter: StagedAdapter, + childId: SessionIdValue, +): Promise { + const deadline = Date.now() + 10_000 + while (adapter.activeCalls !== 1 || scaffold.ctx.agents.get(childId)?.status !== 'running') { + if (Date.now() >= deadline) throw new Error('held child did not enter its running model call') + await new Promise(resolve => setTimeout(resolve, 10)) + } +} + +describe('web e2e: sidebar subagent activity', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let parentHandle: AgentHandle + let childId: SessionIdValue + let adapter: StagedAdapter + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold() + adapter = new StagedAdapter() + scaffold.ctx.effect( + () => scaffold.ctx.llm.registerAdapter([HOLD_PROVIDER], adapter), + 'sidebar subagent activity staged adapter', + ) + const cwd = join(scaffold.workspaceCwd, 'workspace') + await mkdir(cwd) + parentHandle = await scaffold.ctx.agents.create({ + sessionId: SessionId('sidebar-activity-owner'), + meta: { cwd }, + agentOptions: { provider: HOLD_PROVIDER, model: HOLD_MODEL }, + }) + parentHandle.agent.followup(createUserMessage({ + content: [{ type: 'text', text: 'Delegate a background task.' }], + source: { kind: 'user' }, + })) + await parentHandle.agent.whenIdle() + const started = await scaffold.ctx.subagents.startContinuable({ + provider: 'spawn', + label: 'sidebar activity child', + signal: new AbortController().signal, + request: { + prompt: [{ type: 'text', text: 'Hold this delegated task open.' }], + parent: parentHandle.agent, + }, + }) + childId = started.childId + await waitForRunningChild(scaffold, adapter, childId) + + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + const workspace = await scaffold.ctx.workspace.resolveByPath(cwd) + if (workspace === undefined) throw new Error('connected Web workspace was not registered') + await workspace.attachSession(parentHandle.agent.session.id) + }, 60_000) + + afterAll(async () => { + const failures: unknown[] = [] + const child = childId === undefined ? undefined : scaffold?.ctx.agents.get(childId) + if (child !== undefined) { + child.cancel({ kind: 'user' }) + await child.whenIdle().catch((error: unknown) => failures.push(error)) + } + await browser?.close().catch((error: unknown) => failures.push(error)) + await parentHandle?.dispose().catch((error: unknown) => failures.push(error)) + await scaffold?.close().catch((error: unknown) => failures.push(error)) + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'sidebar subagent activity teardown failed') + }) + + it('pins a running descendant on its visible idle owner row', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-subagent-activity')) + const sidebar = page.getByRole('tree', { name: 'Sessions' }) + const ownerRow = sidebar.getByRole('treeitem', { name: /1 subagent running Delegate a background task/ }) + await ownerRow.waitFor({ timeout: 10_000 }) + expect(parentHandle.agent.status).toBe('idle') + await compareOrRefreshGolden( + RUNNING_OWNER_EXPECTED, + await captureStableAria(page, '[role="tree"][aria-label="Sessions"]', scaffold.workspaceCwd), + MODE, + ) + expect(await ownerRow.locator('[data-state="ongoing"]').count()).toBe(1) + await ownerRow.click() + const runningTrigger = page.getByRole('button', { name: '1 subagent running' }) + await runningTrigger.waitFor({ timeout: 10_000 }) + expect(await runningTrigger.locator('[data-state="ongoing"]').count()).toBe(1) + await assertFixtureInventory(SNAPSHOT_DIR, ['owner-running.expected.md']) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) +}) diff --git a/apps/web/tests/skill-invocation-policy.e2e.ts b/apps/web/tests/skill-invocation-policy.e2e.ts index 143bc0d4db..54cd15bf94 100644 --- a/apps/web/tests/skill-invocation-policy.e2e.ts +++ b/apps/web/tests/skill-invocation-policy.e2e.ts @@ -1,5 +1,6 @@ -// Web e2e scenario: the real host filters skill.list to the model-and-user -// intersection before the browser slash source renders candidates. A real +// Web e2e scenario: the real host serves every user-invocable skill to the +// browser slash source — user-only (disable-model-invocation) entries appear +// with their marker while user-disabled quadrants stay hidden. A real // chromium connects a fresh workspace seeded with all four policy quadrants; // no model call is issued, so a stray stream fails loud on the open LLM seam. import { mkdir, writeFile } from 'node:fs/promises' @@ -92,7 +93,7 @@ describe('web e2e: skill invocation policy through the real host', () => { await scaffold?.close() }) - it('renders only the model-and-user intersection in slash candidates', async () => { + it('renders every user-invocable skill and marks the user-only entry', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-skill-invocation-policy')) const input = page.locator('textarea').first() await input.fill('/policy') @@ -102,8 +103,10 @@ describe('web e2e: skill invocation policy through the real host', () => { { timeout: 10_000 }, ).toBe(1) + // The user-only quadrant is invocable here — its only entry point — and + // wears the user-only marker; both user-disabled quadrants stay hidden. + expect(await menu.getByRole('option', { name: /policy-user-only user-only · / }).count()).toBe(1) expect(await menu.getByRole('option', { name: /policy-model-only/ }).count()).toBe(0) - expect(await menu.getByRole('option', { name: /policy-user-only/ }).count()).toBe(0) expect(await menu.getByRole('option', { name: /policy-trusted-only/ }).count()).toBe(0) const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd) diff --git a/apps/web/tests/skill-user-invoke.e2e.ts b/apps/web/tests/skill-user-invoke.e2e.ts new file mode 100644 index 0000000000..2d5a039623 --- /dev/null +++ b/apps/web/tests/skill-user-invoke.e2e.ts @@ -0,0 +1,148 @@ +// Web e2e scenario: a user invokes a disable-model-invocation skill through +// the composer (issue #1470). The entered `/name args` line claims into +// skill.invoke: the real host forwards the gesture as an ordinary user +// prompt, injects the rendered body as instructions context named after the +// skill, and starts a turn answered by the replay seam. The transcript shows +// the gesture bubble, the collapsed context-injection row, and the reply. +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import type { ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay' +import { + assertFixtureInventory, + captureStableAria, + compareOrRefreshGolden, + launchWebScaffold, + watchConsole, + webSnapshotMode, + type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/skill-user-invoke', import.meta.url)) +const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') +const MODE = webSnapshotMode() + +const SKILL_NAME = 'user-invoke-demo' +const ARGS_TEXT = 'and confirm the fixture wiring' +const REPLY = 'USER_INVOKE_REPLY acknowledged; following the injected skill.' + +async function seedUserOnlySkill(workspaceCwd: string): Promise { + const directory = join(workspaceCwd, 'workspace', '.agents', 'skills', SKILL_NAME) + await mkdir(directory, { recursive: true }) + await writeFile(join(directory, 'SKILL.md'), [ + '---', + `name: ${SKILL_NAME}`, + 'description: Prove user-explicit invocation of a model-hidden skill', + 'disable-model-invocation: true', + '---', + '', + 'Reply with the fixture acknowledgement line.', + '', + ].join('\n')) +} + +const REPLAY: ReplayOverrideDoc = [{ + kind: 'chunks', + chunks: [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'text-delta', index: 0, text: REPLY }, + { type: 'block-end', index: 0, block: { type: 'text', text: REPLY } }, + { type: 'usage', usage: { inputTokens: 256, outputTokens: 16 } }, + { type: 'finish', reason: { kind: 'stop' } }, + ], +}] + +describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation through the composer', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let replayDir: string + let tripwire: ReturnType + + beforeAll(async () => { + replayDir = await mkdtemp(join(tmpdir(), 'dsh-skill-user-invoke-replay-')) + const replayOverride = join(replayDir, 'replay.override.json') + await writeFile(replayOverride, JSON.stringify(REPLAY)) + scaffold = await launchWebScaffold({ + replayFixture: join(replayDir, 'override-only.jsonl'), + replayOverride, + // Paced replay keeps the timing-derived chrome (TTFT / tok/s) present + // deterministically; instant playback races it in and out of the golden. + paceMs: 10, + }) + await seedUserOnlySkill(scaffold.workspaceCwd) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + }, 120_000) + + afterAll(async () => { + const failures: unknown[] = [] + await browser?.close().catch((error: unknown) => failures.push(error)) + await scaffold?.close().catch((error: unknown) => failures.push(error)) + if (replayDir !== undefined) { + await rm(replayDir, { recursive: true, force: true }) + .catch((error: unknown) => failures.push(error)) + } + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'skill-user-invoke e2e cleanup failed') + }) + + it('claims /name args into a gesture bubble, an injection row, and a replayed answer', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-skill-user-invoke')) + const composer = page.locator('textarea:enabled').last() + await composer.waitFor({ timeout: 15_000 }) + + // The menu lists the user-only skill (its only entry point) before enter. + await composer.fill(`/${SKILL_NAME}`) + const menu = page.getByRole('listbox', { name: 'Trigger suggestions' }) + await expect.poll( + () => menu.getByRole('option', { name: new RegExp(SKILL_NAME) }).count(), + { timeout: 10_000 }, + ).toBe(1) + + await composer.fill(`/${SKILL_NAME} ${ARGS_TEXT}`) + await composer.press('Enter') + + // The gesture stays an ordinary user bubble (decorated /name token plus + // the trailing text), ahead of the injected context. + const bubble = page.locator('[data-ref-chip="skill"]').first() + await bubble.waitFor({ timeout: 15_000 }) + expect(await bubble.textContent()).toBe(`/${SKILL_NAME}`) + + // The rendered body arrives as a context-injection row named after the + // skill; expanding it reveals the canonical block, and + // the user's text is NOT folded into it. + const injectionRow = page.getByRole('button', { name: `Context injection ${SKILL_NAME}` }) + await injectionRow.waitFor({ timeout: 15_000 }) + await injectionRow.click() + const injectionBody = page + .locator('[data-context-injection-body]') + .filter({ hasText: `` }) + await injectionBody.waitFor({ timeout: 10_000 }) + const injected = await injectionBody.textContent() + expect(injected).toContain('Reply with the fixture acknowledgement line.') + expect(injected).not.toContain(ARGS_TEXT) + await injectionRow.click() + + // The injection started a turn; the replay seam answers it. + await page.getByText('USER_INVOKE_REPLY', { exact: false }).first().waitFor({ timeout: 20_000 }) + + const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 60_000) + + it('keeps its snapshot inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) + }) +}) diff --git a/apps/web/tests/snapshots/declared-reasoning/ui.expected.md b/apps/web/tests/snapshots/declared-reasoning/ui.expected.md new file mode 100644 index 0000000000..810a6bf8b5 --- /dev/null +++ b/apps/web/tests/snapshots/declared-reasoning/ui.expected.md @@ -0,0 +1,7 @@ +- menu "模型与推理等级": + - menuitemradio "Default" [checked]: + - text: Default + - img + - menuitemradio "Off" + - menuitemradio "High" + - menuitemradio "Max" diff --git a/apps/web/tests/snapshots/seeded-history/command-row.expected.md b/apps/web/tests/snapshots/seeded-history/command-row.expected.md index fe58587913..3aa9e2d738 100644 --- a/apps/web/tests/snapshots/seeded-history/command-row.expected.md +++ b/apps/web/tests/snapshots/seeded-history/command-row.expected.md @@ -31,9 +31,7 @@ - button "Branch into a new conversation": - img - text: 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s -- button "Context compacted View compaction summary": - - img - - text: Context compacted View compaction summary +- button "compact Compacted 5 history items (~{{tokens}} tokens)" - button "Context injection AGENTS.md": - img - img diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index ce2921eac2..a30ae29e1e 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -31,9 +31,7 @@ - button "Branch into a new conversation": - img - text: 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s -- button "Context compacted View compaction summary": - - img - - text: Context compacted View compaction summary +- button "compact Compacted 5 history items (~{{tokens}} tokens)" - button "Context injection AGENTS.md": - img - img diff --git a/apps/web/tests/snapshots/sidebar-subagent-activity/owner-running.expected.md b/apps/web/tests/snapshots/sidebar-subagent-activity/owner-running.expected.md new file mode 100644 index 0000000000..c5a2766750 --- /dev/null +++ b/apps/web/tests/snapshots/sidebar-subagent-activity/owner-running.expected.md @@ -0,0 +1,6 @@ +- tree "Sessions": + - treeitem "workspace 2 sessions" [expanded]: + - img + - text: workspace 2 sessions + - treeitem "1 subagent running Delegate a background task. now" + - treeitem "New Session" [selected] diff --git a/apps/web/tests/snapshots/skill-invocation-policy/menu.expected.md b/apps/web/tests/snapshots/skill-invocation-policy/menu.expected.md index 11acc39ad0..ca9230b6f1 100644 --- a/apps/web/tests/snapshots/skill-invocation-policy/menu.expected.md +++ b/apps/web/tests/snapshots/skill-invocation-policy/menu.expected.md @@ -1,3 +1,4 @@ - listbox "Trigger suggestions": - text: Skills - option "policy-shared Available to both model and user invocation" [selected] + - option "policy-user-only user-only · Available only to user invocation" diff --git a/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md b/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md new file mode 100644 index 0000000000..c77081584a --- /dev/null +++ b/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md @@ -0,0 +1,33 @@ +- banner: + - navigation "Session hierarchy": + - button "/user-invoke-demo and confirm the fixtur" [disabled] + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- text: /user-invoke-demo and confirm the fixture wiring {{clock}} +- button "Copy": + - img +- button "Context injection @deepseek-ai/dsh-system-prompt": + - img + - img + - text: Context injection @deepseek-ai/dsh-system-prompt +- button "Context injection user-invoke-demo": + - img + - img + - text: Context injection user-invoke-demo +- paragraph: USER_INVOKE_REPLY acknowledged; following the injected skill. +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- textbox "Message the agent" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "0% of context used" +- button "Send message" [disabled] +- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 256 tok · Output 16 tok diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index 0049cb2791..324cde1227 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -365,22 +365,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = () => scaffold.ctx.agents.get(childId)?.status, { timeout: 10_000 }, ).toBe('running') - const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' }) - await hierarchy.getByRole('button').first().click() - const runningTrigger = page.getByRole('button', { name: '3 subagents running' }) - await runningTrigger.waitFor({ timeout: 10_000 }) - expect(await runningTrigger.locator('[data-state="ongoing"]').count()).toBe(1) - await runningTrigger.click() - await page.getByRole('treeitem', { - name: new RegExp(`${LABEL}.*running`), - }).waitFor({ timeout: 10_000 }) await ended - await page.getByRole('treeitem', { - name: new RegExp(`${LABEL}.*not running`), - }).waitFor({ timeout: 10_000 }) - expect(await page.getByRole('button', { name: '3 subagents' }) - .locator('[data-state="ongoing"]').count()).toBe(0) - await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click() await expect.poll(() => page.getByText(FOLLOWUP, { exact: true }).count(), { timeout: 10_000 }).toBe(1) await expect.poll(() => scaffold.ctx.agents.get(childId), { timeout: 10_000 }).toBeUndefined() expect(await page.getByRole('button', { name: 'Stop generating' }).count()).toBe(0) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 528714a527..354781e934 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -38,6 +38,7 @@ "tests/settings-chrome.e2e.ts", "tests/models-settings.e2e.ts", "tests/default-model.e2e.ts", + "tests/declared-reasoning.e2e.ts", "tests/onboarding-deepseek-config.e2e.ts", "tests/remote-welcome.e2e.ts", "tests/workspace-management.e2e.ts", @@ -57,6 +58,7 @@ "tests/markdown-inline-code-links.e2e.ts", "tests/queue-actions.e2e.ts", "tests/skill-invocation-policy.e2e.ts", + "tests/skill-user-invoke.e2e.ts", "tests/permission-policy-context.e2e.ts", "tests/access-confirmation.e2e.ts", "tests/shipped-composition.e2e.ts", @@ -64,6 +66,7 @@ "tests/produced-files.e2e.ts", "tests/goal-bar.e2e.ts", "tests/subagent-conversation.e2e.ts", + "tests/sidebar-subagent-activity.e2e.ts", "tests/bash-abort-row.e2e.ts", "tests/skill-tool-row.e2e.ts", "tests/turn-tail-actions.e2e.ts", diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index 074644ff3e..360e4b32e4 100644 --- a/docs/api-gateway.i18n.yaml +++ b/docs/api-gateway.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/api-gateway.md -api-gateway.md: 33dfb30c9da25e46b660a3fa54ef37f587cbda08 -api-gateway.zh.md: 633eb10c0f2f065ecf27545813cc17d79f391865 +api-gateway.md: e8aafc173dced3c4ead07421d92401411565ece6 +api-gateway.zh.md: 92681b72ffc573cde834cece19568ec3f1d515ce diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 33dfb30c9d..e8aafc173d 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -57,7 +57,7 @@ Remote methods may return a value synchronously or return a Promise. For coopera The Client uses concrete functions on ordinary objects, not a JavaScript Proxy. Direct and scoped calls appear under `ctx.remote.` and `agentCtx.remote.`. Each namespace is a traced Cordis child Service registered as `remote.`; the Client assembly mounts contributions through `ctx.remote.$mount()`, and the namespace unloads after its last method is withdrawn. Dependency declarations belong to the actual caller: only a business package that reads `ctx.remote.` or `agentCtx.remote.` declares both `remote` and `remote.` in its own `inject`; assemblies that only mount contributions and higher-level runtimes that do not call that namespace do not declare the namespace dependency on the business package's behalf. When an `@Remote` method has exactly one lookup parameter and a same-named `TypeRTContextMap` uses the same wire identity, the generated scoped signature omits that identity parameter. `@RemoteScope` generates only the scoped invocation interface. -```ts +```ts ignore-check import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { AgentContext } from '@deepseek-ai/dsh-client-runtime/client' import type { Context } from 'cordis' @@ -94,7 +94,11 @@ The API Gateway package owns the Host dispatcher and Client Remote endpoint as p ## Strict generation pipeline -The root build orders `build:lib:host`, `build:lib:client`, and `build:web`. The Host lib build first runs `build:lib:contracts`: it compiles the TypeRT generator, then starts a Host `ts.Program` through `tsdown.typert-host.config.ts` with `tsconfig.host.json` as its seed. The generator does not put the Host and Client aggregates in the same program, so it does not trigger conflicts between the two Cordis `Context` declaration merges. +The root build runs `build:lib:host`, `build:lib:client`, and `build:web` in order. The Host lib phase first runs `tsc -b tsconfig.host.json`, then `tsdown --env.DSH_BUILD_FACE host`; the normal Host Project Reference graph compiles the TypeRT generator, which runs during this tsdown pass with the Host aggregate as its only `ts.Program` seed. The Client lib phase then runs `tsc -b tsconfig.client.json` and `tsdown --env.DSH_BUILD_FACE client`, consuming the newly generated Remote Client declarations and runtime contributions without starting TypeRT again. + +Both tsdown passes receive the complete workspace and bundle only JavaScript emitted to `lib/types` by the corresponding tsc phase. The root config does not scan Client artifacts, classify package names, or pass a maintained filter to tsdown; package-local configs return entries for the current phase based on `DSH_BUILD_FACE`. An ordinary Client plugin produces both its Node loader entry and browser bundle during the Client phase. + +`api-remotes` is the only package with split TypeScript faces. Its Host project owns the Agent/Session lookup policy, while its Client project depends on `/remote` declarations generated for business packages during Host tsdown; root aggregates and direct consumers must reference `api/remotes/tsconfig.host.json` or `api/remotes/tsconfig.client.json` respectively. The package's `clientBundle(..., { hostPhase: true })` produces its Host entry during Host tsdown and leaves only the browser entry for Client tsdown. Every other package remains registered in one aggregate. Each contributing business package writes generated files to its own `lib/` directory, not to its source directory: @@ -149,13 +153,13 @@ pnpm run dev:web `dsh` starts the Host source through tsx, so the Host can use the SRC fallback; `dev:web` watches only Client plugins with a `dshClient` declaration and rewrites their `lib/client.js`. It does not analyze Host decorators or generate Remote Client DTS. -Changing only a Remote method's implementation body without changing its contract does not require regenerating the TypeRT files. After adding or removing a decorator or changing an export name, namespace, parameter, return value, lookup, Context, or cancellation signature, regenerate the strict contracts before the Client bundle consumes the new artifacts: +Changing only a Remote method's implementation body without changing its contract does not require regenerating the TypeRT files. After adding or removing a decorator or changing an export name, namespace, parameter, return value, lookup, Context, or cancellation signature, rerun the ordered lib build so the Host generates the strict contract before the Client compiles and bundles the new contribution: ```sh -pnpm run build:lib:contracts +pnpm run build:lib ``` -The running Client watcher consumes these generated files when it rebundles; without a watcher, run `pnpm run build:lib:client`. Recompiling only the frontend source cannot infer new types from Host decorators. `pnpm run typecheck` includes `build:lib:contracts` as a prerequisite, and CI and release builds also use the strict generation pipeline. +The running Client watcher consumes these generated files when it rebundles. If `pnpm run build:lib:host` has already refreshed the Host contract, `pnpm run build:lib:client` can complete the Client side; a clean worktree cannot skip the Host phase. Recompiling only the frontend source cannot infer new types from Host decorators. `pnpm run typecheck` runs the Host lib phase before Client tsc, and CI and release builds use the same order. ## Boundaries diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index 633eb10c0f..92681b72ff 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -57,7 +57,7 @@ Remote 方法可以同步返回或返回 Promise。若需要协作式取消,Ho Client 使用普通对象上的具体函数,不使用 JavaScript Proxy。直接调用与作用域调用分别出现在 `ctx.remote.` 和 `agentCtx.remote.`。每个 namespace 都是注册为 `remote.` 的可追踪 Cordis 子 Service;Client assembly 通过 `ctx.remote.$mount()` 挂载贡献,最后一个方法撤回后该 namespace 随即卸载。依赖声明归实际调用方所有:只有读取 `ctx.remote.` 或 `agentCtx.remote.` 的业务包才在自己的 `inject` 中同时声明 `remote` 与 `remote.`;只负责挂载 contribution 的 assembly,以及不调用该 namespace 的上层 runtime,不代业务包声明 namespace 依赖。当一个 `@Remote` 方法恰好有一个 lookup 参数、且同名 `TypeRTContextMap` 使用相同 wire identity 时,生成的作用域签名会省略该 identity 参数。`@RemoteScope` 只生成作用域调用界面。 -```ts +```ts ignore-check import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { AgentContext } from '@deepseek-ai/dsh-client-runtime/client' import type { Context } from 'cordis' @@ -94,7 +94,11 @@ API Gateway 包同时拥有 Host dispatcher 与 Client Remote endpoint 两个对 ## 严格生成链路 -根构建按 `build:lib:host`、`build:lib:client`、`build:web` 排序。Host lib 构建首先运行 `build:lib:contracts`:它先编译 TypeRT generator,再通过 `tsdown.typert-host.config.ts` 以 `tsconfig.host.json` 为种子启动 Host `ts.Program`。生成器不会把 Host 与 Client 聚合放入同一个 program,因而不会触发两侧 Cordis `Context` 声明合并冲突。 +根构建依次执行 `build:lib:host`、`build:lib:client` 与 `build:web`。Host lib 阶段先运行 `tsc -b tsconfig.host.json`,再运行 `tsdown --env.DSH_BUILD_FACE host`;TypeRT generator 由正常 Host Project Reference 图编译,并在这次 tsdown 中以 Host aggregate 为唯一 `ts.Program` 种子运行。Client lib 阶段随后运行 `tsc -b tsconfig.client.json` 与 `tsdown --env.DSH_BUILD_FACE client`,使用刚生成的 Remote Client 声明和运行时贡献,但不再次启动 TypeRT。 + +两次 tsdown 都接收完整 workspace,且都只打包 `lib/types` 中由对应 tsc 阶段发射的 JavaScript。根配置不扫描 Client 产物、不按 package 名分类,也不向 tsdown 传维护式 filter;各包的本地配置根据 `DSH_BUILD_FACE` 返回当前阶段的入口。普通 Client plugin 在 Client 阶段一起生成 Node loader 入口与 browser bundle。 + +`api-remotes` 是唯一拆分 TypeScript face 的 package 特例。它的 Host project 负责 Agent/Session lookup 策略,Client project 则依赖业务包在 Host tsdown 中生成的 `/remote` 声明;根 aggregate 与直接消费方必须分别引用 `api/remotes/tsconfig.host.json` 或 `api/remotes/tsconfig.client.json`。包内 `clientBundle(..., { hostPhase: true })` 让 Host 入口在 Host tsdown 中生成,让 Client tsdown 只生成 browser 入口。其他 package 仍只登记在一个 aggregate 中。 每个贡献业务包把生成文件写入自己的 `lib/`,而不是源码目录: @@ -149,13 +153,13 @@ pnpm run dev:web `dsh` 通过 tsx 启动 Host 源码,所以 Host 可以使用 SRC 回退;`dev:web` 只监听带 `dshClient` 声明的 Client plugin 并重写其 `lib/client.js`,它不会分析 Host decorator,也不会生成 Remote Client DTS。 -只修改 Remote 方法实现体而不改变契约时,无需重新生成 TypeRT 文件。新增或删除 decorator、修改导出名、namespace、参数、返回值、lookup、Context 或取消签名时,先重新生成严格契约,再让 Client bundle 使用新的产物: +只修改 Remote 方法实现体而不改变契约时,无需重新生成 TypeRT 文件。新增或删除 decorator、修改导出名、namespace、参数、返回值、lookup、Context 或取消签名时,重新执行有序 lib 构建,让 Host 先生成严格契约,再让 Client 编译并打包新的贡献: ```sh -pnpm run build:lib:contracts +pnpm run build:lib ``` -运行中的 Client watcher 会在重新打包时消费这些生成文件;没有 watcher 时运行 `pnpm run build:lib:client`。仅重新编译前端源码不能从 Host decorator 推导新类型。`pnpm run typecheck` 自带 `build:lib:contracts` 前置步骤,CI 与发布构建也使用严格生成链路。 +运行中的 Client watcher 会在重新打包时消费这些生成文件。若已单独运行 `pnpm run build:lib:host` 刷新 Host 契约,也可再运行 `pnpm run build:lib:client` 完成 Client 侧;干净工作树不能跳过 Host 阶段。仅重新编译前端源码不能从 Host decorator 推导新类型。`pnpm run typecheck` 会执行 Host lib 阶段后再运行 Client tsc,CI 与发布构建也使用同一顺序。 ## 边界 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 0164acefcf..ca91a80b7e 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: 35a73d4a307f5f48cc41cc496742a2ac210e8877 -architecture.zh.md: 185958221a477bb690e3ab5c91c33ba892ab2d73 +architecture.md: 3080d21a24310cc8851d64719d862ec719150ab6 +architecture.zh.md: 6e2bf0c155c68e84580aecc6b7783eeffa25c640 diff --git a/docs/architecture.md b/docs/architecture.md index 35a73d4a30..3080d21a24 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -141,9 +141,9 @@ The session log is authoritative. `deriveMessages()` projects model history; raw **Model-visible ⟺ logged**: messages entering at `step/start` plus the folded `request/header` reconstruct every request. The header marks adapter defaults so later proposals discard them and re-resolve the route without losing explicit settings. `request/context` separately records registration-bound provider, model, and capacity metadata when the route changes; it does not participate in request reconstruction or header equality. `dsh-agent-loop/invariant` asserts reconstructability through `ctx.invariants` ([reconstructability](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)). -Durability is a plugin concern. Backends eagerly drain synchronous `session/event` notifications. `session/flush` precedes requests and top-level tool dispatch, and follows `turn/end` before another turn or idle. `SessionPersistence` stores events and header metadata; JSONL defaults to checksummed Zstandard and SQLite shares the contract ([decision](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md)). +Durability is a plugin concern. Backends copy synchronous `session/event` notifications into fixed-window durable batches; `session/flush` bypasses the wait before requests and top-level tool dispatch, and after `turn/end` before another turn or idle. `SessionPersistence` stores events and header metadata; JSONL defaults to checksummed Zstandard and SQLite shares the contract ([checkpoint decision](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md), [batching decision](../.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md)). -Between turns, owners append log-only events through `Session`, flushing only for durability. `session/title` needs eager persistence and lifecycle drains; manual compaction flushes its bracket before the operation completes. Title work never delays responses; latest wins with provenance. Title records are inherited fork boundaries ([decision](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md)). +Between turns, owners append log-only events through `Session`, flushing only for durability. `session/title` relies on bounded background persistence and lifecycle drains; manual compaction flushes its bracket before the operation completes. Title work never delays responses; latest wins with provenance. Title records are inherited fork boundaries ([decision](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md)). ### Model Content diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 185958221a..6e2bf0c155 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -141,9 +141,9 @@ idle inject: **模型可见 ⟺ 已记录**:在 `step/start` 进入的消息加上折叠后的 `request/header` 可以重建每个请求。该 header 会标记适配器默认值,使后续提议丢弃这些值并重新解析路由,同时不丢失显式设置。`request/context` 会在路由变化时另行记录与注册项绑定的提供方、模型及容量元数据;它不参与请求重建或 header 相等性判断。`dsh-agent-loop/invariant` 通过 `ctx.invariants` 断言可重建性([可重建性](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md))。 -持久性由插件负责。后端会尽快排空同步的 `session/event` 通知。`session/flush` 位于请求与顶层工具分发之前,并在 `turn/end` 之后、另一个轮次或空闲状态之前执行。`SessionPersistence` 存储事件和 header 元数据;JSONL 默认采用带校验和的 Zstandard,SQLite 遵循同一契约([决策](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md))。 +持久性由插件负责。后端会将同步的 `session/event` 通知复制到固定窗口的持久化批次中;`session/flush` 会绕过等待,在请求与顶层工具分发之前执行,并在 `turn/end` 之后、另一个轮次或空闲状态之前执行。`SessionPersistence` 存储事件和 header 元数据;JSONL 默认采用带校验和的 Zstandard,SQLite 遵循同一契约([检查点决策](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md)、[批处理决策](../.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md))。 -在轮次之间,事件所有方通过 `Session` 追加纯日志事件,仅为持久性而刷写。`session/title` 需要尽快持久化与生命周期排空;手动压缩会在操作完成前 flush 其标记对。标题工作绝不延迟响应;最新标题按后写覆盖并携带来源信息。标题记录是可继承的 fork 边界([决策](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md))。 +在轮次之间,事件所有方通过 `Session` 追加纯日志事件,仅为持久性而刷写。`session/title` 依赖有界后台持久化与生命周期排空;手动压缩会在操作完成前 flush 其标记对。标题工作绝不延迟响应;最新标题按后写覆盖并携带来源信息。标题记录是可继承的 fork 边界([决策](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md))。 ### 模型内容 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 2de89ef94b..1aee225b5d 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -21,7 +21,6 @@ flowchart LR pkg_session["session"] svc_sessions["ctx.sessions
In-memory session store"] pkg_agent["agent"] - pkg_cli_demo["cli-demo"] pkg_session_persistence["session-persistence"] pkg_session_query["session-query"] pkg_session_query_sqlite["session-query-sqlite"] @@ -91,6 +90,7 @@ flowchart LR svc_sessionProjectionCache["ctx.sessionProjectionCache
Persisted projection cache"] pkg_skill["skill"] svc_skills["ctx.skills
Skill provider registry"] + pkg_skill_badge["skill-badge"] pkg_skill_local["skill-local"] svc_agents["ctx.agents
Agent service"] pkg_acp["acp"] @@ -225,6 +225,7 @@ flowchart LR pkg_settings --> svc_settings pkg_settings_local --> svc_settings pkg_skill --> svc_skills + pkg_skill_badge --> svc_skills pkg_skill_local --> svc_skills pkg_spill --> svc_spillStore pkg_spill_local --> svc_spillStore @@ -260,7 +261,6 @@ flowchart LR svc_agentLoop --> pkg_agent_spine_demo svc_agents --> pkg_acp svc_agents --> pkg_agent_loop - svc_agents --> pkg_cli_demo svc_agents --> pkg_subagent_inprocess svc_approval --> pkg_tool_bash svc_approval --> pkg_tools @@ -307,7 +307,6 @@ flowchart LR svc_sessionQuery --> pkg_tool_session_query svc_sessions --> pkg_agent svc_sessions --> pkg_agent_loop - svc_sessions --> pkg_cli_demo svc_sessions --> pkg_invariants svc_sessions --> pkg_session_persistence svc_sessions --> pkg_session_query @@ -365,7 +364,7 @@ flowchart LR | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compact-basic`](../packages/compact/compact-basic) | - | Adapters register provider implementations; the loop and compaction call the provider-neutral stream service. | | `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compact-basic`](../packages/compact/compact-basic) | - | Owns isolated per-session replay folds; pressure consumers share immutable revisioned measurements. | | `ctx.toolResultPrune` | `core` | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | - | [`compact-basic`](../packages/compact/compact-basic) | - | Rewrites oversized current tool results through replayable single-node surface replacements before summary compaction. | -| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`cli-demo`](../packages/examples/cli-demo), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. | +| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. | | `ctx.invariants` | `core` | [`invariants`](../packages/support/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. | | `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), [`api-gateway`](../packages/api/gateway) | - | Plugins register live zod contributions directly or through dsh-typert-loader; the API gateway consumes invocation descriptors and providers, while other runtime consumers query schemas and reflection metadata at their own edges. | | `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | Associates generated Remote descriptors with live Cordis services, resolves registered identities, and exposes unary calls through the shared Connection RPC carrier. | @@ -386,8 +385,8 @@ flowchart LR | `ctx.commands` | `core` | [`commands`](../packages/ui/commands) | - | - | - | Plugins register direct human commands without sending invocations to the model. | | `ctx.sessionProjections` | `core` | [`session-projection`](../packages/session-projection/session-projection) | - | [`tool-todo`](../packages/todo/tool-todo), [`session-title`](../packages/session-title/session-title), [`host-apiproxy`](../packages/host/apiproxy) | - | Domains register state-driven fold units; the eager drive keeps per-session watermark states and api-proxy serves baselines and pushes changed values. | | `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session-projection/session-projection-cache) | - | [`host-apiproxy`](../packages/host/apiproxy) | - | Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points) and serves the cold-read ladder: cache row + persistence tail replay, so listings never load full logs. | -| `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-local`](../packages/skill/skill-local) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. | -| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | +| `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-badge`](../packages/skill/skill-badge), [`skill-local`](../packages/skill/skill-local) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. | +| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | Folds revisioned objective state from the session log and keeps live continuation activation process-local. | | `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox), [`lsp-local`](../packages/lsp/lsp-local), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | - | The bash executors, the LSP host, and the out-of-process ACP, Codex, and Claude Code subagent backends spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 21f38d18e2..43b8bbc4d9 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -249,46 +249,6 @@ Depends on: [`LocalConfig`](#deepseek-aidsh-bash-local) Source: [`packages/bash/bash-sandbox/src/index.ts:35`](../packages/bash/bash-sandbox/src/index.ts) -## `@deepseek-ai/dsh-cli-demo` - -```ts config-catalog -/** App config forwarded to the spine, configured agent, and JSONL backend. */ -export interface Config { - /** Provider route for the configured agent. */ - provider: string - /** Model name for the configured agent; a matching adapter must be registered. */ - model: string - /** Bundled agent-loop concurrency cap; `1` is serial and omission uses its default. */ - maxParallelToolCalls?: number - /** Deployment persona forwarded to the system-prompt plugin. */ - persona?: string - /** Explicit model-facing tool order forwarded to the system-prompt plugin. */ - toolOrder?: string[] - /** Tool-registry presentation config forwarded through agent-spine-demo. */ - tools?: ToolsConfig - /** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */ - dshHome?: string - /** Fallback session-title limits forwarded through agent-spine-demo. */ - sessionTitle?: NonNullable - /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */ - persistenceRoot?: string - /** JSONL artifact encoding; defaults to checksummed Zstandard frames. */ - persistenceCompression?: JsonlCompression - /** Skill registry, local-provider, and model-facing consumer config. */ - skills?: agentCore.SkillConfig - /** Model-facing bash tool config forwarded through agent-spine-demo. */ - toolBash?: NonNullable - /** Generic background-task control-tool config forwarded through agent-spine-demo. */ - toolTasks?: NonNullable - /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ - workspaceContext: agentCore.Config['workspaceContext'] -} -``` - -Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`JsonlCompression`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) - -Source: [`packages/examples/cli-demo/src/index.ts:26`](../packages/examples/cli-demo/src/index.ts) - ## `@deepseek-ai/dsh-client-connection` Requires: `httpServer` @@ -426,7 +386,7 @@ Source: [`packages/compact/compact-tool-result-prune/src/types.ts:4`](../package ```ts config-catalog /** Plugin config: file location and hot-reload behavior. */ export interface Config { - /** Credentials document path; defaults to `.env` under the harness home. */ + /** Credentials document path; defaults to `.credentials.yaml` under the harness home. */ path?: string /** Harness home used when `path` is omitted; defaults to `$DSH_HOME` or `~/.dsh`. */ dshHome?: string @@ -437,7 +397,7 @@ export interface Config { } ``` -Source: [`packages/credentials/credentials-local/src/index.ts:26`](../packages/credentials/credentials-local/src/index.ts) +Source: [`packages/credentials/credentials-local/src/index.ts:54`](../packages/credentials/credentials-local/src/index.ts) ## `@deepseek-ai/dsh-frontend-static` @@ -509,7 +469,7 @@ export interface Config { } ``` -Source: [`packages/bundle/headless/src/index.ts:33`](../packages/bundle/headless/src/index.ts) +Source: [`packages/bundle/headless/src/index.ts:32`](../packages/bundle/headless/src/index.ts) ## `@deepseek-ai/dsh-hooks-claude` @@ -679,17 +639,9 @@ Requires: `llm` * reasoning effort resolves to `high`. */ export interface Config { - /** - * Trimmed literal API key; whitespace-only is absent, so it resolves through - * {@link apiKeyEnv} like an omitted one. Prefer {@link apiKeyEnv} to keep - * secrets out of configuration files. {@link resolveAdapterOptions} also - * format-checks what remains: a value no HTTP header can carry fails there - * rather than inside `fetch`. - */ - apiKey?: string /** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */ apiKeyEnv?: string - /** Endpoint base; falls back to $DEEPSEEK_BASE_URL, then the public API. */ + /** Endpoint base; falls back to $DEEPSEEK_BASE_URL from a trusted environment layer, then the public API. */ baseURL?: string /** Deployment thinking policy; `disabled` limits every conversation request to `off`. */ thinking?: 'enabled' | 'disabled' @@ -724,7 +676,7 @@ export interface DeepSeekCatalogModel { Depends on: [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -Source: [`packages/llm/llm-deepseek/src/index.ts:60`](../packages/llm/llm-deepseek/src/index.ts) +Source: [`packages/llm/llm-deepseek/src/index.ts:61`](../packages/llm/llm-deepseek/src/index.ts) ## `@deepseek-ai/dsh-llm-pi-ai` @@ -743,12 +695,6 @@ export interface Config { /** Configuration for one pi-ai provider route; the `providers` dict key IS the route. */ export interface PiAiProviderProfile { - /** - * Literal provider credential; prefer {@link apiKeyEnv}. With both absent pi-ai uses its - * provider-native ambient discovery. Trimmed and format-checked by {@link resolveProfiles}; a - * value no HTTP header can carry fails there rather than inside `fetch`. - */ - apiKey?: string /** Credential reference (environment-variable name) resolved per request through `ctx.credentials`. */ apiKeyEnv?: string /** Name shown by configuration surfaces; defaults to the route key. */ @@ -767,6 +713,22 @@ export interface PiAiProviderProfile { * unset fields from the installed model of the same id. */ models?: PiAiModelProfile[] + /** + * Installed-catalog customizations by model id: each entry reshapes that + * one model with the same fields a {@link models} entry takes, while the + * rest of the catalog keeps serving untouched. Only meaningful on a catalog + * route with no `models` list — `models` already replaces the catalog, so + * an override beside it, on a route the catalog does not ship, or naming a + * model the catalog does not describe is refused rather than skipped. + */ + modelOverrides?: Record + /** + * Reasoning-dispatch switches for every `openai-completions` model on this + * route; each model's own `compat` overrides per field. What neither sets + * keeps the installed catalog entry's value, then pi-ai's baseURL-derived + * detection. + */ + compat?: PiAiCompatProfile /** * Context capacity for a model this route lists that neither the entry nor * the installed catalog sizes (default 262,144). A guess by construction, so @@ -814,12 +776,70 @@ export interface PiAiModelProfile { * default on its own. */ maxTokens?: number + /** + * Selectable reasoning efforts. Absent inherits the installed catalog + * entry's capability (a hand-declared model has none and does not reason); + * `false` declares a non-reasoning model, which is how a profile strips + * reasoning from a catalog model its gateway cannot serve; a non-empty dict + * declares the offered levels and their wire spellings. + */ + reasoningEfforts?: false | PiAiReasoningEfforts + /** Reasoning-dispatch switches for this model, winning over the route's. */ + compat?: PiAiCompatProfile } + +/** + * Customization of one installed catalog model, keyed by its id in the + * route's `modelOverrides` dict — the same fields a `models` entry may set, + * with the id living in the key. Unlike a `models` list, overrides leave the + * rest of the catalog serving untouched, which is what makes "correct one + * model, keep the other thirty-seven" a three-line edit. + */ +export type PiAiModelOverride = Omit + +/** + * Reasoning-dispatch compatibility switches, set on the route (its models' + * default) or per model (winning over the route). Only the switches pi-ai's + * reasoning dispatch reads are offered; the rest of pi-ai's compat surface + * keeps its baseURL-derived auto-detection. pi-ai types both fields only on + * `OpenAICompletionsCompat` — the other wire protocols carry their reasoning + * shape in the protocol itself — so resolution rejects a model-level switch + * anywhere else, while a route-level default skips past models it cannot fit. + */ +export interface PiAiCompatProfile { + /** Reasoning parameter shape the endpoint expects; absent keeps the catalog entry's, then pi-ai's baseURL-derived guess. */ + thinkingFormat?: PiAiThinkingFormat + /** Whether the endpoint accepts `reasoning_effort`; absent keeps the catalog entry's, then pi-ai's baseURL-derived guess. */ + supportsReasoningEffort?: boolean +} + +/** + * Selectable reasoning efforts for one model: each key is a level the model + * offers (and selectors show), and its value is the wire spelling dispatch + * sends for it. `off` alone may leave its value empty — "supported, send + * nothing" — because for most providers not thinking is the parameter's + * absence; every other declared level must name a wire value. A level absent + * from the dict is not offered. + */ +export type PiAiReasoningEfforts = Partial> + +/** One reasoning-dispatch wire format a profile may name. */ +export type PiAiThinkingFormat = Exclude + +/** The `compat.thinkingFormat` spellings pi-ai accepts on an `openai-completions` model. */ +type PiThinkingFormat = NonNullable + +/** + * pi-ai thinking formats a profile cannot name: both drive the request through + * `chatTemplateKwargs`, which this configuration does not expose, so offering + * them would hand back a format with nothing to say. + */ +type WithheldThinkingFormat = 'chat-template' | 'qwen-chat-template' ``` -Depends on: `CacheRetention` (`@earendil-works/pi-ai`) · `ModelThinkingLevel` (`@earendil-works/pi-ai`) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`) +Depends on: `CacheRetention` (`@earendil-works/pi-ai`) · `ModelThinkingLevel` (`@earendil-works/pi-ai`) · `OpenAICompletionsCompat` (`@earendil-works/pi-ai`) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`) -Source: [`packages/llm/llm-pi-ai/src/config.ts:126`](../packages/llm/llm-pi-ai/src/config.ts) +Source: [`packages/llm/llm-pi-ai/src/config.ts:142`](../packages/llm/llm-pi-ai/src/config.ts) ## `@deepseek-ai/dsh-llm-replay` @@ -871,7 +891,7 @@ export interface ReplayModelConfig { Depends on: [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -Source: [`packages/support/llm-replay/src/index.ts:710`](../packages/support/llm-replay/src/index.ts) +Source: [`packages/support/llm-replay/src/index.ts:744`](../packages/support/llm-replay/src/index.ts) ## `@deepseek-ai/dsh-llm-retry` @@ -1229,13 +1249,15 @@ export interface Config { compression?: JsonlCompression /** Maximum cold Session preparations retained for history-to-resume reuse. */ preparedSessionCacheSize?: number + /** Fixed live-event coalescing window; not a backend completion deadline. */ + writeBatchMaxDelayMs?: number } /** Physical encoding selected for JSONL session artifacts. */ export type JsonlCompression = 'zstd' | 'none' ``` -Source: [`packages/session-persistence/session-persistence-jsonl/src/index.ts:58`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) +Source: [`packages/session-persistence/session-persistence-jsonl/src/index.ts:59`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) ## `@deepseek-ai/dsh-session-persistence-sqlite` @@ -1263,6 +1285,8 @@ export interface Config { journalMode?: JournalMode /** Maximum cold Session preparations retained for history-to-resume reuse. */ preparedSessionCacheSize?: number + /** Fixed live-event coalescing window; not a backend completion deadline. */ + writeBatchMaxDelayMs?: number } /** @@ -1276,7 +1300,7 @@ export interface Config { export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ``` -Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:66`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) +Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:67`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) ## `@deepseek-ai/dsh-session-projection-cache` @@ -1471,7 +1495,7 @@ export interface Config { } ``` -Source: [`packages/skill/skill/src/index.ts:170`](../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:265`](../packages/skill/skill/src/index.ts) ## `@deepseek-ai/dsh-skill-local` @@ -2063,7 +2087,7 @@ export interface Config { } ``` -Source: [`packages/skill/tool-skill/src/index.ts:58`](../packages/skill/tool-skill/src/index.ts) +Source: [`packages/skill/tool-skill/src/index.ts:61`](../packages/skill/tool-skill/src/index.ts) ## `@deepseek-ai/dsh-tool-str-replace-editor` @@ -2413,7 +2437,7 @@ export interface Config { } ``` -Source: [`packages/web/web-search-deepseek/src/index.ts:43`](../packages/web/web-search-deepseek/src/index.ts) +Source: [`packages/web/web-search-deepseek/src/index.ts:44`](../packages/web/web-search-deepseek/src/index.ts) ## `@deepseek-ai/dsh-web-search-exa` @@ -2435,7 +2459,7 @@ export interface Config { } ``` -Source: [`packages/web/web-search-exa/src/index.ts:37`](../packages/web/web-search-exa/src/index.ts) +Source: [`packages/web/web-search-exa/src/index.ts:38`](../packages/web/web-search-exa/src/index.ts) ## `@deepseek-ai/dsh-web-search-perplexity` @@ -2457,7 +2481,7 @@ export interface Config { } ``` -Source: [`packages/web/web-search-perplexity/src/index.ts:31`](../packages/web/web-search-perplexity/src/index.ts) +Source: [`packages/web/web-search-perplexity/src/index.ts:32`](../packages/web/web-search-perplexity/src/index.ts) ## `@deepseek-ai/dsh-workflow-workerthread` @@ -2558,6 +2582,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts)) - `@deepseek-ai/dsh-session-checkpoint-policy` — requires `llm` · `sessionPersistence` · `sessions` · `tools` ([`packages/session-persistence/session-checkpoint-policy/src/index.ts`](../packages/session-persistence/session-checkpoint-policy/src/index.ts)) - `@deepseek-ai/dsh-session-projection` ([`packages/session-projection/session-projection/src/index.ts`](../packages/session-projection/session-projection/src/index.ts)) +- `@deepseek-ai/dsh-skill-badge` — requires `skills` ([`packages/skill/skill-badge/src/index.ts`](../packages/skill/skill-badge/src/index.ts)) - `@deepseek-ai/dsh-storage` ([`packages/storage/storage/src/index.ts`](../packages/storage/storage/src/index.ts)) - `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts)) - `@deepseek-ai/dsh-subprocess-local` ([`packages/subprocess/subprocess-local/src/index.ts`](../packages/subprocess/subprocess-local/src/index.ts)) @@ -2604,6 +2629,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-client-ui-slots` ([`packages/client/ui-slots/src/index.ts`](../packages/client/ui-slots/src/index.ts)) - `@deepseek-ai/dsh-client-web` ([`packages/client/web/src/index.ts`](../packages/client/web/src/index.ts)) - `@deepseek-ai/dsh-client-web-react` ([`packages/client/web-react/src/index.ts`](../packages/client/web-react/src/index.ts)) +- `@deepseek-ai/dsh-environment` ([`packages/util/environment/src/index.ts`](../packages/util/environment/src/index.ts)) - `@deepseek-ai/dsh-helper` ([`packages/sdk/helper/src/index.ts`](../packages/sdk/helper/src/index.ts)) - `@deepseek-ai/dsh-hook-protocol` ([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts)) - `@deepseek-ai/dsh-jsonrpc-demo` ([`packages/examples/jsonrpc-demo/src/index.ts`](../packages/examples/jsonrpc-demo/src/index.ts)) diff --git a/docs/cookbook/adding-a-package.i18n.yaml b/docs/cookbook/adding-a-package.i18n.yaml index 85c1af757b..0c26b8be17 100644 --- a/docs/cookbook/adding-a-package.i18n.yaml +++ b/docs/cookbook/adding-a-package.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/adding-a-package.md -adding-a-package.md: a45b222f6aed905a18ef9b480c989e6045029afe -adding-a-package.zh.md: af0e4d0779fa99ce43ebccba00c33eab16c4d362 +adding-a-package.md: 8ab603ea7b235bd2a582c9232afaca281a969448 +adding-a-package.zh.md: 8c0bc9dbd02b18a388f8e6ce91af10753c0210a2 diff --git a/docs/cookbook/adding-a-package.md b/docs/cookbook/adding-a-package.md index a45b222f6a..8ab603ea7b 100644 --- a/docs/cookbook/adding-a-package.md +++ b/docs/cookbook/adding-a-package.md @@ -31,7 +31,7 @@ In-package relative imports use explicit `.ts` specifiers in source (for example | File | Change | |---|---| | `tsconfig.base.json` | no edit for an existing group; for a new group, add a `./packages//*/src` candidate to the `@deepseek-ai/dsh-*` wildcard | -| `tsconfig.host.json` (host-side package) or `tsconfig.client.json` (client-side package) | add `{ "path": "./packages//" }` to `references` — exactly one aggregate, never both ([layout](../development.md#typescript-project-layout)) | +| `tsconfig.host.json` (Host package) or `tsconfig.client.json` (Client package) | add `{ "path": "./packages//" }` to `references` — an ordinary package belongs to exactly one aggregate, never both. `api/remotes` uses a repository-specific split because the Host generates a contract that the Client consumes in a later phase; new packages must not copy it ([layout](../development.md#typescript-project-layout)) | | `knip.json` | only if the package has entrypoints that repository discovery does not already cover | A `packages/client/*` package additionally extends `tsconfig.base.client.json` instead of `tsconfig.base.json`, and a client plugin package declares `dshClient` in package.json, exports `./client`, and calls the shared tsdown preset (`packages/client/tsdown.client.ts`) — see [packages/client/AGENTS.md](../../packages/client/AGENTS.md) for the client-side contract. diff --git a/docs/cookbook/adding-a-package.zh.md b/docs/cookbook/adding-a-package.zh.md index af0e4d0779..8c0bc9dbd0 100644 --- a/docs/cookbook/adding-a-package.zh.md +++ b/docs/cookbook/adding-a-package.zh.md @@ -31,7 +31,7 @@ package.json 不变式(由 `pnpm run constraints` / `scripts/check-workspace-c | 文件 | 变更 | |---|---| | `tsconfig.base.json` | 已有分组无需编辑;新分组需为 `@deepseek-ai/dsh-*` 通配符添加 `./packages//*/src` 候选路径 | -| `tsconfig.host.json`(host 侧包)或 `tsconfig.client.json`(client 侧包) | 在 `references` 中添加 `{ "path": "./packages//" }`——恰好一个聚合,绝不两个都加([布局](../development.md#typescript-project-layout)) | +| `tsconfig.host.json`(Host 包)或 `tsconfig.client.json`(Client 包) | 在 `references` 中添加 `{ "path": "./packages//" }`——普通包恰好属于一个 aggregate,绝不两个都加。`api/remotes` 因 Host 生成契约与 Client 消费契约之间存在顺序依赖而使用仓库专属拆分,新增包不得仿照([布局](../development.md#typescript-project-layout)) | | `knip.json` | 仅当包有仓库发现机制尚未覆盖的入口时需要 | `packages/client/*` 包改为 extends `tsconfig.base.client.json`(而非 `tsconfig.base.json`);client 插件包还需在 package.json 声明 `dshClient`、导出 `./client`、调用共享 tsdown preset(`packages/client/tsdown.client.ts`)——client 侧见 [packages/client/AGENTS.md](../../packages/client/AGENTS.md)。 diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml index acc8597d68..e341244f40 100644 --- a/docs/cookbook/extension-cookbook.i18n.yaml +++ b/docs/cookbook/extension-cookbook.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/extension-cookbook.md -extension-cookbook.md: 26f20b8f6cb57103b8e7340fcd21089ff1b0e5f6 -extension-cookbook.zh.md: b8bafdb73e91b56dd411cc5e96c21ccf2c5db123 +extension-cookbook.md: e04f6ffcf9a32ba3ee7344db18cad113b347c667 +extension-cookbook.zh.md: c2fa2f83c39ca0eb93397f39c88265c028224c7a diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index 26f20b8f6c..e04f6ffcf9 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -90,7 +90,7 @@ export function apply(ctx: Context) { ## Runnable wirings -Runnable leaves load their plugin trees from `examples/*/cordis.yml`; the root `demo:*` scripts and those leaf directories are the authoritative inventory. Non-interactive leaves use [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo), ACP leaves use [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo), JSON-RPC leaves use [`@deepseek-ai/dsh-jsonrpc-demo`](../../packages/examples/jsonrpc-demo), and the app packages share [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo). +Runnable leaves load their plugin trees from `examples/*/cordis.yml`; the root `demo:*` scripts and those leaf directories are the authoritative inventory. The product `dsh` launcher owns Web and one-shot headless execution, ACP leaves use [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo), and JSON-RPC leaves use [`@deepseek-ai/dsh-jsonrpc-demo`](../../packages/examples/jsonrpc-demo). The headless snapshot leaf mounts [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) and JSONL persistence explicitly, then drives them through an example-owned test fixture rather than a shipped app package. ## The feature → mechanism map diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md index b8bafdb73e..c2fa2f83c3 100644 --- a/docs/cookbook/extension-cookbook.zh.md +++ b/docs/cookbook/extension-cookbook.zh.md @@ -90,7 +90,7 @@ export function apply(ctx: Context) { ## 可运行的组装示例 -可运行叶子从 `examples/*/cordis.yml` 加载各自的插件树;根目录的 `demo:*` 脚本和这些叶子目录是权威清单。非交互式叶子使用 [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo),ACP 叶子使用 [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo),JSON-RPC 叶子使用 [`@deepseek-ai/dsh-jsonrpc-demo`](../../packages/examples/jsonrpc-demo),应用包共享 [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo)。 +可运行叶子从 `examples/*/cordis.yml` 加载各自的插件树;根目录的 `demo:*` 脚本和这些叶子目录是权威清单。产品 `dsh` 启动器负责 Web 和一次性 headless 执行,ACP 叶子使用 [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo),JSON-RPC 叶子使用 [`@deepseek-ai/dsh-jsonrpc-demo`](../../packages/examples/jsonrpc-demo)。headless 快照叶节点显式挂载 [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) 和 JSONL 持久化,再通过示例自有的测试 fixture(测试前置数据)驱动这些组件,而不是通过已交付的 app 包。 ## 功能→机制映射 @@ -122,7 +122,7 @@ export function apply(ctx: Context) { | Skill(技能) | section + 工具注册;调用时通过 `inject()` 注入 skill 内容 | | 记忆 | section provider + 工具 | | 定时任务(cron) | 插件注册面向模型的调度工具;定时器触发 → 空闲时 `followup(…, {source: {kind: 'cron', …}})`/忙碌时 `inject()` 通知 | -| UI(GUI;CLI 输出 JSONL) | 监听 `session/event`(助手分片、边界、工具活动);输入 → `followup()` | +| UI(GUI;CLI(命令行界面)输出 JSONL) | 监听 `session/event`(助手分片、边界、工具活动);输入 → `followup()` | | 遥测 / 可回放 trace | `session/event` → JSONL;回放 = `sessions.create(id, { seed })` | | 模型适配器 | 通过 `registerAdapter` 注册 `LlmAdapter` 子类(`dsh-llm-deepseek`、`dsh-llm-pi-ai`) | | 插件热重载 | 每个注册都是一个 `ctx.effect` → vendor 的 HMR(热模块替换)直接生效 | diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 4ad9797262..92c2fade12 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -343,7 +343,7 @@ A command was registered or unregistered. This is an unfiltered registry notific 'commands/change'(): void ``` -Source: [`packages/ui/commands/src/index.ts:161`](../../packages/ui/commands/src/index.ts) +Source: [`packages/ui/commands/src/index.ts:172`](../../packages/ui/commands/src/index.ts) ## `credentials/*` @@ -677,7 +677,7 @@ A skill provider, runtime contribution, or provider-backed catalog may have chan 'skills/change'(): void ``` -Source: [`packages/skill/skill/src/index.ts:188`](../../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:283`](../../packages/skill/skill/src/index.ts) ## `subagent/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 4a73dc06ad..a22c3464eb 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -451,7 +451,7 @@ async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise void /** * Ask the active UI provider and wait for the user's answer. * + * When a caller supplies an agent, human interaction is valid only for the + * exact live runtime root. Runtime ownership, not durable session lineage, + * decides this boundary: an owned child has no human answerer and would + * block forever, while a lineage-bearing session resumed as a new runtime + * root may ask normally. + * * @param request Questions, owner agent, and abort signal. * @returns The answer chosen or typed by the human. + * @throws {UserInteractionError} code `CALLER_NOT_LIVE` when a supplied + * agent is not the registry's exact live instance, or `DELEGATED_CALLER` + * when that live agent is owned by another agent. */ async ask(request: AskUserQuestionRequest): Promise ``` diff --git a/docs/cordis-tutorial/01-first-plugin.i18n.yaml b/docs/cordis-tutorial/01-first-plugin.i18n.yaml index 4e829dcb8f..29e0266689 100644 --- a/docs/cordis-tutorial/01-first-plugin.i18n.yaml +++ b/docs/cordis-tutorial/01-first-plugin.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-tutorial/01-first-plugin.md -01-first-plugin.md: c44e7f95fb11d5337ecfaf4251c8b2f2b9b14680 -01-first-plugin.zh.md: 9461884d312ad2e64af12fa42952a986e1ad5d8a +01-first-plugin.md: 4359dfe4883f12e9cb242cf3009827fd7864768c +01-first-plugin.zh.md: 62ccb7e5d37beb5b9636439e563cea6eaa1044a0 diff --git a/docs/cordis-tutorial/01-first-plugin.md b/docs/cordis-tutorial/01-first-plugin.md index c44e7f95fb..4359dfe488 100644 --- a/docs/cordis-tutorial/01-first-plugin.md +++ b/docs/cordis-tutorial/01-first-plugin.md @@ -92,4 +92,4 @@ One caveat worth knowing early: a config entry whose module cannot be **resolved Next: [Lifecycle and effects](02-lifecycle-and-effects.md) — what happens when a plugin unloads. -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/cordis-tutorial/01-first-plugin.zh.md b/docs/cordis-tutorial/01-first-plugin.zh.md index 9461884d31..62ccb7e5d3 100644 --- a/docs/cordis-tutorial/01-first-plugin.zh.md +++ b/docs/cordis-tutorial/01-first-plugin.zh.md @@ -92,4 +92,4 @@ export function apply(ctx: Context) { 下一章:[生命周期与 effect](02-lifecycle-and-effects.md):插件卸载时会发生什么。 -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/cordis-tutorial/02-lifecycle-and-effects.i18n.yaml b/docs/cordis-tutorial/02-lifecycle-and-effects.i18n.yaml index deed723c39..12793267e2 100644 --- a/docs/cordis-tutorial/02-lifecycle-and-effects.i18n.yaml +++ b/docs/cordis-tutorial/02-lifecycle-and-effects.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-tutorial/02-lifecycle-and-effects.md -02-lifecycle-and-effects.md: f1b39e06e9d25c51ab2d76503025e2b6ffe90c73 -02-lifecycle-and-effects.zh.md: 2e98e3af6d2f2b1b9cbb8ea38559bc1ffbf7e43b +02-lifecycle-and-effects.md: 7b195b63a1e8730f27b9dd9af8af6a68a588cee9 +02-lifecycle-and-effects.zh.md: 4a3f83dedd5c95c7fcb5c1aebbbb8cb2e849b9cf diff --git a/docs/cordis-tutorial/02-lifecycle-and-effects.md b/docs/cordis-tutorial/02-lifecycle-and-effects.md index f1b39e06e9..7b195b63a1 100644 --- a/docs/cordis-tutorial/02-lifecycle-and-effects.md +++ b/docs/cordis-tutorial/02-lifecycle-and-effects.md @@ -95,4 +95,4 @@ One ordering caveat: disposers start in reverse registration order, but multiple Next: [Services](03-services.md) — how plugins share capabilities. -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/cordis-tutorial/02-lifecycle-and-effects.zh.md b/docs/cordis-tutorial/02-lifecycle-and-effects.zh.md index 2e98e3af6d..4a3f83dedd 100644 --- a/docs/cordis-tutorial/02-lifecycle-and-effects.zh.md +++ b/docs/cordis-tutorial/02-lifecycle-and-effects.zh.md @@ -95,4 +95,4 @@ PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED 下一章:[服务](03-services.md):插件如何共享功能。 -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/cordis-tutorial/03-services.i18n.yaml b/docs/cordis-tutorial/03-services.i18n.yaml index b116270811..2849ed8858 100644 --- a/docs/cordis-tutorial/03-services.i18n.yaml +++ b/docs/cordis-tutorial/03-services.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-tutorial/03-services.md -03-services.md: 5848132c6ad18338fa893954d45fc20005db6199 -03-services.zh.md: 0f599f082573364e6ad38278e1914d8faf67faa1 +03-services.md: 562b49ede0aa4cc1d58c4d6af7c7d5d1ebb2e4b1 +03-services.zh.md: 964f7e3654614d136b8765bb727f85a5a05587a8 diff --git a/docs/cordis-tutorial/03-services.md b/docs/cordis-tutorial/03-services.md index 5848132c6a..562b49ede0 100644 --- a/docs/cordis-tutorial/03-services.md +++ b/docs/cordis-tutorial/03-services.md @@ -95,4 +95,4 @@ Service names live in one flat namespace per application. Prefix or namespace yo Next: [Events](04-events.md) — communication without a shared service. -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/cordis-tutorial/03-services.zh.md b/docs/cordis-tutorial/03-services.zh.md index 0f599f0825..964f7e3654 100644 --- a/docs/cordis-tutorial/03-services.zh.md +++ b/docs/cordis-tutorial/03-services.zh.md @@ -95,4 +95,4 @@ export function apply(ctx: Context) { 下一章:[事件](04-events.md):无需共享服务即可通信。 -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/cordis-tutorial/04-events.i18n.yaml b/docs/cordis-tutorial/04-events.i18n.yaml index 6d21e5ff1b..e7dc182114 100644 --- a/docs/cordis-tutorial/04-events.i18n.yaml +++ b/docs/cordis-tutorial/04-events.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-tutorial/04-events.md -04-events.md: 18f39dc1b693e5fb7e1793ec4b7dcac9cf24db95 -04-events.zh.md: 3fdafb50303f49dca179bcaea32db211a66241f6 +04-events.md: 28ccb85d657afaabb5c6b4b1e9b10d6cf8710918 +04-events.zh.md: f78c971dcd9674d2a256c41000b627aecb2a572a diff --git a/docs/cordis-tutorial/04-events.md b/docs/cordis-tutorial/04-events.md index 18f39dc1b6..28ccb85d65 100644 --- a/docs/cordis-tutorial/04-events.md +++ b/docs/cordis-tutorial/04-events.md @@ -141,4 +141,4 @@ The harness uses waterfalls for decisions that cooperating plugins may wrap or a Next: [Configuration](05-config.md) — plugin options from `cordis.yml`. -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/cordis-tutorial/04-events.zh.md b/docs/cordis-tutorial/04-events.zh.md index 3fdafb5030..f78c971dcd 100644 --- a/docs/cordis-tutorial/04-events.zh.md +++ b/docs/cordis-tutorial/04-events.zh.md @@ -141,4 +141,4 @@ harness 使用 waterfall 处理协作插件可以包装或回答的决策:[`ag 下一章:[配置](05-config.md):来自 `cordis.yml` 的插件选项。 -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/cordis-tutorial/05-config.i18n.yaml b/docs/cordis-tutorial/05-config.i18n.yaml index 01047ad516..7db45165c4 100644 --- a/docs/cordis-tutorial/05-config.i18n.yaml +++ b/docs/cordis-tutorial/05-config.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-tutorial/05-config.md -05-config.md: fc19add239636fa9e7071d9c77e48595caec1f08 -05-config.zh.md: 0c8170f518f0c87ab5c754606436496a4ff9d51e +05-config.md: 834bb140cc1ff976acc8f21c8f54a7fb02636eac +05-config.zh.md: f5cc6ac1ca4fa02eba6a1b015b9f6ae3b1a925fc diff --git a/docs/cordis-tutorial/05-config.md b/docs/cordis-tutorial/05-config.md index fc19add239..834bb140cc 100644 --- a/docs/cordis-tutorial/05-config.md +++ b/docs/cordis-tutorial/05-config.md @@ -69,16 +69,16 @@ The plugin's fiber goes to FAILED, and this tutorial's launcher exits with statu ## Computed config values -The loader used in this repo supports a `!!js` tag for config values that must be computed at load time, such as reading an API key from the environment: +The loader used in this repo supports a `!!js` tag for config values that must be computed at load time: ```yaml -- name: '@deepseek-ai/dsh-llm-deepseek' +- name: './config-demo.ts' config: - apiKey: !!js process.env.DEEPSEEK_API_KEY + greeting: !!js process.env.DEMO_GREETING ?? 'Hello' ``` `!!js` works **only inside `config`**. Entry metadata (`name`, `id`, `disabled`, `inject`, ...) is static; `disabled: !!js ...` produces a truthy expression object that always disables the entry. See [loader configuration](../cordis-primer.md#loader-configuration). Next: [Composition and HMR](06-composition-and-hmr.md) — treating `cordis.yml` as the application. -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/cordis-tutorial/05-config.zh.md b/docs/cordis-tutorial/05-config.zh.md index 0c8170f518..f5cc6ac1ca 100644 --- a/docs/cordis-tutorial/05-config.zh.md +++ b/docs/cordis-tutorial/05-config.zh.md @@ -69,16 +69,16 @@ ValidationError: invalid config: ## 计算得到的配置值 -本仓库使用的 loader 支持 `!!js` 标签,用于必须在加载时计算的配置值,例如从环境中读取 API key: +本仓库使用的 loader 支持 `!!js` 标签,用于必须在加载时计算的配置值: ```yaml -- name: '@deepseek-ai/dsh-llm-deepseek' +- name: './config-demo.ts' config: - apiKey: !!js process.env.DEEPSEEK_API_KEY + greeting: !!js process.env.DEMO_GREETING ?? 'Hello' ``` `!!js` **仅在 `config` 内有效**。Cordis 配置项的元数据(`name`、`id`、`disabled`、`inject` 等)是静态的;`disabled: !!js ...` 会生成一个真值表达式对象,始终禁用该 Cordis 配置项。详见 [loader 配置](../cordis-primer.md#loader-configuration)。 下一章:[组合与 HMR(热模块替换)](06-composition-and-hmr.md):将 `cordis.yml` 视为应用。 -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/cordis-tutorial/06-composition-and-hmr.i18n.yaml b/docs/cordis-tutorial/06-composition-and-hmr.i18n.yaml index f7abfca742..44b59db26a 100644 --- a/docs/cordis-tutorial/06-composition-and-hmr.i18n.yaml +++ b/docs/cordis-tutorial/06-composition-and-hmr.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-tutorial/06-composition-and-hmr.md -06-composition-and-hmr.md: 66d6a9d93fe39baa881940ba32388979e2678505 -06-composition-and-hmr.zh.md: 7c0a94b0abcc0f153f59391fd009f1e0b40500e5 +06-composition-and-hmr.md: f138918a2d217ed98fdfd4e56dffddc14e3397f0 +06-composition-and-hmr.zh.md: a678e86735c6d9e3b4f3cd0dfa46a2a64079c762 diff --git a/docs/cordis-tutorial/06-composition-and-hmr.md b/docs/cordis-tutorial/06-composition-and-hmr.md index 66d6a9d93f..f138918a2d 100644 --- a/docs/cordis-tutorial/06-composition-and-hmr.md +++ b/docs/cordis-tutorial/06-composition-and-hmr.md @@ -110,4 +110,4 @@ needs-timer is PENDING — a required service is missing Next: [Into the harness](07-into-the-harness.md) — the same patterns against real harness services. -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/cordis-tutorial/06-composition-and-hmr.zh.md b/docs/cordis-tutorial/06-composition-and-hmr.zh.md index 7c0a94b0ab..a678e86735 100644 --- a/docs/cordis-tutorial/06-composition-and-hmr.zh.md +++ b/docs/cordis-tutorial/06-composition-and-hmr.zh.md @@ -110,4 +110,4 @@ needs-timer is PENDING — a required service is missing 下一章:[进入 harness](07-into-the-harness.md):把相同模式用于真实的 harness 服务。 -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/cordis-tutorial/07-into-the-harness.i18n.yaml b/docs/cordis-tutorial/07-into-the-harness.i18n.yaml index 5faa0bf213..f3dde47f3a 100644 --- a/docs/cordis-tutorial/07-into-the-harness.i18n.yaml +++ b/docs/cordis-tutorial/07-into-the-harness.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-tutorial/07-into-the-harness.md -07-into-the-harness.md: 6ec42c50fe5059955734fe7bc46117538dafaffc -07-into-the-harness.zh.md: 903adb903aa4c4355b92eb34e89f218a0295767c +07-into-the-harness.md: e02f8f8d55b3fbe9087d46f8f50baeecb592c1c6 +07-into-the-harness.zh.md: 5f770267e8f3db04cd9cb0e92b6a6278cf05d5e4 diff --git a/docs/cordis-tutorial/07-into-the-harness.md b/docs/cordis-tutorial/07-into-the-harness.md index 6ec42c50fe..e02f8f8d55 100644 --- a/docs/cordis-tutorial/07-into-the-harness.md +++ b/docs/cordis-tutorial/07-into-the-harness.md @@ -104,4 +104,4 @@ Where to go next: - The generated [services](../cordis-catalog/services.md) and [events](../cordis-catalog/events.md) catalogs — everything you can inject and listen to. - [Architecture](../architecture.md) — the system map these plugins live in. -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/cordis-tutorial/07-into-the-harness.zh.md b/docs/cordis-tutorial/07-into-the-harness.zh.md index 903adb903a..5f770267e8 100644 --- a/docs/cordis-tutorial/07-into-the-harness.zh.md +++ b/docs/cordis-tutorial/07-into-the-harness.zh.md @@ -104,4 +104,4 @@ logger 会先触发:`tools/result` 在结果物化过程中发出,发生在 - 生成的[服务](../cordis-catalog/services.md)与[事件](../cordis-catalog/events.md)目录:可以注入和监听的所有内容。 - [架构](../architecture.md):这些插件所处的系统地图。 -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/cordis-tutorial/index.i18n.yaml b/docs/cordis-tutorial/index.i18n.yaml index 256bc4f629..496a3fffa5 100644 --- a/docs/cordis-tutorial/index.i18n.yaml +++ b/docs/cordis-tutorial/index.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-tutorial/index.md -index.md: af622ad4e35829c6283c40f1b0019d7959dac973 -index.zh.md: 0b7684a9532a1efdcc3ea2d067da23852d146e2f +index.md: a20976706f520416236ca759ee33649d1601eaa9 +index.zh.md: f6989521d4b7dffac6114867cc12371af4e4316f diff --git a/docs/cordis-tutorial/index.md b/docs/cordis-tutorial/index.md index af622ad4e3..a20976706f 100644 --- a/docs/cordis-tutorial/index.md +++ b/docs/cordis-tutorial/index.md @@ -13,7 +13,7 @@ If you want the condensed concept reference instead of a walkthrough, read the [ You need a clone of this repository with dependencies installed — the [quick start](../user/guide/quickstart.md) covers prerequisites. No API key is needed for this tutorial; every example runs keylessly. ```sh -git clone https://github.com/deepseek-harness/deepseek-harness.git +git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git cd deepseek-harness pnpm install ``` @@ -55,4 +55,4 @@ The examples use three TypeScript features beyond ordinary modern JavaScript: Chapter 5 also uses an `interface` to describe a configuration object's fields and a generic type such as `Schema` to say which object shape a schema validates. You can copy those declarations as shown; the surrounding text explains what each one connects. -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/cordis-tutorial/index.zh.md b/docs/cordis-tutorial/index.zh.md index 0b7684a953..f6989521d4 100644 --- a/docs/cordis-tutorial/index.zh.md +++ b/docs/cordis-tutorial/index.zh.md @@ -13,7 +13,7 @@ Cordis 是 DeepSeek Harness SDK 底层的插件框架:它是一个小型运行 你需要克隆本仓库并安装依赖,具体前置条件见[快速入门](../user/guide/quickstart.md)。本教程不需要 API 密钥;所有示例均可在无密钥环境中运行。 ```sh -git clone https://github.com/deepseek-harness/deepseek-harness.git +git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git cd deepseek-harness pnpm install ``` @@ -55,4 +55,4 @@ node --import tsx ../../vendor/cordis/bin.js 第 5 章还会使用 `interface` 描述配置对象的字段,并使用 `Schema` 这类泛型表示 schema 所校验的对象形状。你可以直接照写这些声明;周围的正文会解释每项声明连接了什么。 -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/core-data-structures/commands.i18n.yaml b/docs/core-data-structures/commands.i18n.yaml index f454a69873..532151ef70 100644 --- a/docs/core-data-structures/commands.i18n.yaml +++ b/docs/core-data-structures/commands.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/commands.md -commands.md: bc7cf676232a4271d7bc21d6688bc598d9325957 -commands.zh.md: a2f82c06ac7ca87c5d5cfa525aaf9afa8777e9c9 +commands.md: 1390f736ff582a997ee710cfd27fce4e7db3461f +commands.zh.md: 53aec9bd138773df7fceb45e5d6c30b7205dd0d9 diff --git a/docs/core-data-structures/commands.md b/docs/core-data-structures/commands.md index bc7cf67623..1390f736ff 100644 --- a/docs/core-data-structures/commands.md +++ b/docs/core-data-structures/commands.md @@ -61,10 +61,17 @@ interface CommandInvocation { ```ts type-equiv /** Expected command outcome rendered directly by the dispatching UI. */ type CommandResult = - | { readonly kind: 'success'; readonly text?: string } + | { + readonly kind: 'success' + readonly text?: string + /** Earlier authoritative domain event that owns a richer presentation. */ + readonly sourceEventSeq?: number + } | { readonly kind: 'error'; readonly text: string } ``` +`sourceEventSeq` is optional and success-only. When present, it names an earlier non-command event in the receiving session log; `command/done` persists the same reference so a client can combine the command lifecycle with that domain projection without parsing `text` or relying on adjacent rows. + ## Discovery and parsing views Adapters receive handler-free immutable descriptors after scope resolution. `parseCommand()` returns `ParsedCommand` before registry resolution; syntax-valid input can still name an unavailable command. diff --git a/docs/core-data-structures/commands.zh.md b/docs/core-data-structures/commands.zh.md index a2f82c06ac..53aec9bd13 100644 --- a/docs/core-data-structures/commands.zh.md +++ b/docs/core-data-structures/commands.zh.md @@ -61,10 +61,17 @@ interface CommandInvocation { ```ts type-equiv /** Expected command outcome rendered directly by the dispatching UI. */ type CommandResult = - | { readonly kind: 'success'; readonly text?: string } + | { + readonly kind: 'success' + readonly text?: string + /** Earlier authoritative domain event that owns a richer presentation. */ + readonly sourceEventSeq?: number + } | { readonly kind: 'error'; readonly text: string } ``` +`sourceEventSeq` 是可选字段,且只用于成功结果。存在时,它指向接收会话日志中更早的一条非命令事件;`command/done` 会持久化同一引用,让客户端能够将命令生命周期与该领域投影合并,而无须解析 `text` 或依赖相邻行。 + ## 发现与解析视图 作用域解析后,适配器会获得不含处理器的不可变描述符。`parseCommand()` 在注册表解析前返回 `ParsedCommand`;语法有效的输入仍可能指向不可用的命令。 diff --git a/docs/core-data-structures/compaction.i18n.yaml b/docs/core-data-structures/compaction.i18n.yaml index a0c3f27f88..2ad9ea8e8a 100644 --- a/docs/core-data-structures/compaction.i18n.yaml +++ b/docs/core-data-structures/compaction.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/compaction.md -compaction.md: fe64ffe2707ab4a41f1db186965b944d525684c3 -compaction.zh.md: ea694f30dcc50ef48847ffbd5efdeed36aa234c5 +compaction.md: f1df5b83bd43136af60988dabd9dc68fe32467a2 +compaction.zh.md: 52540250d466f81f51cf7c681bb6f1436e29a12d diff --git a/docs/core-data-structures/compaction.md b/docs/core-data-structures/compaction.md index fe64ffe270..f1df5b83bd 100644 --- a/docs/core-data-structures/compaction.md +++ b/docs/core-data-structures/compaction.md @@ -13,7 +13,7 @@ Compaction extends [`SessionEventMap`](session.md) with three event types via de | Event | Payload | Role | |---|---|---| | `compact/start` | `{ turn }` | acquires the log-recorded lock; a number identifies the open automatic turn, while `null` identifies a standalone manual attempt | -| `compact/summary` | `{ summary, rawOutput?, shadowedRange, shadowedSeqs, shadowedTokenCount, provider, model, maxTokens?, usage? }` | provenance: the safe summary projection, optional complete provider output and usage, the shadowed surface-boundary pair (`start`/`end` seqs — a position span, not a numeric interval), the shadowed seqs in surface order, the estimated token count, and the summarize call's envelope (`provider`, `model`, plus its generation cap when one applied) — logged so the one-shot request is reconstructable from log + code (the reconstructability Agent Note) | +| `compact/summary` | `{ summary, rawOutput?, llmStreamCall?, shadowedRange, shadowedSeqs, shadowedTokenCount, provider, model, maxTokens?, usage? }` | provenance: the safe summary projection, optional complete provider output and usage, an `llmStreamCall: true` marker when producing the result consumed exactly one call through this context's `ctx.llm.stream()` (which requires complete `rawOutput`), the shadowed surface-boundary pair (`start`/`end` seqs — a position span, not a numeric interval), the shadowed seqs in surface order, the estimated token count, and the summarize call's envelope (`provider`, `model`, plus its generation cap when one applied) — logged so the one-shot request is reconstructable from log + code (the reconstructability Agent Note); unmarked `rawOutput` does not identify the call path | | `compact/end` | `{ turn, error? }` | releases the lock with the same numeric-or-null owner (`error` records an unsuccessful attempt) | The lock brackets the **whole** operation: `compact/start` is appended first, then summarization, the `compact/summary` provenance record, and the `user/message` replacement all land, and only then `compact/end`. Releasing the lock last turns a crash mid-operation into a detectable orphaned lock (a `compact/start` with no matching `compact/end`) rather than a `compact/end` that falsely claims compaction finished. diff --git a/docs/core-data-structures/compaction.zh.md b/docs/core-data-structures/compaction.zh.md index ea694f30dc..52540250d4 100644 --- a/docs/core-data-structures/compaction.zh.md +++ b/docs/core-data-structures/compaction.zh.md @@ -13,7 +13,7 @@ | 事件 | 载荷 | 作用 | |---|---|---| | `compact/start` | `{ turn }` | 获取日志记录的锁;数字标识打开的自动轮次,`null` 标识独立手动尝试 | -| `compact/summary` | `{ summary, rawOutput?, shadowedRange, shadowedSeqs, shadowedTokenCount, provider, model, maxTokens?, usage? }` | provenance:安全摘要投影、可选的完整 provider 输出与 usage、被遮蔽的 surface 边界对(`start`/`end` seq——位置跨度,而非数值区间)、按 surface 顺序排列的被遮蔽 seq、估算 token 数,以及摘要调用的 envelope(`provider`、`model`,若有生成上限则还包括该上限)——写入日志后,该一次性请求可由日志 + 代码重建(见可重建性 Agent Note) | +| `compact/summary` | `{ summary, rawOutput?, llmStreamCall?, shadowedRange, shadowedSeqs, shadowedTokenCount, provider, model, maxTokens?, usage? }` | provenance:安全摘要投影、可选的完整 provider 输出与 usage、生成结果时恰好通过此上下文的 `ctx.llm.stream()` 发起一次调用所带的 `llmStreamCall: true` 标记(此时必须提供完整的 `rawOutput`)、被遮蔽的 surface 边界对(`start`/`end` seq——位置跨度,而非数值区间)、按 surface 顺序排列的被遮蔽 seq、估算 token 数,以及摘要调用的 envelope(`provider`、`model`,若有生成上限则还包括该上限)——写入日志后,该一次性请求可由日志 + 代码重建(见可重建性 Agent Note);未带标记的 `rawOutput` 并不能判定调用路径 | | `compact/end` | `{ turn, error? }` | 使用相同的数字或 `null` 归属值释放锁(`error` 记录失败尝试) | 锁括住**整个**操作:先追加 `compact/start`,然后执行摘要生成、写入 `compact/summary` 来源记录与 `user/message` 替换,最后才追加 `compact/end`。最后释放锁意味着操作中途崩溃会表现为可检测的遗留锁(有 `compact/start` 而无匹配的 `compact/end`),而非一个虚假声称压缩已完成的 `compact/end`。 diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index 461d1ef4fc..e79d5dac2f 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/core.md -core.md: f7cf288715a3aec2f7037f12fc983e3172a77cef -core.zh.md: c17fd1335503c95e7f7f6f96cc286f567a8384e6 +core.md: dbd584f10b3daf873bc14210472efb6cd315717e +core.zh.md: 1fe1616a0c96abb4e8b91417cc4eae292416e42a diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index f7cf288715..dbd584f10b 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -530,8 +530,6 @@ The loop builds each request from logged state. `EpochHeader` records call confi On the wire, a loop-built request reads the `system` slot (the rendered prompt assembly) followed by the derived history — the boundary snapshot, whose tail is the newest `user/message` on a turn's first step and the previous step's tool results on later steps. The dev invariant recomputes exactly this equation against every loop-built request. -FIXME(call-config-shape): revisit which remaining fields are genuinely epoch-level for cache purposes (`model` and the model-owned reasoning effort are explicit; the sampling scalars sit here out of caution). - ```ts type-equiv /** * Provider, model, reasoning effort, and sampling scalars of one conversation's diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index c17fd13355..1fe1616a0c 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -536,8 +536,6 @@ interface ToolSchema { 在协议格式上,循环构建的请求先读取 `system` 槽位(渲染后的提示词组装),再读取派生历史——边界快照,其尾部在轮次首步是最新的 `user/message`,在后续步骤是上一步的工具结果。开发不变式针对每个循环构建的请求精确重算此等式。 -FIXME(call-config-shape):重新审视其余哪些字段出于缓存目的确实属于 epoch 层级(`model` 和模型持有的推理强度已明确属于;采样标量目前出于谨慎保留在此)。 - ```ts type-equiv /** * Provider, model, reasoning effort, and sampling scalars of one conversation's diff --git a/docs/core-data-structures/credentials.i18n.yaml b/docs/core-data-structures/credentials.i18n.yaml index 23bb940afe..d44275d97e 100644 --- a/docs/core-data-structures/credentials.i18n.yaml +++ b/docs/core-data-structures/credentials.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/credentials.md -credentials.md: 3f6fcd127d01e2c49e17c70c002bebe9f363e951 -credentials.zh.md: b5d2d9e164a85ce090790635c438b768cae4c9ca +credentials.md: ef74ddeb4346e18f8d5d33488657e5d50f1d754e +credentials.zh.md: 09cf374a2346fd93aa834e3372321e6eeece6ed8 diff --git a/docs/core-data-structures/credentials.md b/docs/core-data-structures/credentials.md index 3f6fcd127d..ef74ddeb43 100644 --- a/docs/core-data-structures/credentials.md +++ b/docs/core-data-structures/credentials.md @@ -24,7 +24,7 @@ type CredentialRef = Branded<'CredentialRef'> interface ResolvedCredential { /** The non-empty secret value. */ value: string - /** Provider-defined source layer id (the local provider uses `env` and `file`). */ + /** Provider-defined source layer id (the local provider uses `env`, `file`, `project-env`, and `user-env`). */ source: string } ``` diff --git a/docs/core-data-structures/credentials.zh.md b/docs/core-data-structures/credentials.zh.md index b5d2d9e164..09cf374a23 100644 --- a/docs/core-data-structures/credentials.zh.md +++ b/docs/core-data-structures/credentials.zh.md @@ -24,7 +24,7 @@ type CredentialRef = Branded<'CredentialRef'> interface ResolvedCredential { /** The non-empty secret value. */ value: string - /** Provider-defined source layer id (the local provider uses `env` and `file`). */ + /** Provider-defined source layer id (the local provider uses `env`, `file`, `project-env`, and `user-env`). */ source: string } ``` diff --git a/docs/core-data-structures/persistence.i18n.yaml b/docs/core-data-structures/persistence.i18n.yaml index 58321e0f22..fe28a36f40 100644 --- a/docs/core-data-structures/persistence.i18n.yaml +++ b/docs/core-data-structures/persistence.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/persistence.md -persistence.md: 0968496201defa869d94925e8e5ae3c5da1bbd37 -persistence.zh.md: efb01427b4355e531fb9b86922223cf27d3b3db0 +persistence.md: 3d2bc7ff3b45cd3f38fca0b880a0df9adb928bad +persistence.zh.md: 035f46d76e0189bd686e991c3d56d486b6f309d5 diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index 0968496201..3d2bc7ff3b 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -8,7 +8,7 @@ The seam is a textbook [capability seam](../../.agents/notes/implemented/archite ## The flush checkpoint -`session/event` is a *synchronous* notification; persistence plugins copy the event into a per-session controller and start an eager write without blocking the producer. Concurrent events share the current batch, and events admitted during that write trigger a follow-up batch. `session/flush` waits until no current or pending batch remains, so the loop still uses it as the ordering and error-observation checkpoint before claiming the next ordinary turn. A rejected eager write retains its events; an explicit flush retries them and reports failure through `agent/error` and the logger, never as a session event past the closed turn. Disposal performs the same final drain. +`session/event` is a *synchronous* notification; persistence plugins copy the event into a per-session controller without blocking the producer. The first pending event starts a fixed batching window, and later events join without resetting its deadline. Expiry starts one durable batch; events admitted during that write receive their own deadline and form a follow-up batch. `session/flush` cancels the wait and drains through quiescence, so the loop still uses it as the ordering and error-observation checkpoint before claiming the next ordinary turn. A rejected background write retains its events and pauses automatic retry; a new event starts a fresh window, while explicit flush retries immediately and reports failure through `agent/error` and the logger, never as a session event past the closed turn. Disposal performs the same final drain. The configured maximum bounds only intentional batching wait, not event-loop scheduling or backend durability latency ([decision](../../.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md)). ## Crash recovery preserves an interrupted turn diff --git a/docs/core-data-structures/persistence.zh.md b/docs/core-data-structures/persistence.zh.md index efb01427b4..035f46d76e 100644 --- a/docs/core-data-structures/persistence.zh.md +++ b/docs/core-data-structures/persistence.zh.md @@ -8,7 +8,7 @@ ## flush 检查点 -`session/event` 是一个*同步*通知;持久化插件会将事件复制到逐会话控制器,并立即启动写入而不阻塞生产方。并发事件会加入当前批次;在该批次写入期间接纳的事件会触发后续批次。`session/flush` 会等待当前与待处理批次全部清空,因此循环仍将其用作在领取下一个普通轮次之前的顺序与错误观察检查点。立即写入被拒绝时会保留对应事件;显式 flush 会重试这些事件,并通过 `agent/error` 和 logger 报告失败,绝不会把失败记录成已关闭轮次之后的会话事件。dispose(资源释放)会执行同样的最终排空。 +`session/event` 是一个*同步*通知;持久化插件会将事件复制到逐会话控制器,而不阻塞生产方。第一个待处理事件会开启固定批处理窗口,后续事件会加入但不会重置截止时间。窗口到期后会启动一个持久化批次;该次写入期间接纳的事件会获得自己的截止时间,并形成后续批次。`session/flush` 会取消等待并排空至完全停稳,因此循环仍将其用作在领取下一个普通轮次之前的顺序与错误观察检查点。后台写入被拒绝时会保留对应事件并暂停自动重试;新事件会开启新的固定窗口,而显式 flush 会立即重试,并通过 `agent/error` 和 logger 报告失败,绝不会把失败记录成已关闭轮次之后的会话事件。dispose(资源释放)会执行同样的最终排空。配置的最大值只限制有意的批处理等待,不限制事件循环调度或后端完成持久化的延迟([决策](../../.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md))。 ## 崩溃恢复保留被中断的轮次 diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml index 78626957f6..16e969bd94 100644 --- a/docs/core-data-structures/session.i18n.yaml +++ b/docs/core-data-structures/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/session.md -session.md: fb04ff3eb3c15d1f3c923ee73d4ba19537d36f11 -session.zh.md: 138159d97a5c368f28821715512632e0ca95b29e +session.md: 5d54b03df8ec3345e8bf04702f242e3aacf8ec39 +session.zh.md: 5a867cfe302bf02994bfe5a5a704bed55222e8ea diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index fb04ff3eb3..5d54b03df8 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -561,7 +561,7 @@ interface TurnEndReasonMap { ## Execution enclosure and standalone events -A turn encloses one model-loop execution, not the whole session log. AgentLoop records injected `user/message` events only from entering pre-step batches inside a turn; plugin-owned log-only events may still appear between `turn/end` and the next `turn/start`, consuming event seqs without incrementing turn numbers. Persistence eagerly records every contiguous accepted event, while crash repair closes only a genuinely open trailing turn. A producer that needs a durability barrier explicitly awaits `ctx.sessions.flush(session)`. +A turn encloses one model-loop execution, not the whole session log. AgentLoop records injected `user/message` events only from entering pre-step batches inside a turn; plugin-owned log-only events may still appear between `turn/end` and the next `turn/start`, consuming event seqs without incrementing turn numbers. Persistence admits every contiguous accepted event into a bounded durable batch, while crash repair closes only a genuinely open trailing turn. A producer that needs an immediate durability barrier explicitly awaits `ctx.sessions.flush(session)`. The optional `dsh-session/invariant` companion enforces the relations owned by core: turn and step numbering, execution-event enclosure, and same-step tool call/result pairing. Merge-extensible event relations belong to the plugin that declares them, so core does not reject an unknown event merely because no turn is open. See [the standalone-event decision](../../.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md). diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md index 138159d97a..5a867cfe30 100644 --- a/docs/core-data-structures/session.zh.md +++ b/docs/core-data-structures/session.zh.md @@ -565,7 +565,7 @@ interface TurnEndReasonMap { ## 执行封闭与独立事件 -一个轮次包围一次模型循环执行,而不是整个会话日志。AgentLoop 只会从轮次内返回 enter 的 pre-step 批次记录注入的 `user/message` 事件;插件所属的纯日志事件仍可出现在 `turn/end` 与下一个 `turn/start` 之间,占用事件 seq 但不递增轮次编号。持久化会尽快记录每个连续且已接受的事件,而崩溃修复只关闭确实仍处于开放状态的尾部轮次。需要持久性屏障的生产方会显式等待 `ctx.sessions.flush(session)`。 +一个轮次包围一次模型循环执行,而不是整个会话日志。AgentLoop 只会从轮次内返回 enter 的 pre-step 批次记录注入的 `user/message` 事件;插件所属的纯日志事件仍可出现在 `turn/end` 与下一个 `turn/start` 之间,占用事件 seq 但不递增轮次编号。持久化会将每个连续且已接受的事件纳入有界持久化批次,而崩溃修复只关闭确实仍处于开放状态的尾部轮次。需要即时持久性屏障的生产方会显式等待 `ctx.sessions.flush(session)`。 可选的 `dsh-session/invariant` 配套插件会强制核心拥有的关系:轮次与步骤编号、执行事件封闭,以及同一步骤内的工具调用/结果配对。可合并扩展事件的关系由声明它的插件拥有,因此核心不会仅因没有开放轮次就拒绝未知事件。见[独立事件决策](../../.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md)。 diff --git a/docs/core-data-structures/skills.i18n.yaml b/docs/core-data-structures/skills.i18n.yaml index ea1c7a28bb..d0ce7b7574 100644 --- a/docs/core-data-structures/skills.i18n.yaml +++ b/docs/core-data-structures/skills.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/skills.md -skills.md: 16e6fb649f9db4d7be47468adf0bf8a00df428c3 -skills.zh.md: 7d69b84f7be200b795e7a58eb3de1f029a8024f5 +skills.md: d862cbc07135680c2377b4c8c82c80d055344221 +skills.zh.md: 3f8c034ec2aa24b4acdbbc1e2ac27717c21649d5 diff --git a/docs/core-data-structures/skills.md b/docs/core-data-structures/skills.md index 16e6fb649f..d862cbc071 100644 --- a/docs/core-data-structures/skills.md +++ b/docs/core-data-structures/skills.md @@ -2,9 +2,9 @@ English | [中文](skills.zh.md) -The [skill capability family](../../packages/skill) is split across three packages: the registry ([dsh-skill](../../packages/skill/skill), `ctx.skills`) merges provider catalogs; the local provider ([dsh-skill-local](../../packages/skill/skill-local)) scans and watches project/custom/user directories; the consumer ([dsh-tool-skill](../../packages/skill/tool-skill)) owns the initial and replacement catalogs plus the model-facing `skill` tool. Skills are optional instructions, not session events, so their vocabulary lives here rather than in [core.md](core.md). +The [skill capability family](../../packages/skill) includes the registry ([dsh-skill](../../packages/skill/skill), `ctx.skills`), the local provider ([dsh-skill-local](../../packages/skill/skill-local)), the optional packaged badge provider ([dsh-skill-badge](../../packages/skill/skill-badge)), and the consumer ([dsh-tool-skill](../../packages/skill/tool-skill)). The registry merges provider catalogs; providers contribute local or packaged skills; the consumer owns the initial and replacement catalogs plus the model-facing `skill` tool. Skills are optional instructions, not session events, so their vocabulary lives here rather than in [core.md](core.md). -Source: [`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/index.ts), [`packages/skill/skill-local/src/index.ts`](../../packages/skill/skill-local/src/index.ts), and [`packages/skill/tool-skill/src/index.ts`](../../packages/skill/tool-skill/src/index.ts). +Source: [`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/index.ts), [`packages/skill/skill-local/src/index.ts`](../../packages/skill/skill-local/src/index.ts), [`packages/skill/skill-badge/src/index.ts`](../../packages/skill/skill-badge/src/index.ts), and [`packages/skill/tool-skill/src/index.ts`](../../packages/skill/tool-skill/src/index.ts). ## Provider registry @@ -72,7 +72,9 @@ The shipped local provider scans roots in rank order: | 500 | `user-agents` | `/skills` | | 600 | `bundled` | `Config.bundledSkillDir` when configured | -The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. When `ctx.fs` is available, the git-root walk probes `.git` through the filesystem service so remote or sandboxed workspaces do not fall back to the host filesystem boundary. The user DSH root skips its `.system` child. The local provider does not ship built-in system skills; deployments supply built-ins through another provider. +The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. When `ctx.fs` is available, the git-root walk probes `.git` through the filesystem service so remote or sandboxed workspaces do not fall back to the host filesystem boundary. The user DSH root skips its `.system` child. The local provider does not synthesize built-in system skills; deployments supply packaged skills through configured bundled roots or dedicated providers. + +`dsh-skill-badge` registers one immutable `bundled` candidate at `BUNDLED_SKILL_RANK` and exposes its packaged asset directory through `resourceBase`. The shipped CLI declares the plugin disabled, so enabling its composition row is an explicit opt-in. Chokidar watches existing roots for direct bundle/flat-entry additions and removals plus direct skill-entry changes. A missing root is followed one absent path segment at a time from its nearest existing ancestor until Chokidar can attach. Resource files below a bundle are not catalog changes. Model-facing `write` and `edit` observations synchronously invalidate the provider when their target is catalog-relevant, while the host watcher covers IDE, Git, shell, and external-process mutations. Watcher failures make the current observation incomplete without hiding readable candidates from direct loads; project-scoped watchers use a configured bounded LRU. diff --git a/docs/core-data-structures/skills.zh.md b/docs/core-data-structures/skills.zh.md index 7d69b84f7b..3f8c034ec2 100644 --- a/docs/core-data-structures/skills.zh.md +++ b/docs/core-data-structures/skills.zh.md @@ -2,9 +2,9 @@ [English](skills.md) | 中文 -[skill(技能)能力族](../../packages/skill) 拆分为三个包:注册表([dsh-skill](../../packages/skill/skill),`ctx.skills`)合并各提供方的目录;本地提供方([dsh-skill-local](../../packages/skill/skill-local))扫描并监视项目、自定义和用户目录;消费方([dsh-tool-skill](../../packages/skill/tool-skill))拥有初始目录和替换目录,以及面向模型的 `skill` 工具。skill 是可选的指令而非会话事件,因此其词汇定义在此处而非 [core.md](core.md)。 +[skill(技能)能力族](../../packages/skill) 包含注册表([dsh-skill](../../packages/skill/skill),`ctx.skills`)、本地提供方([dsh-skill-local](../../packages/skill/skill-local))、可选的随包徽章提供方([dsh-skill-badge](../../packages/skill/skill-badge))和消费方([dsh-tool-skill](../../packages/skill/tool-skill))。注册表合并各提供方的目录;提供方贡献本地或随包 skill;消费方拥有初始目录和替换目录,以及面向模型的 `skill` 工具。skill 是可选的指令而非会话事件,因此其词汇定义在此处而非 [core.md](core.md)。 -源码:[`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/index.ts)、[`packages/skill/skill-local/src/index.ts`](../../packages/skill/skill-local/src/index.ts) 与 [`packages/skill/tool-skill/src/index.ts`](../../packages/skill/tool-skill/src/index.ts)。 +源码:[`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/index.ts)、[`packages/skill/skill-local/src/index.ts`](../../packages/skill/skill-local/src/index.ts)、[`packages/skill/skill-badge/src/index.ts`](../../packages/skill/skill-badge/src/index.ts) 与 [`packages/skill/tool-skill/src/index.ts`](../../packages/skill/tool-skill/src/index.ts)。 ## 提供方注册表 @@ -72,7 +72,9 @@ interface SkillProviderControl { | 500 | `user-agents` | `/skills` | | 600 | `bundled` | 配置了 `Config.bundledSkillDir` 时使用该目录 | -项目根目录为包含 `.git` 的最近祖先目录;找不到时使用当前 cwd。当 `ctx.fs` 可用时,git-root 向上查找通过文件系统服务探测 `.git`,使远程或沙箱工作区不会回退到宿主文件系统边界。用户 DSH 根目录会跳过其 `.system` 子目录。本地提供方不附带内置系统 skill;部署方通过另一个提供方提供内置 skill。 +项目根目录为包含 `.git` 的最近祖先目录;找不到时使用当前 cwd。当 `ctx.fs` 可用时,git-root 向上查找通过文件系统服务探测 `.git`,使远程或沙箱工作区不会回退到宿主文件系统边界。用户 DSH 根目录会跳过其 `.system` 子目录。本地提供方不会合成内置系统 skill;部署方通过已配置的 bundled 根目录或专用提供方提供随包 skill。 + +`dsh-skill-badge` 在 `BUNDLED_SKILL_RANK` 注册一个不可变的 `bundled` 候选项,并通过 `resourceBase` 公开其随包资产目录。交付的 CLI(命令行界面)将该插件声明为禁用,因此启用其组合配置行即为显式选择加入。 Chokidar 会监视现有根目录中直属 bundle 和平铺条目的添加与移除,以及直属 skill 条目的变更。缺失的根目录会从最近的现有祖先开始,逐个跟踪缺失路径段,直至 Chokidar 可以附加。bundle 下的资源文件变更不属于目录变更。面向模型的 `write` 和 `edit` 观测会在目标路径相关时同步使提供方目录失效,而宿主 watcher 覆盖 IDE、Git、shell 和外部进程产生的变更。watcher 失败会使当前观测不完整,但不会在直接加载时隐藏可读候选项;项目作用域 watcher 使用按配置设限的 LRU。 diff --git a/docs/core-data-structures/user-interaction.i18n.yaml b/docs/core-data-structures/user-interaction.i18n.yaml index 50087c9f56..5480052231 100644 --- a/docs/core-data-structures/user-interaction.i18n.yaml +++ b/docs/core-data-structures/user-interaction.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/user-interaction.md -user-interaction.md: dd9dc4f98e6517e016d224e6bed25a511c2a4256 -user-interaction.zh.md: 669fbe04c76039e637a162e4de817dc5704fedeb +user-interaction.md: 73727cbb754f10633d3213f67f7c3c9fdb215500 +user-interaction.zh.md: b6e95af4026e58c63c713d87ee9a86b3712b311f diff --git a/docs/core-data-structures/user-interaction.md b/docs/core-data-structures/user-interaction.md index dd9dc4f98e..73727cbb75 100644 --- a/docs/core-data-structures/user-interaction.md +++ b/docs/core-data-structures/user-interaction.md @@ -70,14 +70,14 @@ interface AskUserQuestionItem { ## Ask request -`AskUserQuestionRequest` is the cross-package request. `questions` is an array so a UI can present related prompts in one flow while preserving a stable id per answer. +`AskUserQuestionRequest` is the cross-package request. `questions` is an array so a UI can present related prompts in one flow while preserving a stable id per answer. When present, `agent` is the exact live caller; the interaction seam admits it only while the live registry identifies that instance as a runtime root. ```ts type-equiv /** Request for a human answer. */ interface AskUserQuestionRequest { /** Questions to display. */ questions: AskUserQuestionItem[] - /** Calling agent, when the request came from an agent tool call. */ + /** Exact live calling agent, when the request came from an agent tool call. */ agent?: Agent /** Abort signal for the owning tool/step. */ signal?: AbortSignal diff --git a/docs/core-data-structures/user-interaction.zh.md b/docs/core-data-structures/user-interaction.zh.md index 669fbe04c7..b6e95af402 100644 --- a/docs/core-data-structures/user-interaction.zh.md +++ b/docs/core-data-structures/user-interaction.zh.md @@ -70,14 +70,14 @@ interface AskUserQuestionItem { ## 提问请求 -`AskUserQuestionRequest` 是跨包(package)的请求。`questions` 是数组,这样 UI 可以在一个流程中呈现相关提示,同时保持每个回答有稳定的 id。 +`AskUserQuestionRequest` 是跨包(package)的请求。`questions` 是数组,这样 UI 可以在一个流程中呈现相关提示,同时保持每个回答有稳定的 id。如提供 `agent`,它必须与存活调用方是同一实例;只有当当前注册表将该实例识别为运行时根时,交互 seam 才会接纳该 agent。 ```ts type-equiv /** Request for a human answer. */ interface AskUserQuestionRequest { /** Questions to display. */ questions: AskUserQuestionItem[] - /** Calling agent, when the request came from an agent tool call. */ + /** Exact live calling agent, when the request came from an agent tool call. */ agent?: Agent /** Abort signal for the owning tool/step. */ signal?: AbortSignal diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index b66552b175..676c78bb90 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/development.md -development.md: b7ecab3536d739c105f11a640a07ea83a22f4398 -development.zh.md: 33ceba9f05c45c06acae7c83425a30c5e26ca433 +development.md: a9edd80abfbd53c9fc0495b4b495fa495207ca95 +development.zh.md: 405942c26ef3600209bc14a300c94a98d4f2b9f3 diff --git a/docs/development.md b/docs/development.md index b7ecab3536..a9edd80abf 100644 --- a/docs/development.md +++ b/docs/development.md @@ -43,24 +43,39 @@ Setup is complete when `pnpm run typecheck` exits successfully. ### TypeScript project layout -The repository typecheck runs the whole-repo `tsc -b` graph: it emits every package/vendor `lib/types` and checks examples, tests, and scripts through two no-emit aggregates. - -The repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them. +The repository uses isolated Host and Client aggregates. An ordinary package is registered in exactly one aggregate: Host packages in `tsconfig.host.json` and Client packages in `tsconfig.client.json`. | File | Role | Forms a program? | |---|---|---| -| `tsconfig.json` | Solution root: `extends` base, `files: []`, references to the two aggregates. The whole-repo `tsc -b tsconfig.json` graph, the tsserver discovery entry, and — through the inherited `paths` — the resolution config for tsx running `examples/` and `scripts/` (their nearest tsconfig is this file). | No | -| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes | -| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes | +| `tsconfig.json` | Solution root: `extends` base, `files: []`, and references to the two aggregates. It is the tsserver discovery entry and the entry for explicitly running the complete Project Reference graph; through the inherited `paths`, it is also the resolution config for tsx running `examples/` and `scripts/`. | No | +| `tsconfig.host.json` | Host aggregate: Host packages, examples, tests, scripts, website, and the exceptional Host project of `api/remotes`. | Yes | +| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`, and the exceptional Client project of `api/remotes`. | Yes | | `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No | -| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No | +| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the Client aggregate and every `packages/client/*` package. | No | -Host and client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Two disciplines follow: +Host and Client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Three disciplines follow: - `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope. -- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. Program-backed generators and gates (`scripts/ts-project.ts` consumers, doc-typecheck standalone mode) are host-only by decision; the client side gains program-backed tooling only with a concrete need. +- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. +- A new package is registered in exactly one aggregate. Having both a Node loader entry and a browser entry is not a reason to split a package; an ordinary Client plugin produces both runtime artifacts during the Client build phase. -Static analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md). +`api/remotes` is the repository's only package with split Host and Client tsconfigs. Its Host entry must participate in the Host TypeRT graph, while its Client entry imports `/remote` declarations that Host tsdown must generate first. The package-root `tsconfig.json` is therefore only a solution, and the two aggregates and direct consumers reference `tsconfig.host.json` or `tsconfig.client.json` respectively. The workspace `constraints` gate walks the reachable Project Reference graph and checks each referencing project's own compiler face: a single-config target remains valid from either face, while a split target must name the matching leaf rather than its solution root or opposite leaf. Do not copy this structure to other packages; see the [`api-remotes` README](../packages/api/remotes/README.md) for the complete boundary. + +The root build follows the generated dependency order: + +```sh +tsc -b tsconfig.host.json +tsdown --env.DSH_BUILD_FACE host +tsc -b tsconfig.client.json +tsdown --env.DSH_BUILD_FACE client +pnpm run build:web +``` + +Both tsdown passes use the same complete workspace match. They neither scan build artifacts to discover Client packages nor maintain a Host/Client package filter list. Package-local tsdown configs select entries for the current phase through `DSH_BUILD_FACE`: an ordinary Client plugin produces both its Node loader and browser bundle during the Client phase; `api-remotes` uses `hostPhase: true` to produce its Host entry early and only its browser bundle during the Client phase. Tsdown consumes only the JavaScript emitted to `lib/types` by the preceding tsc phase. + +TypeRT runs only during Host tsdown, seeded by `tsconfig.host.json`. It analyzes Host types and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start TypeRT. Consequently, `pnpm run typecheck` runs the complete Host lib phase before Client tsc, while `pnpm run build` continues through Client tsdown and the Web build. The [API Remotes generated-contract build note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md) records this ordering decision. + +Static analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Generated Host-for-Client Remote declarations are the deliberate exception: the public `typecheck`, `lint`, and `doc-typecheck` commands generate them first, while internal `*:contracts-ready` scripts assume that an invoking public command or scheduler gate already owns an explicit dependency on the TypeRT contract pass or the complete build. See the [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md) for the two-aggregate topology, the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md) for tsc-first emit ownership, and the [TypeRT Remote note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) for the gate-preparation contract. Business services declare callable methods on the Host with `@Remote` or `@RemoteScope`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions under `ctx.remote` and scoped `agentCtx.remote` namespaces. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order. @@ -87,8 +102,8 @@ DEEPSEEK_BASE_URL=https://... # optional lefthook is configured in `lefthook.yml` as a fast local checkpoint: -- `pre-commit` applies formatting-only ESLint fixes, validates the staged files with Oxlint and applies its native fixes, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard. -- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates). +- `pre-commit` applies formatting-only ESLint fixes, validates the staged files with the project-free `.oxlintrc.staged.json` profile and applies Oxlint's native fixes, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard. +- `pre-push` runs `pnpm run typecheck`, which completes the Host lib phase, including generated TypeRT contracts, before the Client TypeScript check. The vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code. diff --git a/docs/development.zh.md b/docs/development.zh.md index 33ceba9f05..405942c26e 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -43,24 +43,39 @@ pnpm run typecheck ### TypeScript 项目布局 -仓库类型检查会执行全仓 `tsc -b` 图:它会发射每个 package/vendor 的 `lib/types`,并通过两个 no-emit 聚合检查示例、测试和脚本。 - -仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。 +仓库使用相互隔离的 Host 与 Client aggregate。普通 package 只登记进其中一个 aggregate;Host 包进入 `tsconfig.host.json`,Client 包进入 `tsconfig.client.json`。 | 文件 | 角色 | 是否构成 program? | |---|---|---| -| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 | -| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 | -| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 | +| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个 aggregate。它是 tsserver 发现入口,也是显式执行整张 Project Reference 图时的入口;经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置。 | 否 | +| `tsconfig.host.json` | Host aggregate:Host package、示例、测试、脚本和 website,以及 `api/remotes` 的 Host 特例 project。 | 是 | +| `tsconfig.client.json` | Client aggregate:`packages/client/*` package 及其测试、`apps/web`,以及 `api/remotes` 的 Client 特例 project。 | 是 | | `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 | -| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 | +| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 Client aggregate 和每个 `packages/client/*` package extends。 | 否 | -host 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律: +Host 与 Client 保持两个 aggregate program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个 aggregate,一个 paths 门面也可以横跨两侧。由此推出三条纪律: - `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。 -- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧;client 侧只在出现真实需求时再获得基于 program 的工具。 +- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个 aggregate 展平进一个 program 会撞上 `Context` 合并冲突。 +- 新 package 只登记进一个 aggregate。包同时具有 Node loader 入口和 browser 入口并不构成拆分理由;普通 Client plugin 的两份运行时产物都在 Client 构建阶段生成。 -静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。 +`api/remotes` 是唯一拆分 Host/Client tsconfig 的仓库特例。它的 Host 入口必须进入 Host TypeRT 图,而 Client 入口导入 Host tsdown 才会生成的 `/remote` 声明,因此本包根 `tsconfig.json` 只作为 solution,两个 aggregate 和直接消费方分别引用 `tsconfig.host.json` 或 `tsconfig.client.json`。workspace `constraints` 门禁遍历可达的 Project Reference 图,并按各引用 project 自身的 compiler face 检查:只有单一配置的目标可由任一 face 引用,拆分配置的目标则必须引用匹配的 leaf,不得引用 solution 根或另一侧 leaf。不要把该结构推广到其他包;完整边界见 [`api-remotes` README](../packages/api/remotes/README.md)。 + +根构建按生成依赖排序: + +```sh +tsc -b tsconfig.host.json +tsdown --env.DSH_BUILD_FACE host +tsc -b tsconfig.client.json +tsdown --env.DSH_BUILD_FACE client +pnpm run build:web +``` + +两次 tsdown 都使用同一组完整 workspace 匹配,不扫描构建产物来发现 Client package,也不维护 Host/Client package 过滤表。包内 tsdown 配置根据 `DSH_BUILD_FACE` 决定当前阶段的入口:普通 Client plugin 在 Client 阶段同时生成 Node loader 与 browser bundle;`api-remotes` 通过 `hostPhase: true` 提前生成 Host 入口,再在 Client 阶段只生成 browser bundle。tsdown 只消费 `lib/types` 中由前置 tsc 发射的 JavaScript。 + +TypeRT 只在 Host tsdown 中以 `tsconfig.host.json` 为种子运行。它分析 Host 类型并生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 TypeRT。`pnpm run typecheck` 因此先执行完整 Host lib 阶段,再运行 Client tsc;`pnpm run build` 继续执行 Client tsdown 和 Web 构建。该顺序的决策记录见 [API Remotes 生成契约构建 Note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md)。 + +静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。生成的 Host-for-Client Remote 声明是有意设置的例外:公共 `typecheck`、`lint` 和 `doc-typecheck` 命令会先生成这些声明,而内部 `*:contracts-ready` 脚本以调用它的公共命令或调度器门禁已经显式依赖 TypeRT 契约 pass 或完整构建为前提。双 aggregate 拓扑见 [solution-root Note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md),tsc-first 发射职责见 [ts-build-config Note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md),门禁准备契约见 [TypeRT Remote Agent Note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md)。 业务 Service 在 Host 使用 `@Remote` 或 `@RemoteScope` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并挂到 `ctx.remote` 与作用域 `agentCtx.remote` namespace。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。 @@ -87,8 +102,8 @@ DEEPSEEK_BASE_URL=https://... # optional lefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点: -- `pre-commit` 应用仅用于格式化的 ESLint 修复,使用 Oxlint 验证暂存文件并应用其原生修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫; -- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。 +- `pre-commit` 应用仅用于格式化的 ESLint 修复,使用不加载项目的 `.oxlintrc.staged.json` 配置验证暂存文件并应用 Oxlint 的原生修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫; +- `pre-push` 运行 `pnpm run typecheck`;该命令会先完成包含 TypeRT 契约生成的完整 Host lib 阶段,再运行 Client TypeScript 检查。 vendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 39297afd46..cf5a2bd64b 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -21,7 +21,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/status` | `emit` | [`packages/core/agent/src/types.ts:173`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`headless`](../packages/bundle/headless), [`jsonrpc`](../packages/ui/jsonrpc) | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:273`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | -| `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:161`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy` | +| `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:172`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy` | | `credentials/updated` | `emit` | [`packages/credentials/credentials/src/index.ts:67`](../packages/credentials/credentials/src/index.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../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-policy`](../packages/fs/fs-policy) | @@ -32,11 +32,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:62`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:96`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:96`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`loader-smoke`](../packages/support/loader-smoke), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:105`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:170`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | | `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:157`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | -| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:188`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | +| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:283`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:160`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:134`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:140`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/docs/glossary.i18n.yaml b/docs/glossary.i18n.yaml index 5c6f7d4630..2dfe08c2fd 100644 --- a/docs/glossary.i18n.yaml +++ b/docs/glossary.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/glossary.md -glossary.md: 0270a2d0dba558483e8e458a932a27b0151f2c93 -glossary.zh.md: c3584731cc08b23cf7f47c09620a77b7bff65689 +glossary.md: 16409517bff623a80d6e1e00888d95f63b42f780 +glossary.zh.md: fe3138ac81f2681d7fc69cc2aede96edeb7ec176 diff --git a/docs/glossary.md b/docs/glossary.md index 0270a2d0db..16409517bf 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -4,8 +4,6 @@ English | [中文](glossary.zh.md) Domain vocabulary for the DeepSeek Harness SDK uses one canonical term per concept. Terms link to their entries with standard Markdown anchors; implementation detail stays in package READMEs and Agent Notes. -FIXME(glossary-completeness): Expand this glossary before the first release so it covers the SDK's other core and capability subsystems, not only agent scope. - ## agent-scope - **scope** — the unit of per-agent registration: a contribution (tool, prompt section, variable, restriction, listener) is either *global* (visible to every agent) or *scoped* (owned by exactly one [scope key](#scope-key)). Two levels, flat: scoped registrations do not inherit down to subagents; subtree behavior is expressed with [lineage](#lineage) data, never scope structure. diff --git a/docs/glossary.zh.md b/docs/glossary.zh.md index c3584731cc..fe3138ac81 100644 --- a/docs/glossary.zh.md +++ b/docs/glossary.zh.md @@ -4,8 +4,6 @@ DeepSeek Harness SDK 的领域词汇为每个概念规定一个规范术语。各术语通过标准 Markdown 锚点链接到相应条目;实现细节留在各包的 README 与 Agent Note 中。 -FIXME(glossary-completeness): 首次发布前扩充本术语表,使其覆盖 SDK 的其他核心与能力子系统,而非仅限于 agent scope。 - ## agent-scope - **scope**:按 agent(智能体)划分的注册单位。一项贡献(工具、提示词片段、变量、限制、监听器)要么是*全局的*(对所有 agent 可见),要么是*带作用域的*(归属于恰好一个 [scope key](#scope-key))。只有两层,采用扁平结构:带作用域的注册不会向下继承给 subagent;子树行为通过 [lineage](#lineage) 数据表达,从不通过 scope 结构。 diff --git a/docs/module-graph.md b/docs/module-graph.md index 9b1774145a..a2119828cb 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -10,6 +10,7 @@ flowchart TD subgraph group_util["packages/util"] pkg_atomic_write["atomic-write"] pkg_brand["brand"] + pkg_environment["environment"] pkg_native_command["native-command"] pkg_paths["paths"] pkg_retention["retention"] @@ -56,6 +57,7 @@ flowchart TD end subgraph group_skill["packages/skill"] pkg_skill["skill"] + pkg_skill_badge["skill-badge"] pkg_skill_local["skill-local"] pkg_tool_skill["tool-skill"] end @@ -204,7 +206,6 @@ flowchart TD subgraph group_examples["packages/examples"] pkg_acp_demo["acp-demo"] pkg_agent_spine_demo["agent-spine-demo"] - pkg_cli_demo["cli-demo"] pkg_jsonrpc_demo["jsonrpc-demo"] end subgraph group_feedback["packages/feedback"] @@ -292,15 +293,13 @@ flowchart TD end pkg_atomic_write --> pkg_invariants pkg_brand --> pkg_invariants + pkg_environment --> pkg_invariants pkg_native_command --> pkg_invariants pkg_paths --> pkg_invariants pkg_retention --> pkg_invariants pkg_timeout --> pkg_invariants pkg_scope --> pkg_invariants - pkg_skill --> pkg_invariants - pkg_acp_snapshot --> pkg_invariants pkg_llm_mock_server --> pkg_invariants - pkg_loader_smoke --> pkg_invariants pkg_base --> pkg_invariants pkg_client_modules --> pkg_invariants pkg_client_schema_form --> pkg_invariants @@ -326,6 +325,9 @@ flowchart TD pkg_client_hmr --> pkg_client_modules pkg_client_hmr --> pkg_host_webserver pkg_client_hmr --> pkg_invariants + pkg_client_runtime --> pkg_invariants + pkg_client_runtime --> pkg_type_meta + pkg_client_runtime --> pkg_typert_registry pkg_credentials --> pkg_brand pkg_credentials --> pkg_invariants pkg_frontend_static --> pkg_host_webserver @@ -350,11 +352,13 @@ flowchart TD pkg_typert_loader --> pkg_invariants pkg_typert_loader --> pkg_typert_registry pkg_llm_deepseek --> pkg_credentials + pkg_llm_deepseek --> pkg_environment pkg_llm_deepseek --> pkg_invariants pkg_llm_deepseek --> pkg_llm pkg_llm_deepseek --> pkg_settings pkg_llm_deepseek --> pkg_timeout pkg_llm_pi_ai --> pkg_credentials + pkg_llm_pi_ai --> pkg_environment pkg_llm_pi_ai --> pkg_invariants pkg_llm_pi_ai --> pkg_llm pkg_llm_pi_ai --> pkg_settings @@ -367,13 +371,40 @@ flowchart TD pkg_system_prompt --> pkg_invariants pkg_system_prompt --> pkg_llm pkg_system_prompt --> pkg_scope + pkg_skill --> pkg_invariants + pkg_skill --> pkg_llm pkg_web --> pkg_invariants pkg_web --> pkg_llm pkg_api_gateway --> pkg_client_connection pkg_api_gateway --> pkg_invariants pkg_api_gateway --> pkg_typert_registry + pkg_client_locale --> pkg_client_connection + pkg_client_locale --> pkg_client_runtime + pkg_client_locale --> pkg_client_ui_primitives + pkg_client_locale --> pkg_client_ui_slots + pkg_client_locale --> pkg_invariants + pkg_client_test_runtime --> pkg_client_runtime + pkg_client_test_runtime --> pkg_client_ui_slots + pkg_client_test_runtime --> pkg_client_web_react + pkg_client_test_runtime --> pkg_host_apiproxy + pkg_client_test_runtime --> pkg_invariants + pkg_client_ui_models --> pkg_client_connection + pkg_client_ui_models --> pkg_client_runtime + pkg_client_ui_models --> pkg_client_schema_form + pkg_client_ui_models --> pkg_client_ui_primitives + pkg_client_ui_models --> pkg_client_ui_slots + pkg_client_ui_models --> pkg_client_web_react + pkg_client_ui_models --> pkg_invariants + pkg_client_ui_settings --> pkg_client_runtime + pkg_client_ui_settings --> pkg_client_ui_primitives + pkg_client_ui_settings --> pkg_client_ui_slots + pkg_client_ui_settings --> pkg_invariants + pkg_client_ui_trajectory --> pkg_client_runtime + pkg_client_ui_trajectory --> pkg_client_ui_primitives + pkg_client_ui_trajectory --> pkg_invariants pkg_credentials_local --> pkg_atomic_write pkg_credentials_local --> pkg_credentials + pkg_credentials_local --> pkg_environment pkg_credentials_local --> pkg_invariants pkg_credentials_local --> pkg_paths pkg_lsp --> pkg_brand @@ -398,14 +429,18 @@ flowchart TD pkg_fs --> pkg_invariants pkg_fs --> pkg_llm pkg_fs --> pkg_sandbox + pkg_skill_badge --> pkg_invariants + pkg_skill_badge --> pkg_skill pkg_compact --> pkg_invariants pkg_compact --> pkg_llm pkg_compact --> pkg_session pkg_web_fetch_local --> pkg_invariants pkg_web_fetch_local --> pkg_timeout pkg_web_fetch_local --> pkg_web + pkg_web_search_exa --> pkg_environment pkg_web_search_exa --> pkg_invariants pkg_web_search_exa --> pkg_web + pkg_web_search_perplexity --> pkg_environment pkg_web_search_perplexity --> pkg_invariants pkg_web_search_perplexity --> pkg_web pkg_spill --> pkg_brand @@ -415,12 +450,44 @@ flowchart TD pkg_session_persistence --> pkg_brand pkg_session_persistence --> pkg_invariants pkg_session_persistence --> pkg_session - pkg_llm_replay --> pkg_invariants - pkg_llm_replay --> pkg_llm - pkg_llm_replay --> pkg_session + pkg_session_persistence --> pkg_timeout + pkg_acp_snapshot --> pkg_invariants + pkg_acp_snapshot --> pkg_session + pkg_app_boot --> pkg_environment pkg_app_boot --> pkg_invariants pkg_app_boot --> pkg_paths pkg_app_boot --> pkg_system_prompt + pkg_client_ui_question --> pkg_client_locale + pkg_client_ui_question --> pkg_invariants + pkg_client_ui_settings_general --> pkg_client_connection + pkg_client_ui_settings_general --> pkg_client_locale + pkg_client_ui_settings_general --> pkg_client_runtime + pkg_client_ui_settings_general --> pkg_client_ui_primitives + pkg_client_ui_settings_general --> pkg_client_ui_settings + pkg_client_ui_settings_general --> pkg_client_ui_slots + pkg_client_ui_settings_general --> pkg_client_web_react + pkg_client_ui_settings_general --> pkg_invariants + pkg_client_ui_sidebar --> pkg_client_locale + pkg_client_ui_sidebar --> pkg_client_runtime + pkg_client_ui_sidebar --> pkg_client_ui_primitives + pkg_client_ui_sidebar --> pkg_client_ui_slots + pkg_client_ui_sidebar --> pkg_invariants + pkg_client_ui_slash --> pkg_client_locale + pkg_client_ui_slash --> pkg_client_runtime + pkg_client_ui_slash --> pkg_client_ui_primitives + pkg_client_ui_slash --> pkg_client_ui_slots + pkg_client_ui_slash --> pkg_invariants + pkg_client_ui_theme --> pkg_client_connection + pkg_client_ui_theme --> pkg_client_locale + pkg_client_ui_theme --> pkg_client_runtime + pkg_client_ui_theme --> pkg_client_ui_primitives + pkg_client_ui_theme --> pkg_client_ui_slots + pkg_client_ui_theme --> pkg_invariants + pkg_client_ui_workspace --> pkg_client_locale + pkg_client_ui_workspace --> pkg_client_runtime + pkg_client_ui_workspace --> pkg_client_ui_primitives + pkg_client_ui_workspace --> pkg_client_ui_slots + pkg_client_ui_workspace --> pkg_invariants pkg_code_runtime_worker --> pkg_code_runtime pkg_code_runtime_worker --> pkg_invariants pkg_code_runtime_worker --> pkg_session @@ -472,6 +539,7 @@ flowchart TD pkg_skill_local --> pkg_skill pkg_web_search_deepseek --> pkg_agent pkg_web_search_deepseek --> pkg_credentials + pkg_web_search_deepseek --> pkg_environment pkg_web_search_deepseek --> pkg_invariants pkg_web_search_deepseek --> pkg_session pkg_web_search_deepseek --> pkg_web @@ -491,6 +559,14 @@ flowchart TD pkg_session_title --> pkg_llm pkg_session_title --> pkg_session pkg_session_title --> pkg_session_projection + pkg_llm_replay --> pkg_compact + pkg_llm_replay --> pkg_invariants + pkg_llm_replay --> pkg_llm + pkg_llm_replay --> pkg_session + pkg_loader_smoke --> pkg_agent + pkg_loader_smoke --> pkg_invariants + pkg_loader_smoke --> pkg_llm + pkg_loader_smoke --> pkg_session pkg_commands --> pkg_agent pkg_commands --> pkg_brand pkg_commands --> pkg_invariants @@ -511,6 +587,10 @@ flowchart TD pkg_headless --> pkg_host_webserver pkg_headless --> pkg_invariants pkg_headless --> pkg_session + pkg_client_ui_layout --> pkg_client_runtime + pkg_client_ui_layout --> pkg_client_ui_slots + pkg_client_ui_layout --> pkg_client_ui_theme + pkg_client_ui_layout --> pkg_invariants pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session @@ -518,6 +598,16 @@ flowchart TD pkg_tmux_context --> pkg_bash pkg_tmux_context --> pkg_invariants pkg_tmux_context --> pkg_session + pkg_host_directory_picker_browse --> pkg_client_locale + pkg_host_directory_picker_browse --> pkg_client_runtime + pkg_host_directory_picker_browse --> pkg_client_ui_primitives + pkg_host_directory_picker_browse --> pkg_client_ui_slots + pkg_host_directory_picker_browse --> pkg_client_ui_workspace + pkg_host_directory_picker_browse --> pkg_invariants + pkg_host_directory_picker_native --> pkg_client_runtime + pkg_host_directory_picker_native --> pkg_client_ui_slots + pkg_host_directory_picker_native --> pkg_client_ui_workspace + pkg_host_directory_picker_native --> pkg_invariants pkg_pty --> pkg_agent pkg_pty --> pkg_brand pkg_pty --> pkg_invariants @@ -615,9 +705,21 @@ flowchart TD pkg_api_remotes --> pkg_session pkg_api_remotes --> pkg_session_persistence pkg_api_remotes --> pkg_typert_registry + pkg_client_ui_conversation --> pkg_client_connection + pkg_client_ui_conversation --> pkg_client_locale + pkg_client_ui_conversation --> pkg_client_runtime + pkg_client_ui_conversation --> pkg_client_ui_primitives + pkg_client_ui_conversation --> pkg_client_ui_slash + pkg_client_ui_conversation --> pkg_client_ui_slots + pkg_client_ui_conversation --> pkg_invariants + pkg_client_ui_conversation --> pkg_token_meter pkg_command_feedback --> pkg_commands pkg_command_feedback --> pkg_invariants pkg_command_feedback --> pkg_session + pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse + pkg_host_directory_picker_auto --> pkg_host_directory_picker_native + pkg_host_directory_picker_auto --> pkg_host_webserver + pkg_host_directory_picker_auto --> pkg_invariants pkg_pty_local --> pkg_agent pkg_pty_local --> pkg_invariants pkg_pty_local --> pkg_pty @@ -771,10 +873,35 @@ flowchart TD pkg_tool_ask_user --> pkg_invariants pkg_tool_ask_user --> pkg_tools pkg_tool_ask_user --> pkg_user_interaction - pkg_client_runtime --> pkg_api_remotes - pkg_client_runtime --> pkg_invariants - pkg_client_runtime --> pkg_type_meta - pkg_client_runtime --> pkg_typert_registry + pkg_client_ui_command --> pkg_client_connection + pkg_client_ui_command --> pkg_client_locale + pkg_client_ui_command --> pkg_client_runtime + pkg_client_ui_command --> pkg_client_ui_conversation + pkg_client_ui_command --> pkg_client_ui_primitives + pkg_client_ui_command --> pkg_client_ui_slash + pkg_client_ui_command --> pkg_client_ui_slots + pkg_client_ui_command --> pkg_invariants + pkg_client_ui_deliverables --> pkg_client_locale + pkg_client_ui_deliverables --> pkg_client_runtime + pkg_client_ui_deliverables --> pkg_client_ui_conversation + pkg_client_ui_deliverables --> pkg_client_ui_slots + pkg_client_ui_deliverables --> pkg_invariants + pkg_client_ui_goal --> pkg_api_remotes + pkg_client_ui_goal --> pkg_client_locale + pkg_client_ui_goal --> pkg_client_runtime + pkg_client_ui_goal --> pkg_client_ui_conversation + pkg_client_ui_goal --> pkg_client_ui_primitives + pkg_client_ui_goal --> pkg_client_ui_slots + pkg_client_ui_goal --> pkg_goal + pkg_client_ui_goal --> pkg_invariants + pkg_client_ui_skill --> pkg_client_connection + pkg_client_ui_skill --> pkg_client_locale + pkg_client_ui_skill --> pkg_client_runtime + pkg_client_ui_skill --> pkg_client_ui_conversation + pkg_client_ui_skill --> pkg_client_ui_primitives + pkg_client_ui_skill --> pkg_client_ui_slash + pkg_client_ui_skill --> pkg_client_ui_slots + pkg_client_ui_skill --> pkg_invariants pkg_session_reference --> pkg_agent pkg_session_reference --> pkg_compact pkg_session_reference --> pkg_invariants @@ -906,30 +1033,42 @@ flowchart TD pkg_web_app --> pkg_bash_env pkg_web_app --> pkg_invariants pkg_web_app --> pkg_system_prompt - pkg_client_locale --> pkg_client_connection - pkg_client_locale --> pkg_client_runtime - pkg_client_locale --> pkg_client_ui_primitives - pkg_client_locale --> pkg_client_ui_slots - pkg_client_locale --> pkg_invariants - pkg_client_test_runtime --> pkg_client_runtime - pkg_client_test_runtime --> pkg_client_ui_slots - pkg_client_test_runtime --> pkg_client_web_react - pkg_client_test_runtime --> pkg_host_apiproxy - pkg_client_test_runtime --> pkg_invariants - pkg_client_ui_models --> pkg_client_connection - pkg_client_ui_models --> pkg_client_runtime - pkg_client_ui_models --> pkg_client_schema_form - pkg_client_ui_models --> pkg_client_ui_primitives - pkg_client_ui_models --> pkg_client_ui_slots - pkg_client_ui_models --> pkg_client_web_react - pkg_client_ui_models --> pkg_invariants - pkg_client_ui_settings --> pkg_client_runtime - pkg_client_ui_settings --> pkg_client_ui_primitives - pkg_client_ui_settings --> pkg_client_ui_slots - pkg_client_ui_settings --> pkg_invariants - pkg_client_ui_trajectory --> pkg_client_runtime - pkg_client_ui_trajectory --> pkg_client_ui_primitives - pkg_client_ui_trajectory --> pkg_invariants + pkg_client_ui_model --> pkg_client_connection + pkg_client_ui_model --> pkg_client_locale + pkg_client_ui_model --> pkg_client_runtime + pkg_client_ui_model --> pkg_client_ui_command + pkg_client_ui_model --> pkg_client_ui_conversation + pkg_client_ui_model --> pkg_client_ui_primitives + pkg_client_ui_model --> pkg_client_ui_slash + pkg_client_ui_model --> pkg_client_ui_slots + pkg_client_ui_model --> pkg_invariants + pkg_client_ui_permission --> pkg_client_connection + pkg_client_ui_permission --> pkg_client_locale + pkg_client_ui_permission --> pkg_client_runtime + pkg_client_ui_permission --> pkg_client_schema_form + pkg_client_ui_permission --> pkg_client_ui_command + pkg_client_ui_permission --> pkg_client_ui_primitives + pkg_client_ui_permission --> pkg_client_ui_slash + pkg_client_ui_permission --> pkg_client_ui_slots + pkg_client_ui_permission --> pkg_invariants + pkg_client_ui_permission --> pkg_permission + pkg_client_ui_plan --> pkg_client_connection + pkg_client_ui_plan --> pkg_client_locale + pkg_client_ui_plan --> pkg_client_runtime + pkg_client_ui_plan --> pkg_client_ui_conversation + pkg_client_ui_plan --> pkg_client_ui_primitives + pkg_client_ui_plan --> pkg_client_ui_slots + pkg_client_ui_plan --> pkg_invariants + pkg_client_ui_plan --> pkg_plan_mode + pkg_client_ui_subagent --> pkg_client_locale + pkg_client_ui_subagent --> pkg_client_runtime + pkg_client_ui_subagent --> pkg_client_ui_conversation + pkg_client_ui_subagent --> pkg_client_ui_primitives + pkg_client_ui_subagent --> pkg_client_ui_slash + pkg_client_ui_subagent --> pkg_client_ui_slots + pkg_client_ui_subagent --> pkg_invariants + pkg_client_ui_subagent --> pkg_subagent + pkg_client_ui_subagent --> pkg_token_meter pkg_sdk_protocol --> pkg_invariants pkg_sdk_protocol --> pkg_llm pkg_sdk_protocol --> pkg_session @@ -972,37 +1111,6 @@ flowchart TD pkg_jsonrpc --> pkg_sdk_protocol pkg_jsonrpc --> pkg_session pkg_jsonrpc --> pkg_subagent - pkg_client_ui_question --> pkg_client_locale - pkg_client_ui_question --> pkg_invariants - pkg_client_ui_settings_general --> pkg_client_connection - pkg_client_ui_settings_general --> pkg_client_locale - pkg_client_ui_settings_general --> pkg_client_runtime - pkg_client_ui_settings_general --> pkg_client_ui_primitives - pkg_client_ui_settings_general --> pkg_client_ui_settings - pkg_client_ui_settings_general --> pkg_client_ui_slots - pkg_client_ui_settings_general --> pkg_client_web_react - pkg_client_ui_settings_general --> pkg_invariants - pkg_client_ui_sidebar --> pkg_client_locale - pkg_client_ui_sidebar --> pkg_client_runtime - pkg_client_ui_sidebar --> pkg_client_ui_primitives - pkg_client_ui_sidebar --> pkg_client_ui_slots - pkg_client_ui_sidebar --> pkg_invariants - pkg_client_ui_slash --> pkg_client_locale - pkg_client_ui_slash --> pkg_client_runtime - pkg_client_ui_slash --> pkg_client_ui_primitives - pkg_client_ui_slash --> pkg_client_ui_slots - pkg_client_ui_slash --> pkg_invariants - pkg_client_ui_theme --> pkg_client_connection - pkg_client_ui_theme --> pkg_client_locale - pkg_client_ui_theme --> pkg_client_runtime - pkg_client_ui_theme --> pkg_client_ui_primitives - pkg_client_ui_theme --> pkg_client_ui_slots - pkg_client_ui_theme --> pkg_invariants - pkg_client_ui_workspace --> pkg_client_locale - pkg_client_ui_workspace --> pkg_client_runtime - pkg_client_ui_workspace --> pkg_client_ui_primitives - pkg_client_ui_workspace --> pkg_client_ui_slots - pkg_client_ui_workspace --> pkg_invariants pkg_agent_spine_demo --> pkg_agent pkg_agent_spine_demo --> pkg_agent_loop pkg_agent_spine_demo --> pkg_bash_env @@ -1036,18 +1144,6 @@ flowchart TD pkg_subagent_dsh_sdk --> pkg_session pkg_subagent_dsh_sdk --> pkg_subagent pkg_subagent_dsh_sdk --> pkg_subprocess - pkg_client_ui_conversation --> pkg_client_connection - pkg_client_ui_conversation --> pkg_client_locale - pkg_client_ui_conversation --> pkg_client_runtime - pkg_client_ui_conversation --> pkg_client_ui_primitives - pkg_client_ui_conversation --> pkg_client_ui_slash - pkg_client_ui_conversation --> pkg_client_ui_slots - pkg_client_ui_conversation --> pkg_invariants - pkg_client_ui_conversation --> pkg_token_meter - pkg_client_ui_layout --> pkg_client_runtime - pkg_client_ui_layout --> pkg_client_ui_slots - pkg_client_ui_layout --> pkg_client_ui_theme - pkg_client_ui_layout --> pkg_invariants pkg_acp_demo --> pkg_acp pkg_acp_demo --> pkg_agent_spine_demo pkg_acp_demo --> pkg_app_boot @@ -1058,95 +1154,6 @@ flowchart TD pkg_acp_demo --> pkg_session_query_sqlite pkg_acp_demo --> pkg_tools pkg_acp_demo --> pkg_workspace_context - pkg_cli_demo --> pkg_agent - pkg_cli_demo --> pkg_agent_spine_demo - pkg_cli_demo --> pkg_app_boot - pkg_cli_demo --> pkg_invariants - pkg_cli_demo --> pkg_llm - pkg_cli_demo --> pkg_session - pkg_cli_demo --> pkg_session_checkpoint_policy - pkg_cli_demo --> pkg_session_persistence_jsonl - pkg_cli_demo --> pkg_tools - pkg_cli_demo --> pkg_workspace_context - pkg_host_directory_picker_browse --> pkg_client_locale - pkg_host_directory_picker_browse --> pkg_client_runtime - pkg_host_directory_picker_browse --> pkg_client_ui_primitives - pkg_host_directory_picker_browse --> pkg_client_ui_slots - pkg_host_directory_picker_browse --> pkg_client_ui_workspace - pkg_host_directory_picker_browse --> pkg_invariants - pkg_host_directory_picker_native --> pkg_client_runtime - pkg_host_directory_picker_native --> pkg_client_ui_slots - pkg_host_directory_picker_native --> pkg_client_ui_workspace - pkg_host_directory_picker_native --> pkg_invariants - pkg_client_ui_command --> pkg_client_connection - pkg_client_ui_command --> pkg_client_locale - pkg_client_ui_command --> pkg_client_runtime - pkg_client_ui_command --> pkg_client_ui_conversation - pkg_client_ui_command --> pkg_client_ui_primitives - pkg_client_ui_command --> pkg_client_ui_slash - pkg_client_ui_command --> pkg_client_ui_slots - pkg_client_ui_command --> pkg_invariants - pkg_client_ui_deliverables --> pkg_client_locale - pkg_client_ui_deliverables --> pkg_client_runtime - pkg_client_ui_deliverables --> pkg_client_ui_conversation - pkg_client_ui_deliverables --> pkg_client_ui_slots - pkg_client_ui_deliverables --> pkg_invariants - pkg_client_ui_goal --> pkg_api_remotes - pkg_client_ui_goal --> pkg_client_locale - pkg_client_ui_goal --> pkg_client_runtime - pkg_client_ui_goal --> pkg_client_ui_conversation - pkg_client_ui_goal --> pkg_client_ui_primitives - pkg_client_ui_goal --> pkg_client_ui_slots - pkg_client_ui_goal --> pkg_goal - pkg_client_ui_goal --> pkg_invariants - pkg_client_ui_plan --> pkg_client_connection - pkg_client_ui_plan --> pkg_client_locale - pkg_client_ui_plan --> pkg_client_runtime - pkg_client_ui_plan --> pkg_client_ui_conversation - pkg_client_ui_plan --> pkg_client_ui_primitives - pkg_client_ui_plan --> pkg_client_ui_slots - pkg_client_ui_plan --> pkg_invariants - pkg_client_ui_plan --> pkg_plan_mode - pkg_client_ui_skill --> pkg_client_connection - pkg_client_ui_skill --> pkg_client_locale - pkg_client_ui_skill --> pkg_client_runtime - pkg_client_ui_skill --> pkg_client_ui_conversation - pkg_client_ui_skill --> pkg_client_ui_primitives - pkg_client_ui_skill --> pkg_client_ui_slash - pkg_client_ui_skill --> pkg_client_ui_slots - pkg_client_ui_skill --> pkg_invariants - pkg_client_ui_subagent --> pkg_client_locale - pkg_client_ui_subagent --> pkg_client_runtime - pkg_client_ui_subagent --> pkg_client_ui_conversation - pkg_client_ui_subagent --> pkg_client_ui_primitives - pkg_client_ui_subagent --> pkg_client_ui_slash - pkg_client_ui_subagent --> pkg_client_ui_slots - pkg_client_ui_subagent --> pkg_invariants - pkg_client_ui_subagent --> pkg_subagent - pkg_client_ui_subagent --> pkg_token_meter - pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse - pkg_host_directory_picker_auto --> pkg_host_directory_picker_native - pkg_host_directory_picker_auto --> pkg_host_webserver - pkg_host_directory_picker_auto --> pkg_invariants - pkg_client_ui_model --> pkg_client_connection - pkg_client_ui_model --> pkg_client_locale - pkg_client_ui_model --> pkg_client_runtime - pkg_client_ui_model --> pkg_client_ui_command - pkg_client_ui_model --> pkg_client_ui_conversation - pkg_client_ui_model --> pkg_client_ui_primitives - pkg_client_ui_model --> pkg_client_ui_slash - pkg_client_ui_model --> pkg_client_ui_slots - pkg_client_ui_model --> pkg_invariants - pkg_client_ui_permission --> pkg_client_connection - pkg_client_ui_permission --> pkg_client_locale - pkg_client_ui_permission --> pkg_client_runtime - pkg_client_ui_permission --> pkg_client_schema_form - pkg_client_ui_permission --> pkg_client_ui_command - pkg_client_ui_permission --> pkg_client_ui_primitives - pkg_client_ui_permission --> pkg_client_ui_slash - pkg_client_ui_permission --> pkg_client_ui_slots - pkg_client_ui_permission --> pkg_invariants - pkg_client_ui_permission --> pkg_permission ``` | Package | Group | Depends on | @@ -1154,15 +1161,13 @@ flowchart TD | [`invariants`](../packages/support/invariants) | `support` | — | | [`atomic-write`](../packages/util/atomic-write) | `util` | [`invariants`](../packages/support/invariants) | | [`brand`](../packages/util/brand) | `util` | [`invariants`](../packages/support/invariants) | +| [`environment`](../packages/util/environment) | `util` | [`invariants`](../packages/support/invariants) | | [`native-command`](../packages/util/native-command) | `util` | [`invariants`](../packages/support/invariants) | | [`paths`](../packages/util/paths) | `util` | [`invariants`](../packages/support/invariants) | | [`retention`](../packages/util/retention) | `util` | [`invariants`](../packages/support/invariants) | | [`timeout`](../packages/util/timeout) | `util` | [`invariants`](../packages/support/invariants) | | [`scope`](../packages/core/scope) | `core` | [`invariants`](../packages/support/invariants) | -| [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/support/invariants) | -| [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | [`invariants`](../packages/support/invariants) | | [`llm-mock-server`](../packages/support/llm-mock-server) | `support` | [`invariants`](../packages/support/invariants) | -| [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`invariants`](../packages/support/invariants) | | [`base`](../packages/bundle/base) | `bundle` | [`invariants`](../packages/support/invariants) | | [`client-modules`](../packages/client/modules) | `client` | [`invariants`](../packages/support/invariants) | | [`client-schema-form`](../packages/client/schema-form) | `client` | [`invariants`](../packages/support/invariants) | @@ -1183,6 +1188,7 @@ flowchart TD | [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) | | [`client-connection`](../packages/client/connection) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | +| [`client-runtime`](../packages/client/runtime) | `client` | [`invariants`](../packages/support/invariants), [`type-meta`](../packages/typert/type-meta), [`typert-registry`](../packages/typert/registry) | | [`credentials`](../packages/credentials/credentials) | `credentials` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`frontend-static`](../packages/host/frontend-static) | `host` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | @@ -1193,27 +1199,40 @@ flowchart TD | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`typert-loader`](../packages/typert/loader) | `typert` | [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) | -| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | -| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | +| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | +| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`type-meta`](../packages/typert/type-meta) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | +| [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection), [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) | -| [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | +| [`client-locale`](../packages/client/locale) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) | +| [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | +| [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) | +| [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`settings-local`](../packages/settings/settings-local) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) | | [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`type-meta`](../packages/typert/type-meta) | | [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | +| [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`invariants`](../packages/support/invariants), [`skill`](../packages/skill/skill) | | [`compact`](../packages/compact/compact) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`web-fetch-local`](../packages/web/web-fetch-local) | `web` | [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | -| [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) | -| [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) | +| [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) | +| [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) | | [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | -| [`llm-replay`](../packages/support/llm-replay) | `support` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`app-boot`](../packages/ui/app-boot) | `ui` | [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`system-prompt`](../packages/core/system-prompt) | +| [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | +| [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | +| [`app-boot`](../packages/ui/app-boot) | `ui` | [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`system-prompt`](../packages/core/system-prompt) | +| [`client-ui-question`](../packages/client/ui-question) | `client` | [`client-locale`](../packages/client/locale), [`invariants`](../packages/support/invariants) | +| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | +| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | @@ -1226,18 +1245,23 @@ flowchart TD | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | | [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | | [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`skill`](../packages/skill/skill) | -| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`web`](../packages/web/web) | +| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`web`](../packages/web/web) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/support/invariants), [`spill`](../packages/spill/spill) | | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | `session-persistence` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | | [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | `session-persistence` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | | [`session-title`](../packages/session-title/session-title) | `session-title` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) | +| [`llm-replay`](../packages/support/llm-replay) | `support` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`commands`](../packages/ui/commands) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | | [`user-approval`](../packages/ui/user-approval) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | +| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | +| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | +| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants) | @@ -1258,7 +1282,9 @@ flowchart TD | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/ui/user-approval) | | [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`typert-registry`](../packages/typert/registry) | +| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`token-meter`](../packages/llm/token-meter) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | +| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | @@ -1284,7 +1310,10 @@ flowchart TD | [`session-title-first-message-llm`](../packages/session-title/session-title-first-message-llm) | `session-title` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`session-title-llm`](../packages/session-title/session-title-llm) | | [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | -| [`client-runtime`](../packages/client/runtime) | `client` | [`api-remotes`](../packages/api/remotes), [`invariants`](../packages/support/invariants), [`type-meta`](../packages/typert/type-meta), [`typert-registry`](../packages/typert/registry) | +| [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | +| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | @@ -1306,11 +1335,10 @@ flowchart TD | [`repository-plugin`](../packages/cordis/repository-plugin) | `cordis` | [`invariants`](../packages/support/invariants), [`mcp-client`](../packages/mcp/mcp-client), [`paths`](../packages/util/paths), [`skill-local`](../packages/skill/skill-local) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) | -| [`client-locale`](../packages/client/locale) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) | -| [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | -| [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) | +| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-command`](../packages/client/ui-command), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`permission`](../packages/ui/permission) | +| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | +| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | | [`sdk-protocol`](../packages/sdk/sdk-protocol) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | @@ -1318,27 +1346,7 @@ flowchart TD | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | -| [`client-ui-question`](../packages/client/ui-question) | `client` | [`client-locale`](../packages/client/locale), [`invariants`](../packages/support/invariants) | -| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | -| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`bash-env`](../packages/bash/bash-env), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`sdk-client`](../packages/sdk/sdk-client) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/sdk-client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | -| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`token-meter`](../packages/llm/token-meter) | -| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | -| [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | -| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | -| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | -| [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | -| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | -| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | -| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | -| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-command`](../packages/client/ui-command), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`permission`](../packages/ui/permission) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 48732dbbb0..533ebd3a97 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -200,12 +200,18 @@ Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/ /** * The paired command settled. `kind`/`text` carry the handler's verbatim * outcome (a thrown/aborted handler settles as `kind: 'error'` with the - * rendered failure); presentation stays client-computed at render time. + * rendered failure). A successful command may identify the earlier + * authoritative domain event for a richer client-computed presentation. */ -'command/done': { commandId: CommandId; kind: 'success' | 'error'; text?: string } +'command/done': { + commandId: CommandId + kind: 'success' | 'error' + text?: string + sourceEventSeq?: number +} ``` -Source: [`packages/ui/commands/src/index.ts:145`](../packages/ui/commands/src/index.ts) +Source: [`packages/ui/commands/src/index.ts:151`](../packages/ui/commands/src/index.ts) #### `command/run` — log-only @@ -223,7 +229,7 @@ Source: [`packages/ui/commands/src/index.ts:145`](../packages/ui/commands/src/in 'command/run': { commandId: CommandId; name: string; args?: string; source: CommandSource } ``` -Source: [`packages/ui/commands/src/index.ts:139`](../packages/ui/commands/src/index.ts) +Source: [`packages/ui/commands/src/index.ts:144`](../packages/ui/commands/src/index.ts) ### `compact/*` @@ -237,7 +243,7 @@ Source: [`packages/ui/commands/src/index.ts:139`](../packages/ui/commands/src/in 'compact/end': { turn: number | null; error?: string } ``` -Source: [`packages/compact/compact/src/types.ts:54`](../packages/compact/compact/src/types.ts) +Source: [`packages/compact/compact/src/types.ts:65`](../packages/compact/compact/src/types.ts) #### `compact/prune` — log-only @@ -261,7 +267,7 @@ Source: [`packages/compact/compact/src/types.ts:54`](../packages/compact/compact } ``` -Source: [`packages/compact/compact/src/types.ts:64`](../packages/compact/compact/src/types.ts) +Source: [`packages/compact/compact/src/types.ts:75`](../packages/compact/compact/src/types.ts) #### `compact/start` — log-only @@ -290,8 +296,6 @@ Source: [`packages/compact/compact/src/types.ts:19`](../packages/compact/compact */ 'compact/summary': { summary: ContentBlock[] - /** Complete provider output before the backend's safe summary projection. */ - rawOutput?: ContentBlock[] shadowedRange: { start: number; end: number } shadowedSeqs: number[] shadowedTokenCount: number @@ -308,7 +312,20 @@ Source: [`packages/compact/compact/src/types.ts:19`](../packages/compact/compact maxTokens?: number /** Provider-reported token usage for the summarization request, when emitted. */ usage?: TokenUsage -} +} & ( + | { + /** Complete provider output before the backend's safe summary projection. */ + rawOutput: ContentBlock[] + /** Identifies exactly one call through this context's `ctx.llm.stream()`. */ + llmStreamCall: true + } + | { + /** Optional complete output from an unmarked template, remote, or other summarizer. */ + rawOutput?: ContentBlock[] + /** An unmarked summary does not identify a call through this context's LLM seam. */ + llmStreamCall?: never + } +) ``` Types: [ContentBlock](core-data-structures/core.md) · [TokenUsage](core-data-structures/llm-streaming.md) diff --git a/docs/testing.i18n.yaml b/docs/testing.i18n.yaml index 2fcbea62e4..90970c8c57 100644 --- a/docs/testing.i18n.yaml +++ b/docs/testing.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/testing.md -testing.md: 514ca4e1df7505b02350470d5de4a5ee3647634b -testing.zh.md: 6d24d8e74d726d53fa3500f57e34238483c5481c +testing.md: fecf02fe887aac5c92c739523f10a0562eba6242 +testing.zh.md: a34f56fac72baa9916d5f96e83048ad54cf23d4a diff --git a/docs/testing.md b/docs/testing.md index 514ca4e1df..fecf02fe88 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -9,7 +9,7 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning - **Unit** (`pnpm run test`): vitest over package and example specs under their `tests/**` directories plus repository script specs under `scripts/**/*.spec.ts`; tests stay with the code area they exercise. Every registry gets an HMR-safety test (dispose the contributing fiber, assert cleanup). Prefer edge cases, error paths, event ordering, concurrency races, and permanent contract regressions (see `packages/core/agent-loop/tests/contract-regressions.spec.ts`). - **Coverage gate** (`pnpm run test:coverage`): the gating run, per-file 100% on `packages/*/*/src`. An uncovered line is often dead code the gate is correctly flagging for deletion, not a missing test to bolt on. Line coverage is necessary, never sufficient — it proves lines ran, not that the feature works as shipped. Per-file 100% on `packages/bash/pwsh-local/src` needs a real `pwsh`: without one its executor suites self-skip and `vitest.config.ts` exempts the file so pwsh-less hosts stay green, while CI runners ship pwsh and enforce the full bar. - **Real-API e2e** (`pnpm run test:e2e`): with-key tests against live provider APIs — the DeepSeek model plus provider-specific smokes that gate on their own keys (`EXA_API_KEY`, `PERPLEXITY_API_KEY`, …); each suite self-skips without its key so keyless CI stays green ([real-API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md)). -- **Snapshot** (`pnpm run test:snapshot`): keyless expected outputs cover external behavior — transport contracts and presentation, while persisted logs pin assembled backend behavior. ACP boots the real automation-server example, replays a recorded session, and diffs normalized JSON-RPC plus the re-persisted log ([ACP snapshot Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md)); headless pins `stream-json` through its real one-shot process. Use `pnpm run test:snapshot:record` when a model transcript changes and `pnpm run test:snapshot:refresh` when replay input remains valid; review every JSONL and expected-output diff. One ACP scenario (`text-turn`) pins full system-prompt/tool-schema content; other fixtures tokenize it so an edit churns one line ([pinned-header Agent Note](../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). +- **Snapshot** (`pnpm run test:snapshot`): keyless expected outputs cover external behavior — transport contracts and presentation, while persisted logs pin assembled backend behavior. ACP boots the real automation-server example, replays a recorded session, and diffs normalized JSON-RPC plus the re-persisted log ([ACP snapshot Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md)); headless backend scenarios boot their explicit example composition through an unexported JSONL test driver, while `apps/cli` separately owns product `dsh run` acceptance. Use `pnpm run test:snapshot:record` when a model transcript changes and `pnpm run test:snapshot:refresh` when replay input remains valid; review every JSONL and expected-output diff. One ACP scenario (`text-turn`) pins full system-prompt/tool-schema content; other fixtures tokenize it so an edit churns one line ([pinned-header Agent Note](../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). - **Web browser snapshot** (`pnpm run test:web`; required Linux PR gate): Chromium compares replayed browser output with `apps/web/tests/snapshots/`. CI forces read-only `DSH_SNAPSHOT=replay`, never writing expected outputs; record/refresh stay local and every diff is reviewed ([web e2e lane](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md), [CI gate decision](../.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md)). `test:web` [builds first](../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md) for plugin CSS. Committed session-format JSONL uses the canonical packed-row layout, and the keyless snapshot gate discovers every such fixture by its `session` header. In-flight branches carrying older fixture edits merge current `master` and run the [temporary migrator](../scripts/migrate-packed-session-fixtures.ts) through `pnpm run migrate:packed-session-fixtures`; the [removal proposal](../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md) retires that command and these links after all affected branches converge. @@ -46,4 +46,4 @@ An e2e assertion re-runs the command or re-reads the file externally; a keyword ## When a snapshot test is required -Every non-trivial model-, protocol-, or human-visible change adds or updates a keyless scenario in the same PR through a runnable example's owning snapshot suite. Package tests, e2e assertions, mock/test-only compositions, and PR rationale do not replace the assembled transcript; extend the harness when needed. ACP automation scenarios use `examples//tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) suite factory (`examples/acp-agent` is primary); `examples/headless-agent` owns the `stream-json` snapshot and replay fixtures. The `pwsh-tool-turn` ACP scenario boots real `pwsh` and skips where it is absent. Completed interactive-terminal journeys use JSONL-driven scenarios under `apps/cli/tests/snapshots/`; transient presentation uses the package-local semantic matrix, with a PTY case when input, Loader selection, or terminal teardown changes. Browser-rendered web GUI journeys use `apps/web/tests/snapshots/`. New capability seams, lifecycle shapes, or transcript surfaces name every coverage tier at plan time and verify the harness can express it before implementation. +Every non-trivial model-, protocol-, or human-visible change adds or updates a keyless scenario in the same PR through a runnable example's owning snapshot suite. Package tests, e2e assertions, mock/test-only compositions, and PR rationale do not replace the assembled transcript; extend the harness when needed. ACP automation scenarios use `examples//tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) suite factory (`examples/acp-agent` is primary); `examples/headless-agent` owns the internal canonical-event JSONL snapshots and replay fixtures. The `pwsh-tool-turn` ACP scenario boots real `pwsh` and skips where it is absent. Completed interactive-terminal journeys use JSONL-driven scenarios under `apps/cli/tests/snapshots/`; transient presentation uses the package-local semantic matrix, with a PTY case when input, Loader selection, or terminal teardown changes. Browser-rendered web GUI journeys use `apps/web/tests/snapshots/`. New capability seams, lifecycle shapes, or transcript surfaces name every coverage tier at plan time and verify the harness can express it before implementation. diff --git a/docs/testing.zh.md b/docs/testing.zh.md index 6d24d8e74d..a34f56fac7 100644 --- a/docs/testing.zh.md +++ b/docs/testing.zh.md @@ -9,7 +9,7 @@ - **单元测试**(`pnpm run test`):vitest 运行包(package)和示例各自的 `tests/**` 目录下的测试,以及匹配 `scripts/**/*.spec.ts` 的仓库脚本测试;测试文件与其所覆盖的代码区域放在一起。每个注册表都有一个 HMR(热模块替换)安全测试(dispose(资源释放)贡献的 fiber,断言清理完成)。优先覆盖边界情况、错误路径、事件顺序、并发竞态,以及永久性契约回归(见 `packages/core/agent-loop/tests/contract-regressions.spec.ts`)。 - **覆盖率门禁**(`pnpm run test:coverage`):门禁级运行,对 `packages/*/*/src` 按文件 100% 覆盖。未覆盖的行往往是门禁正确标记出的死代码(应删除),而非需要补写的测试。行覆盖率是必要条件,但永远不是充分条件:它证明行被执行过,不证明功能按交付预期工作。`packages/bash/pwsh-local/src` 的按文件 100% 覆盖需要真实的 `pwsh`:缺少它时其 executor 套件会自动跳过,`vitest.config.ts` 会豁免该文件以使无 pwsh 的主机保持绿色,而 CI runner 自带 pwsh,仍按完整标准执行门禁。 - **真实 API e2e**(`pnpm run test:e2e`):带密钥测试调用真实提供方 API,包括 DeepSeek 模型以及各提供方特有的冒烟测试;这些测试各自由自己的密钥控制(`EXA_API_KEY`、`PERPLEXITY_API_KEY` 等),缺少密钥时套件会自动跳过,使 keyless CI 保持绿色([真实 API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md))。 -- **快照**(`pnpm run test:snapshot`):无密钥预期输出覆盖对外行为(传输契约与呈现),持久化日志则固定组装后的后端行为。ACP 启动真实的自动化服务器示例、回放录制会话,并对归一化 JSON-RPC 与重新持久化的日志执行 diff([ACP 快照 Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md));headless 通过真实单次运行进程固定 `stream-json`。当模型 transcript(文本记录)发生变化时使用 `pnpm run test:snapshot:record`,回放输入仍然有效时使用 `pnpm run test:snapshot:refresh`;请审查每一处 JSONL 与预期输出差异。一个 ACP 场景(`text-turn`)固定完整的系统提示词与工具 schema 内容;其他 fixture(测试前置数据)将其 token 化,因此修改只会扰动一行([pinned-header Agent Note](../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。 +- **快照**(`pnpm run test:snapshot`):无密钥预期输出覆盖对外行为(传输契约与呈现),持久化日志则固定组装后的后端行为。ACP 启动真实的自动化服务器示例、回放录制会话,并对归一化 JSON-RPC 与重新持久化的日志执行 diff([ACP 快照 Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md));headless 后端场景通过未导出的 JSONL 测试 driver 启动各自显式的示例组装,而 `apps/cli` 则单独负责产品 CLI(命令行界面)`dsh run` 的验收。当模型 transcript(文本记录)发生变化时使用 `pnpm run test:snapshot:record`,回放输入仍然有效时使用 `pnpm run test:snapshot:refresh`;请审查每一处 JSONL 与预期输出差异。一个 ACP 场景(`text-turn`)固定完整的系统提示词与工具 schema 内容;其他 fixture(测试前置数据)将其 token 化,因此修改只会扰动一行([pinned-header Agent Note](../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。 - **Web 浏览器快照**(`pnpm run test:web`;必需的 Linux PR(Pull Request)门禁):Chromium 将回放后的浏览器输出与 `apps/web/tests/snapshots/` 比较。CI 强制只读的 `DSH_SNAPSHOT=replay`,绝不写入预期输出;record/refresh 留在本地,每处 diff 都须评审([web e2e 车道](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md)、[CI 门禁决策](../.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md))。`test:web` 会[先构建](../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md)以交付插件 CSS。 签入仓库的会话格式 JSONL 使用规范打包行布局,无密钥快照门禁会通过 `session` header 发现每一份此类 fixture。仍携带旧版 fixture 改动的在途分支应合并当前 `master`,并通过 `pnpm run migrate:packed-session-fixtures` 运行[临时迁移器](../scripts/migrate-packed-session-fixtures.ts);待所有受影响分支收敛后,[移除提案](../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md)会移除该命令及这些链接。 @@ -46,4 +46,4 @@ e2e 断言应重新运行命令或从外部重新读取文件;对 agent 自身 ## 何时需要快照测试 -每项非平凡的模型可见、协议可见或人类可见变更,都必须在同一 PR 中,通过可运行示例所属的快照套件添加或更新无密钥场景。包测试、e2e 断言、mock 与仅测试组合、PR 理由都不能取代组装后的 transcript;必要时应扩展 harness。ACP 自动化场景使用 `examples//tests/snapshots/`,即基于 [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) 套件工厂的场景表(`examples/acp-agent` 为主套件);`examples/headless-agent` 拥有 `stream-json` 快照与回放 fixture。`pwsh-tool-turn` ACP 场景启动真实 `pwsh`,在无 `pwsh` 的主机上跳过。已完成的交互式终端旅程使用 `apps/cli/tests/snapshots/` 下由 JSONL 驱动的场景;瞬态呈现使用包内语义矩阵,输入、Loader 选择或终端清理发生变化时还要添加 PTY 用例。新的能力 seam、生命周期形态或 transcript 呈现接口在计划阶段就要列出每个覆盖层级,并在实现前验证 harness 能够表达它们。 +每项非平凡的模型可见、协议可见或人类可见变更,都必须在同一 PR 中,通过可运行示例所属的快照套件添加或更新无密钥场景。包测试、e2e 断言、mock 与仅测试组合、PR 理由都不能取代组装后的 transcript;必要时应扩展 harness。ACP 自动化场景使用 `examples//tests/snapshots/`,即基于 [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) 套件工厂的场景表(`examples/acp-agent` 为主套件);`examples/headless-agent` 拥有内部规范事件 JSONL 快照与回放 fixture。`pwsh-tool-turn` ACP 场景启动真实 `pwsh`,在无 `pwsh` 的主机上跳过。已完成的交互式终端旅程使用 `apps/cli/tests/snapshots/` 下由 JSONL 驱动的场景;瞬态呈现使用包内语义矩阵,输入、Loader 选择或终端清理发生变化时还要添加 PTY 用例。新的能力 seam、生命周期形态或 transcript 呈现接口在计划阶段就要列出每个覆盖层级,并在实现前验证 harness 能够表达它们。 diff --git a/docs/user/guide/config.i18n.yaml b/docs/user/guide/config.i18n.yaml index 172af1bcac..954eab633d 100644 --- a/docs/user/guide/config.i18n.yaml +++ b/docs/user/guide/config.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/guide/config.md -config.md: 0f1a99ed0afdab13d052ae2714fc2e1718d3d85e -config.zh.md: 74e14e4e6e1fbb38a7a8d5167a4747080a662128 +config.md: 34ab38c60cc7a9b6026f5be2be47f440eb6cb08d +config.zh.md: ce965dfc67c759ffcbad7b044b74abb2025851c2 diff --git a/docs/user/guide/config.md b/docs/user/guide/config.md index 0f1a99ed0a..34ab38c60c 100644 --- a/docs/user/guide/config.md +++ b/docs/user/guide/config.md @@ -18,10 +18,6 @@ A minimal configuration is a list of plugin entries: ```yaml - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' - config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - models: - - deepseek-v4-flash - id: bash name: '@deepseek-ai/dsh-bash-local' @@ -53,15 +49,14 @@ Cordis starts sibling entries concurrently. A plugin declares required services `dsh --profile ` composes the profile's bundle patch layers (its manifest's `dsh.profile.bundles` list, in order) over an empty root, then the profile's own `~/.dsh/profiles//cordis.patch.yml`, then each `--patch ` overlay, then CLI-flag patches. Later layers win per row. -A patch replaces a row's entire `config` value; it does not deep-merge keys. For example, patching `llm-deepseek` with only `config: { thinking: disabled }` also removes that row's configured `apiKey` and `baseURL`, so restate every key the row must retain. +A patch replaces a row's entire `config` value; it does not deep-merge keys. For example, patching `llm-deepseek` with only `config: { thinking: disabled }` also removes that row's configured `apiKeyEnv` and `baseURL`, so restate every key the row must retain. ## JavaScript values and environment variables -The Cordis loader evaluates runtime expressions tagged with `!!js`. Keep API keys and other secrets in the gitignored `.env` file at the repository root, never in committed configuration. +The Cordis loader evaluates runtime expressions tagged with `!!js` for non-secret runtime values. Bundled LLM adapters carry credential references such as `apiKeyEnv`; the value belongs in an environment layer or `$DSH_HOME/.credentials.yaml`, not Cordis configuration. ```yaml config: - apiKey: !!js process.env.DEEPSEEK_API_KEY cwd: !!js process.cwd() ``` diff --git a/docs/user/guide/config.zh.md b/docs/user/guide/config.zh.md index 74e14e4e6e..ce965dfc67 100644 --- a/docs/user/guide/config.zh.md +++ b/docs/user/guide/config.zh.md @@ -18,10 +18,6 @@ Harness 使用 `cordis.yml` 描述 agent(智能体)加载哪些插件以及 ```yaml - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' - config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - models: - - deepseek-v4-flash - id: bash name: '@deepseek-ai/dsh-bash-local' @@ -53,15 +49,14 @@ Cordis 会并发启动同级配置项。插件通过 `inject` 声明必需服务 `dsh --profile ` 按该 profile 的 manifest(元数据清单)中 `dsh.profile.bundles` 列表的顺序,在空根之上组合各组合包补丁层,随后依次应用该 profile 自己的 `~/.dsh/profiles//cordis.patch.yml`、每个 `--patch ` overlay,最后是 CLI(命令行界面)标志补丁。同一行以较后的层为准。 -补丁会替换目标行的整个 `config` 值,而不是深度合并各个键。例如,只用 `config: { thinking: disabled }` 修补 `llm-deepseek`,也会移除该行原有的 `apiKey` 与 `baseURL`;因此必须重新写出该行需要保留的全部键。 +补丁会替换目标行的整个 `config` 值,而不是深度合并各个键。例如,只用 `config: { thinking: disabled }` 修补 `llm-deepseek`,也会移除该行原有的 `apiKeyEnv` 与 `baseURL`;因此必须重新写出该行需要保留的全部键。 ## JavaScript 值和环境变量 -Cordis loader 使用 `!!js` 标签读取运行时表达式。API key 等凭据应放在仓库根目录、已被 Git 忽略的 `.env` 中,不能提交到配置文件。 +Cordis loader 会求值以 `!!js` 标记的运行时表达式,用于非机密的运行时值。仓库内置的 LLM(大语言模型)适配器携带 `apiKeyEnv` 等凭据引用;对应的值应放在环境层或 `$DSH_HOME/.credentials.yaml`,而不是 Cordis 配置中。 ```yaml config: - apiKey: !!js process.env.DEEPSEEK_API_KEY cwd: !!js process.cwd() ``` diff --git a/docs/user/guide/index.i18n.yaml b/docs/user/guide/index.i18n.yaml index 137a9697c1..a48c5bf842 100644 --- a/docs/user/guide/index.i18n.yaml +++ b/docs/user/guide/index.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/guide/index.md -index.md: 4bb9f2e0056792a160877515f142eb36d4f680ac -index.zh.md: 2792547a146b5ca6186bcb69c1c026744e80b326 +index.md: a04698e29755d4a08b012f8b61accb79c470dcb0 +index.zh.md: 85990f49c7b3cdf7518237c6b558375b7a636243 diff --git a/docs/user/guide/index.md b/docs/user/guide/index.md index 4bb9f2e005..a04698e297 100644 --- a/docs/user/guide/index.md +++ b/docs/user/guide/index.md @@ -11,15 +11,15 @@ Harness implements every capability an AI agent needs—including LLM calls, too ```yaml # Select the LLM backend - name: '@deepseek-ai/dsh-llm-deepseek' - config: - apiKey: !!js process.env.DEEPSEEK_API_KEY -# Select the one-shot application -- id: cli-agent - name: '@deepseek-ai/dsh-cli-demo' +# Compose one configured agent +- id: agent-spine + name: '@deepseek-ai/dsh-agent-spine-demo' config: - provider: deepseek-official - model: deepseek-v4-flash + agents: + - id: main + provider: deepseek-official + model: deepseek-v4-flash workspaceContext: false ``` diff --git a/docs/user/guide/index.zh.md b/docs/user/guide/index.zh.md index 2792547a14..85990f49c7 100644 --- a/docs/user/guide/index.zh.md +++ b/docs/user/guide/index.zh.md @@ -11,15 +11,15 @@ Harness 将一个 AI Agent(智能体) 所需要的所有能力——LLM 调 ```yaml # Select the LLM backend - name: '@deepseek-ai/dsh-llm-deepseek' - config: - apiKey: !!js process.env.DEEPSEEK_API_KEY -# Select the one-shot application -- id: cli-agent - name: '@deepseek-ai/dsh-cli-demo' +# Compose one configured agent +- id: agent-spine + name: '@deepseek-ai/dsh-agent-spine-demo' config: - provider: deepseek-official - model: deepseek-v4-flash + agents: + - id: main + provider: deepseek-official + model: deepseek-v4-flash workspaceContext: false ``` diff --git a/docs/user/guide/providers.i18n.yaml b/docs/user/guide/providers.i18n.yaml index 82c2f2781a..38df7fb986 100644 --- a/docs/user/guide/providers.i18n.yaml +++ b/docs/user/guide/providers.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/guide/providers.md -providers.md: 5234f5bb03755c11652eb23f3c5d677fa3cddb40 -providers.zh.md: a54819cab8524a6007c335ad70cecd6516bba25b +providers.md: 8b52044e64411e3081d56c1ee1849d0b24cd1cda +providers.zh.md: f4a42a4093d253b4b230e4a838ba275a0ce58ac9 diff --git a/docs/user/guide/providers.md b/docs/user/guide/providers.md index 5234f5bb03..8b52044e64 100644 --- a/docs/user/guide/providers.md +++ b/docs/user/guide/providers.md @@ -31,7 +31,7 @@ That holds for providers that authenticate with an API key. The catalog also car **Let the endpoint report its models.** Expand **Model catalog** and choose **Fetch available models**: the interrogation asks the endpoint **the form currently shows** — including a base URL edited but not yet saved and a key typed but not yet stored — and offers what it reports as candidates to pick from. A route the installed catalog describes is answered from that catalog with no network call. Adopting a candidate only writes rows into the draft; nothing is stored until you save. -Keys are write-only: the page only ever holds a redacted descriptor, never the literal secret. A key you enter is stored in `$DSH_HOME/.env`, and the profile records only the variable name that references it. +Keys are write-only: the page only ever holds a redacted descriptor, never the literal secret. A key you enter is stored in `$DSH_HOME/.credentials.yaml`, and the profile records only the variable name that references it. ## settings.yaml for advanced configuration @@ -59,6 +59,16 @@ llm-pi-ai: - id: claude-sonnet-4-5 contextWindow: 200000 + # Catalog route with one model reshaped in place; the rest of the catalog + # keeps serving (a models list would replace it instead). + deepseek: + apiKeyEnv: DEEPSEEK_API_KEY + modelOverrides: + deepseek-v4-pro: + reasoningEfforts: + off: + high: high + # Hand-declared route: pi-ai ships nothing under this key, so the profile # supplies the whole provider. acme-gateway: @@ -66,11 +76,22 @@ llm-pi-ai: apiKeyEnv: ACME_GATEWAY_API_KEY api: openai-completions baseURL: https://gateway.acme.example/v1 + # Reasoning dialect for an endpoint whose URL pi-ai cannot recognize. + compat: + thinkingFormat: deepseek models: - id: acme-large name: Acme Large contextWindow: 65536 maxTokens: 4096 + - id: acme-think + name: Acme Think + # key = level offered in the picker, value = what goes on the wire; + # only off may leave the value empty (supported, send nothing). + reasoningEfforts: + off: + high: high + max: ultra ``` A settings section merges over the matching `cordis.yml` configuration **per provider**, so you can override one field of one route and leave the rest as the composition set them. @@ -79,9 +100,15 @@ A profile the adapter could not serve is refused **where it is written**: a hand ## The model catalog -A profile's `models` list *replaces* that route's installed catalog rather than extending it; omitting it or leaving it empty serves the catalog unchanged. Each entry defaults its unset fields from the installed model of the same `id`, so narrowing a route to two models, correcting one capacity, or adding a model newer than the installed catalog are each a one-line edit. +A profile's `models` list *replaces* that route's installed catalog rather than extending it; omitting it or leaving it empty serves the catalog unchanged. Each entry defaults its unset fields from the installed model of the same `id`, so narrowing a route to two models, correcting one capacity, or adding a model newer than the installed catalog are each a one-line edit — but once you declare the list, every model the route should keep serving must appear in it, an entry of nothing but `id` being enough. -Only the four fields the harness consumes are configurable: `id`, `name`, `contextWindow`, and `maxTokens`. Pricing and input modalities have no consumer, and reasoning is not per-model configurable at all — it rides the installed catalog entry. +Reshaping a few catalog models while keeping the rest is `modelOverrides`' job: it is keyed by catalog model id, takes the same fields a `models` entry does, and leaves the rest of the catalog serving untouched. An override naming a model the catalog does not describe — or set beside a `models` list, or on a custom provider — is refused rather than silently skipped. + +The configurable model fields are `id`, `name`, `contextWindow`, `maxTokens`, `reasoningEfforts`, and `compat`. Pricing and input modalities have no consumer and ride the installed entry. + +**Declare reasoning levels per model.** `reasoningEfforts` lists the levels a model offers: each key appears in the composer's effort picker, and its value is what dispatch sends on the wire — `high: high` passes the name through, `max: ultra` renames it for a gateway with its own vocabulary. A level you leave out is not offered. `off` is special: declared without a value, Off appears in the picker and selecting it sends nothing; left out entirely, the picker offers no Off and requests carry no off switch — the provider's own default decides. `reasoningEfforts: false` declares a non-reasoning model, which is also how you strip reasoning from a catalog model your gateway cannot serve. Without this field a custom model does not reason and a catalog model keeps its catalog levels. + +**Pick the reasoning dialect.** How a level travels — plain `reasoning_effort`, DeepSeek's `thinking: {type}` plus effort, and so on — is normally guessed from the endpoint URL, and a private gateway's URL says nothing, so a DeepSeek-style gateway would be spoken to in the OpenAI dialect. `compat.thinkingFormat` sets the dialect explicitly, and `compat.supportsReasoningEffort: false` holds the parameter back from an endpoint that rejects it; both work on the route (its models' default) or per model, for `openai-completions` routes only. A model neither the entry nor the catalog sizes takes the route's `defaultContextWindow` (262,144) and `defaultMaxTokens` (32,768). Both are guesses by construction, which is why they are route fields: a deployment whose gateway serves smaller models corrects them once. @@ -89,9 +116,9 @@ Model ids are not lifecycle configuration. Requesting a model the route does not ## Credentials -Prefer `apiKeyEnv`: it is a *reference* resolved per request, so no secret enters the configuration file. A literal `apiKey` is the escape hatch. Omitting both is what leaves a route unauthenticated, which for a catalog route means pi-ai's own environment discovery. A reference that resolves to nothing fails the request with `MISSING_CREDENTIAL` rather than falling through to whatever unrelated key the environment happens to hold. +Use `apiKeyEnv`: it is a *reference* resolved per request, so no secret enters the configuration file. Omitting it leaves a route unauthenticated, which for a catalog route means pi-ai's own environment discovery. A reference that resolves to nothing fails the request with `MISSING_CREDENTIAL` rather than falling through to whatever unrelated key the environment happens to hold. -References resolve from `$DSH_HOME/.env` — what the Models page's key fields write — and from the matching environment variable when no credential service is mounted. One credential serves every model on its route. +Under `dsh`, references resolve from the inherited environment, the Models page's `$DSH_HOME/.credentials.yaml` store, the invoking directory's `.env`, then `$DSH_HOME/.env`. Without a credential service, a reference reads only the matching environment variable. One credential serves every model on its route. ## Point an agent at the new provider @@ -114,6 +141,7 @@ If the provider a saved default names is later removed, the composer says **Sele - **`MISSING_CREDENTIAL`** — the variable the profile's `apiKeyEnv` names holds no value. Store the key once through the Models page, or export the variable. - **`UNKNOWN_MODEL`** — the requested model is not in the route's configured catalog. Add it to `models`, or use an id the catalog already carries. +- **`UNSUPPORTED_REASONING_EFFORT`** — the request asked the model for a level it does not offer. Pick a level the composer lists for that model, or declare the missing one in the model's `reasoningEfforts`. - **`settings-rejected`** — the written profile cannot be served, and the message names the route and model. For a hand-declared route, check that `api`, `baseURL`, and `models` are all present. - **Fetching available models answers 401** — the endpoint refused the interrogation. Check the key; if the base URL points at an Anthropic-style gateway, note that the interrogation reads only the OpenAI-compatible `GET /models`, so enter the models by hand instead. diff --git a/docs/user/guide/providers.zh.md b/docs/user/guide/providers.zh.md index a54819cab8..f4a42a4093 100644 --- a/docs/user/guide/providers.zh.md +++ b/docs/user/guide/providers.zh.md @@ -31,7 +31,7 @@ Harness 出厂自带 DeepSeek,同时预装了一个通用的多提供方适配 **让端点自己报模型。** 展开**模型目录**后点**获取可用模型**,会按你**当前表单里**的地址与密钥去问端点(地址改了但没保存、密钥刚输入还没存下,都算数),把它报告的模型列成候选让你勾选。内置目录里的路由直接由目录作答,不联网。采纳只是把行写进草稿,最终还是你点保存才落盘。 -密钥是只写的:页面拿到的永远是脱敏描述符,不是明文。写入的密钥存进 `$DSH_HOME/.env`,profile 里只记录引用它的变量名。 +密钥是只写的:页面拿到的永远是脱敏描述符,不是明文。写入的密钥存进 `$DSH_HOME/.credentials.yaml`,profile 里只记录引用它的变量名。 ## settings.yaml:进阶配置 @@ -59,6 +59,16 @@ llm-pi-ai: - id: claude-sonnet-4-5 contextWindow: 200000 + # Catalog route with one model reshaped in place; the rest of the catalog + # keeps serving (a models list would replace it instead). + deepseek: + apiKeyEnv: DEEPSEEK_API_KEY + modelOverrides: + deepseek-v4-pro: + reasoningEfforts: + off: + high: high + # Hand-declared route: pi-ai ships nothing under this key, so the profile # supplies the whole provider. acme-gateway: @@ -66,11 +76,22 @@ llm-pi-ai: apiKeyEnv: ACME_GATEWAY_API_KEY api: openai-completions baseURL: https://gateway.acme.example/v1 + # Reasoning dialect for an endpoint whose URL pi-ai cannot recognize. + compat: + thinkingFormat: deepseek models: - id: acme-large name: Acme Large contextWindow: 65536 maxTokens: 4096 + - id: acme-think + name: Acme Think + # key = level offered in the picker, value = what goes on the wire; + # only off may leave the value empty (supported, send nothing). + reasoningEfforts: + off: + high: high + max: ultra ``` settings 段落**逐个提供方**地盖在 `cordis.yml` 的同名配置之上,所以你可以只覆盖某个路由的一个字段,其余保持组合里的样子。 @@ -79,9 +100,15 @@ settings 段落**逐个提供方**地盖在 `cordis.yml` 的同名配置之上 ## 模型目录 -`models` 是**替换**该路由的内置目录,不是往里追加;省略或留空则原样使用内置目录。每个条目会从同 `id` 的内置模型继承自己没写的字段,所以「收窄到两个模型」「更正一个容量」「加一个比内置目录更新的模型」都是一行编辑。 +`models` 是**替换**该路由的内置目录,不是往里追加;省略或留空则原样使用内置目录。每个条目会从同 `id` 的内置模型继承自己没写的字段,所以「收窄到两个模型」「更正一个容量」「加一个比内置目录更新的模型」都是一行编辑——但一旦声明了这份列表,该路由要继续服务的每个模型就都必须出现在其中,条目哪怕只写一个 `id` 也足够。 -可配置的只有 harness 会消费的四个字段:`id`、`name`、`contextWindow`、`maxTokens`。定价与输入模态没有消费方,推理能力也不按模型配置——它随内置目录条目走。 +就地重塑目录里的几个模型、保留其余,归 `modelOverrides` 管:它以目录模型 id 为键,接受与 `models` 条目相同的字段,目录的其余部分原样继续服务。覆盖若点名了目录没有描述的模型,或与 `models` 列表并存,或写在自定义提供方上,都会被拒绝,而不是被静默跳过。 + +可配置的模型字段是 `id`、`name`、`contextWindow`、`maxTokens`、`reasoningEfforts` 与 `compat`。定价与输入模态没有消费方,随内置目录条目走。 + +**按模型声明推理档位。** `reasoningEfforts` 列出模型提供的档位:每个键都会出现在输入框的档位选择器里,其值是分派在协议中实际发送的内容——`high: high` 原样透传名称,`max: ultra` 则为使用自有词汇的网关改名。没写的档位不会被提供。`off` 比较特殊:声明而不给值,选择器里会出现 Off,选中它时什么也不发送;完全不写,选择器不提供 Off,请求也不携带关闭开关——由提供方自己的默认行为决定。`reasoningEfforts: false` 声明一个不具备推理能力的模型,这也是从网关服务不了的目录模型上剥除推理的办法。不写这个字段,自定义模型不推理,目录模型保留目录给出的档位。 + +**选定推理方言。** 档位如何在协议中传输——单独一个 `reasoning_effort`、DeepSeek 的 `thinking: {type}` 加档位,诸如此类——通常靠端点 URL 来猜,而私有网关的 URL 什么也说明不了,于是 DeepSeek 风格的网关只会收到 OpenAI 方言的请求。`compat.thinkingFormat` 用来显式指定方言,`compat.supportsReasoningEffort: false` 则让该参数不再发给拒绝它的端点;两者既可设在路由上(作为其模型的默认值),也可按模型设置,且仅适用于 `openai-completions` 路由。 两处容量都没给出的模型,取路由级兜底 `defaultContextWindow`(262144)与 `defaultMaxTokens`(32768)。这两个数按定义就是猜测,所以它们是路由字段:网关服务的模型更小时改一次即可。 @@ -89,9 +116,9 @@ settings 段落**逐个提供方**地盖在 `cordis.yml` 的同名配置之上 ## 凭据 -优先用 `apiKeyEnv`——它是一个**引用**,每次请求时解析,密钥本身不进配置文件;`apiKey` 字面量是应急出口。两者都不给,才表示这个路由不带认证,对内置目录路由意味着交给 pi-ai 自己的环境发现。给了引用却解析不到,请求会以 `MISSING_CREDENTIAL` 失败,而不是退回去用环境里碰巧存在的某个不相干的 key 计费。 +使用 `apiKeyEnv`——它是一个**引用**,每次请求时解析,密钥本身不进配置文件。省略它会让路由不带认证,对内置目录路由意味着交给 pi-ai 自己的环境发现。给了引用却解析不到,请求会以 `MISSING_CREDENTIAL` 失败,而不是退回去用环境里碰巧存在的某个不相干的 key 计费。 -引用解析自 `$DSH_HOME/.env`(模型页的密钥输入框写的就是它),没有挂载凭据服务时则直接读同名环境变量。一份凭据供该路由上的所有模型使用。 +在 `dsh` 下,引用依次从继承环境、模型页的 `$DSH_HOME/.credentials.yaml` 存储、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析。未挂载凭据服务时,引用只读取同名环境变量。一份凭据供该路由上的所有模型使用。 ## 让 agent 用上新提供方 @@ -114,6 +141,7 @@ api-gateway: - **`MISSING_CREDENTIAL`** — profile 里的 `apiKeyEnv` 指向的变量没有值。用模型页存一次密钥,或导出该环境变量。 - **`UNKNOWN_MODEL`** — 请求的模型不在该路由配置的目录里。把它加进 `models`,或改用目录里已有的 id。 +- **`UNSUPPORTED_REASONING_EFFORT`** — 请求向模型要了一个它不提供的档位。从输入框为该模型列出的档位里挑一个,或把缺的那个声明进该模型的 `reasoningEfforts`。 - **`settings-rejected`** — 写入的 profile 服务不了,错误信息会点名具体的路由和模型。手工声明的路由检查 `api`、`baseURL`、`models` 是否齐全。 - **获取可用模型返回 401** — 端点拒绝了这次探测。检查密钥;若地址指向的是 Anthropic 风格网关,注意探测只读 OpenAI 兼容的 `GET /models`,此时手工填写模型即可。 diff --git a/docs/user/guide/quickstart.i18n.yaml b/docs/user/guide/quickstart.i18n.yaml index 27592cdb74..dfdba88bec 100644 --- a/docs/user/guide/quickstart.i18n.yaml +++ b/docs/user/guide/quickstart.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/guide/quickstart.md -quickstart.md: 72cc5a52c33faf81098f747799692b384fcd5f1a -quickstart.zh.md: ebb831c1e1bec02117c78a4c2426a84af3bb9478 +quickstart.md: 9d1a04f0a094919bb008a7e94564a6612e10732d +quickstart.zh.md: 7182f63586f4f08524c643a7c6c1ad599b2de098 diff --git a/docs/user/guide/quickstart.md b/docs/user/guide/quickstart.md index 72cc5a52c3..9d1a04f0a0 100644 --- a/docs/user/guide/quickstart.md +++ b/docs/user/guide/quickstart.md @@ -19,9 +19,10 @@ pnpm -v ## Step 1: install and configure the API key ```sh -git clone https://github.com/deepseek-harness/deepseek-harness.git +git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git cd deepseek-harness pnpm install +pnpm run build ``` Create the gitignored repository-root `.env`: @@ -35,17 +36,16 @@ DEEPSEEK_API_KEY=sk-your-key-here Run a non-interactive task and print its final answer: ```sh -pnpm run demo:headless "summarize the architecture of this workspace" +pnpm run dsh run "summarize the architecture of this workspace" ``` -Headless runs one complete model/tool turn, persists the session, prints the result, and exits. Use `--output-format stream-json` when you need the canonical event stream. +`dsh run` creates and persists a fresh session, prints the final assistant answer, and exits. While it runs, stderr prints the local browser URL where the session can be observed. ## Step 3: use the Web UI -Build and start the browser interface: +Start the browser interface: ```sh -pnpm run build pnpm run dsh web ``` @@ -53,7 +53,7 @@ Open `http://127.0.0.1:3080`. The agent can read and write files, run commands, ## What happened -headless-agent uses the `@deepseek-ai/dsh-cli-demo` app. `dsh web` instead boots the `web` profile: the [`dsh-base`](../../../packages/bundle/base/cordis.patch.yml) and [`dsh-web-app`](../../../packages/bundle/web-app/cordis.patch.yml) bundle patch layers composed over an empty root. Both select the DeepSeek model and capability plugins appropriate to their entry mode. +`dsh run` boots the `headless` profile: [`dsh-base`](../../../packages/bundle/base/cordis.patch.yml), [`dsh-web-app`](../../../packages/bundle/web-app/cordis.patch.yml), and [`dsh-headless`](../../../packages/bundle/headless/cordis.patch.yml) compose over an empty root. `dsh web` uses the first two layers without the one-shot runner. Both select the DeepSeek model and capability plugins appropriate to their entry mode. ## Next steps diff --git a/docs/user/guide/quickstart.zh.md b/docs/user/guide/quickstart.zh.md index ebb831c1e1..7182f63586 100644 --- a/docs/user/guide/quickstart.zh.md +++ b/docs/user/guide/quickstart.zh.md @@ -19,9 +19,10 @@ pnpm -v ## 第一步:安装并配置 API key ```sh -git clone https://github.com/deepseek-harness/deepseek-harness.git +git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git cd deepseek-harness pnpm install +pnpm run build ``` 在仓库根目录创建已被 Git 忽略的 `.env`: @@ -35,17 +36,16 @@ DEEPSEEK_API_KEY=sk-your-key-here 运行一个非交互式任务并打印最终回答: ```sh -pnpm run demo:headless "summarize the architecture of this workspace" +pnpm run dsh run "summarize the architecture of this workspace" ``` -Headless 运行一个完整的模型/工具轮次,持久化会话,打印结果后退出。需要规范事件流时可使用 `--output-format stream-json`。 +`dsh run` 创建并持久化一个新会话,打印最终 assistant 回答,然后退出。运行期间,stderr 会打印可用于观察该会话的本地浏览器 URL。 ## 第三步:使用 Web UI -构建并启动浏览器界面: +启动浏览器界面: ```sh -pnpm run build pnpm run dsh web ``` @@ -53,7 +53,7 @@ pnpm run dsh web ## 回头看 -headless-agent 使用 `@deepseek-ai/dsh-cli-demo` app。`dsh web` 则启动 `web` profile:由 [`dsh-base`](../../../packages/bundle/base/cordis.patch.yml) 与 [`dsh-web-app`](../../../packages/bundle/web-app/cordis.patch.yml) 两个组合包的 patch 层在空根之上组合而成。二者都会根据各自入口模式选择 DeepSeek 模型和能力插件。 +`dsh run` 启动 `headless` profile:[`dsh-base`](../../../packages/bundle/base/cordis.patch.yml)、[`dsh-web-app`](../../../packages/bundle/web-app/cordis.patch.yml) 和 [`dsh-headless`](../../../packages/bundle/headless/cordis.patch.yml) 在空根之上组合。`dsh web` 使用前两层,不包含一次性 runner。二者都会根据各自入口模式选择 DeepSeek 模型和能力插件。 ## 下一步 diff --git a/examples/acp-agent/child-question.cordis.snapshot.yml b/examples/acp-agent/child-question.cordis.snapshot.yml new file mode 100644 index 0000000000..4eb0c5bfb4 --- /dev/null +++ b/examples/acp-agent/child-question.cordis.snapshot.yml @@ -0,0 +1,50 @@ +# Keyless counterpart to child-question.cordis.yml: keep the real interaction +# seam, model-facing tool, and tripwire provider while replacing DeepSeek with +# per-session replay. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - id: sandbox + name: '@deepseek-ai/dsh-sandbox-local' + config: + runnerCommand: + - bash + - -c + - while [ "$1" != "--" ]; do shift; done; shift; exec "$@" + - passthrough-runner + runnerFailureSignatures: + - 'passthrough-runner: profile rejected' + - id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek-official + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: none + workspaceContext: + maxBytes: 65536 + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + + Verify your work by running the code or tests. Keep answers brief and factual. + - insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + - id: deepseek-v4-flash + - id: deepseek-v4-pro + - id: user-interaction + name: '@deepseek-ai/dsh-user-interaction' + - id: tool-ask-user + name: '@deepseek-ai/dsh-tool-ask-user' + - id: child-question-tripwire + name: './tests/fixtures/child-question-tripwire.ts' diff --git a/examples/acp-agent/child-question.cordis.yml b/examples/acp-agent/child-question.cordis.yml new file mode 100644 index 0000000000..803d21c35d --- /dev/null +++ b/examples/acp-agent/child-question.cordis.yml @@ -0,0 +1,14 @@ +# Snapshot-only human-interaction composition. The provider is a tripwire: the +# runtime-owned child must be rejected by the seam before any UI wait begins. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - insert: + - id: user-interaction + name: '@deepseek-ai/dsh-user-interaction' + - id: tool-ask-user + name: '@deepseek-ai/dsh-tool-ask-user' + - id: child-question-tripwire + name: './tests/fixtures/child-question-tripwire.ts' diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index 1af9fa6316..59e7b78278 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -9,8 +9,6 @@ - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL thinking: enabled reasoningEffort: max models: diff --git a/examples/acp-agent/retry.cordis.yml b/examples/acp-agent/retry.cordis.yml index 589120c080..087faa271d 100644 --- a/examples/acp-agent/retry.cordis.yml +++ b/examples/acp-agent/retry.cordis.yml @@ -13,8 +13,6 @@ - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL thinking: enabled reasoningEffort: max retryPolicy: diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index bf4f7ac972..c09dd7d2ed 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -40,6 +40,7 @@ const FS_CONFIG = fileURLToPath(new URL('../fs.cordis.yml', import.meta.url)) const SESSION_QUERY_CONFIG = fileURLToPath(new URL('../session-query.cordis.yml', import.meta.url)) const PTY_CONFIG = fileURLToPath(new URL('../pty.cordis.yml', import.meta.url)) const DEPTH_TWO_CONFIG = fileURLToPath(new URL('../depth-two.cordis.yml', import.meta.url)) +const CHILD_QUESTION_CONFIG = fileURLToPath(new URL('../child-question.cordis.yml', import.meta.url)) const SESSION_SANDBOX_ROOT_CONFIG = fileURLToPath(new URL('../session-sandbox-root.cordis.yml', import.meta.url)) const RETRY_CONFIG = fileURLToPath(new URL('../retry.cordis.yml', import.meta.url)) const SESSION_TITLE_CONFIG = fileURLToPath(new URL('../session-title.cordis.yml', import.meta.url)) @@ -90,8 +91,8 @@ async function prepareFsSearchWorkspace(cwd: string): Promise { } } -// FIXME: Migrate backend-oriented scenarios to the headless stream-json suite; -// this ACP suite should eventually retain only automation-protocol contracts. +// TODO(acp-snapshot-ownership): Move backend/product scenarios to headless while +// retaining ACP protocol contracts here. function fixtureRecords(name: string): unknown[] { return readFileSync(join(SNAPSHOTS_DIR, name, 'session.jsonl'), 'utf8') @@ -351,6 +352,19 @@ const SCENARIOS: Scenario[] = [ overridden: true, configPath: DEPTH_TWO_CONFIG, }, + // Authored keyless replay through the assembled app: a one-shot child calls + // the real ask_user_question tool, the runtime-ownership guard rejects before + // the tripwire provider, and the child carries the unresolved decision in its + // final result so the parent can complete instead of waiting forever. + { + name: 'subagent-child-question-rejection', + hasModelTurn: true, + recorded: false, + pinsHeader: true, + headerClass: 'child-question', + systemPromptSource: 'text-turn', + configPath: CHILD_QUESTION_CONFIG, + }, // The workflow tool: the model writes a one-child orchestration script; the // child runs as a spawn subagent under the worker-thread engine (its session is the // child fixture), and the tool result carries the script's return value. diff --git a/examples/acp-agent/tests/fixtures/child-question-tripwire.ts b/examples/acp-agent/tests/fixtures/child-question-tripwire.ts new file mode 100644 index 0000000000..7eb15ac551 --- /dev/null +++ b/examples/acp-agent/tests/fixtures/child-question-tripwire.ts @@ -0,0 +1,17 @@ +import type { Context } from 'cordis' +import '@deepseek-ai/dsh-user-interaction' + +/** Snapshot-only provider whose invocation means the child guard failed. */ +export const name = 'child-question-tripwire' + +/** User-interaction service required by the tripwire provider. */ +export const inject = ['userInteraction'] + +/** Register a provider that must remain unreachable for the delegated call. */ +export function apply(ctx: Context): void { + ctx.userInteraction.registerProvider({ + async ask() { + throw new Error('snapshot tripwire: delegated question reached the UI provider') + }, + }) +} diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-acp/cordis.yml b/examples/acp-agent/tests/fixtures/subagent/subagent-acp/cordis.yml index c69cf574f7..6575f1b145 100644 --- a/examples/acp-agent/tests/fixtures/subagent/subagent-acp/cordis.yml +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-acp/cordis.yml @@ -34,12 +34,22 @@ # budget, so the local numeric default cannot apply here. maxDepth: 'provider-managed' -- id: cli-agent - name: '@deepseek-ai/dsh-cli-demo' +- id: agent-spine + name: '@deepseek-ai/dsh-agent-spine-demo' config: - provider: mock - model: mock-delegate + agents: + - id: main + provider: mock + model: mock-delegate + cwd: !!js process.cwd() persona: 'Test ACP subagent cwd inheritance.' - persistenceRoot: './.sessions' - persistenceCompression: 'none' workspaceContext: false + +- id: persistence + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: './.sessions' + compression: 'none' + +- id: checkpoint-policy + name: '@deepseek-ai/dsh-session-checkpoint-policy' diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-acp/driver.ts b/examples/acp-agent/tests/fixtures/subagent/subagent-acp/driver.ts index d146b8df80..18f7691d55 100644 --- a/examples/acp-agent/tests/fixtures/subagent/subagent-acp/driver.ts +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-acp/driver.ts @@ -2,14 +2,14 @@ /** Test driver: one delegation turn through a headless Loader composition. */ import { boot, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' -import { runOneShot } from '@deepseek-ai/dsh-cli-demo/src/cli.ts' +import { runFixtureTurn } from '@deepseek-ai/dsh-loader-smoke' const configPath = process.argv[2] if (configPath === undefined) throw new Error('acp-subagent cwd driver requires a config path') const ctx = await boot('acp-subagent-cwd-e2e', resolveConfigPath(configPath, undefined)) try { - await runOneShot(ctx, { task: 'delegate' }) + await runFixtureTurn(ctx, { task: 'delegate' }) } finally { await ctx.fiber.dispose() } diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/cordis.yml b/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/cordis.yml index 2bfcd2af3f..fcdc0c4fb6 100644 --- a/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/cordis.yml +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/cordis.yml @@ -31,10 +31,21 @@ enableRunInBackground: false maxDepth: 'provider-managed' -- id: cli-agent - name: '@deepseek-ai/dsh-cli-demo' +- id: agent-spine + name: '@deepseek-ai/dsh-agent-spine-demo' config: - provider: mock - model: mock-delegate + agents: + - id: main + provider: mock + model: mock-delegate + cwd: !!js process.cwd() persona: 'This composition test must not start a model turn.' workspaceContext: false + +- id: persistence + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: './.sessions' + +- id: checkpoint-policy + name: '@deepseek-ai/dsh-session-checkpoint-policy' diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml b/examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml index be1af7c135..e770b11c95 100644 --- a/examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml @@ -20,10 +20,21 @@ enableRunInBackground: false maxDepth: 'provider-managed' -- id: cli-agent - name: '@deepseek-ai/dsh-cli-demo' +- id: agent-spine + name: '@deepseek-ai/dsh-agent-spine-demo' config: - provider: mock - model: mock-delegate + agents: + - id: main + provider: mock + model: mock-delegate + cwd: !!js process.cwd() persona: 'This composition test must not start a model turn.' workspaceContext: false + +- id: persistence + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: './.sessions' + +- id: checkpoint-policy + name: '@deepseek-ai/dsh-session-checkpoint-policy' diff --git a/examples/acp-agent/tests/fs-search.cordis.yml b/examples/acp-agent/tests/fs-search.cordis.yml index c86b34b8aa..9f9ac7cf0c 100644 --- a/examples/acp-agent/tests/fs-search.cordis.yml +++ b/examples/acp-agent/tests/fs-search.cordis.yml @@ -2,8 +2,6 @@ - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL models: - id: deepseek-v4-pro diff --git a/examples/acp-agent/tests/pwsh.cordis.yml b/examples/acp-agent/tests/pwsh.cordis.yml index 7021ae2116..570d98bf4d 100644 --- a/examples/acp-agent/tests/pwsh.cordis.yml +++ b/examples/acp-agent/tests/pwsh.cordis.yml @@ -2,8 +2,6 @@ - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL models: - id: deepseek-v4-pro diff --git a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl index f30dc715cf..ec369b492a 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl +++ b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl @@ -5,7 +5,7 @@ {"type":"step/start","seq":3,"time":1785498773754,"data":{"turn":1,"step":1}} {"type":"user/message","seq":4,"time":1785498773754,"data":{"content":[{"type":"text","text":"Load the snapshot-skill skill with the skill tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"0ca31b92-27ac-451d-98d3-d1e5f605454b"},"surfaceOp":"append"} {"type":"user/message","seq":5,"time":1785498773755,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"3fc7e2f8-90fc-496c-b516-700cef1d86f1"},"surfaceOp":"append"} -{"type":"user/message","seq":6,"time":1785730426818,"data":{"content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `model-only-skill`: Prove user-disabled skills remain available to the model.\n- `snapshot-skill`: Exercise project skill discovery and loading in snapshot tests.\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\n"}],"source":{"kind":"skill-catalog","form":"catalog","entries":[{"name":"model-only-skill","description":"Prove user-disabled skills remain available to the model."},{"name":"snapshot-skill","description":"Exercise project skill discovery and loading in snapshot tests."}]},"role":"user","id":"60880315-9799-44c8-8a99-e6fe9ee5bdc5"},"surfaceOp":"append"} +{"type":"user/message","seq":6,"time":1785730426818,"data":{"content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `model-only-skill`: Prove user-disabled skills remain available to the model.\n- `snapshot-skill`: Exercise project skill discovery and loading in snapshot tests.\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\nA user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.\n"}],"source":{"kind":"skill-catalog","form":"catalog","entries":[{"name":"model-only-skill","description":"Prove user-disabled skills remain available to the model."},{"name":"snapshot-skill","description":"Exercise project skill discovery and loading in snapshot tests."}]},"role":"user","id":"60880315-9799-44c8-8a99-e6fe9ee5bdc5"},"surfaceOp":"append"} {"type":"session/title","seq":7,"time":1785730426818,"data":{"title":"Load the snapshot-skill skill with","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","seq":8,"time":1785498773756,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":9,"time":1785730426819,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/input.json b/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/input.json new file mode 100644 index 0000000000..d61b79c3ee --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Delegate one question check. Ask the child to call ask_user_question once about the CUDA fallback and return any unresolved question in its final result." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/session.1.jsonl new file mode 100644 index 0000000000..68527f63ac --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/session.1.jsonl @@ -0,0 +1,29 @@ +{"type":"session","version":0,"id":"55555555-5555-4555-8555-555555555555","createdAt":2001,"cwd":"{{cwd}}","parentSession":"44444444-4444-4444-8444-444444444444","origin":"subagent","delegationDepth":1} +{"type":"agent/inbox/spliced","seq":0,"time":1786173701247,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Call ask_user_question once to ask whether deployment should use the CUDA fallback. If the tool returns an error, include the unresolved question verbatim in your final result."}],"source":{"kind":"user"},"role":"user","id":"106c2785-219e-46e8-8386-497ac6a98f68"}]}} +{"type":"turn/start","seq":1,"time":1786173701247,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1786173701247,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":3,"time":1786173701270,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Check deployment question"}} +{"type":"step/start","seq":4,"time":1786173701272,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":5,"time":1786173701272,"data":{"content":[{"type":"text","text":"Call ask_user_question once to ask whether deployment should use the CUDA fallback. If the tool returns an error, include the unresolved question verbatim in your final result."}],"source":{"kind":"user"},"role":"user","id":"106c2785-219e-46e8-8386-497ac6a98f68"},"surfaceOp":"append"} +{"type":"user/message","seq":6,"time":1786173701272,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"fafefa37-7640-4c80-a00a-6a0c3ce46281"},"surfaceOp":"append"} +{"type":"session/title","seq":7,"time":1786173701272,"data":{"title":"Call ask_user_question once to ask","messageSeqs":[5],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":8,"time":1786173701272,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":9,"time":1786173701273,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":11,"time":1786173701278,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_child_question","name":"ask_user_question","argumentsDelta":"{\"questions\":[{\"id\":\"cuda-fallback\",\"header\":\"Deployment\",\"question\":\"Should deployment use the CUDA fallback?\"}]}"}}} +{"type":"assistant/chunk","seq":12,"time":1786173701279,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_child_question","name":"ask_user_question","arguments":"{\"questions\":[{\"id\":\"cuda-fallback\",\"header\":\"Deployment\",\"question\":\"Should deployment use the CUDA fallback?\"}]}"}}}} +{"type":"assistant/chunk","seq":13,"time":1786173701279,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":14,"time":1786173701279,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":15,"time":1786173701279,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_child_question","name":"ask_user_question","arguments":"{\"questions\":[{\"id\":\"cuda-fallback\",\"header\":\"Deployment\",\"question\":\"Should deployment use the CUDA fallback?\"}]}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"301e1969-74b2-45d8-a764-604b806f1c01"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} +{"type":"tool/call","seq":16,"time":1786173701279,"data":{"turn":1,"step":1,"callId":"call_child_question","name":"ask_user_question","arguments":"{\"questions\":[{\"id\":\"cuda-fallback\",\"header\":\"Deployment\",\"question\":\"Should deployment use the CUDA fallback?\"}]}"}} +{"type":"tool/result","seq":17,"time":1786173701292,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_child_question"},"content":[{"type":"tool-result","toolCallId":"call_child_question","content":[{"type":"text","text":"Error: human interaction is unavailable while the calling agent is owned by another live agent; include the unresolved question or decision in the child agent's final result"}],"isError":true}],"role":"user","id":"b9fc0a38-47bb-4335-a8e4-c881ed66bbc3"},"error":{"name":"UserInteractionError","code":"DELEGATED_CALLER"}},"sourceEventSeqs":[16],"surfaceOp":"append"} +{"type":"step/end","seq":18,"time":1786173701292,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":19,"time":1786173701309,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":20,"time":1786173701314,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":21,"time":1786173701314,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"UNRESOLVED: Should deployment use the CUDA fallback?"}}} +{"type":"assistant/chunk","seq":22,"time":1786173701314,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"UNRESOLVED: Should deployment use the CUDA fallback?"}}}} +{"type":"assistant/chunk","seq":23,"time":1786173701314,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":4}}}} +{"type":"assistant/chunk","seq":24,"time":1786173701315,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":25,"time":1786173701315,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"UNRESOLVED: Should deployment use the CUDA fallback?"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5f2ada85-5967-4ed8-9e16-eaff2af847b5"},"usage":{"inputTokens":10,"outputTokens":4}},"sourceEventSeqs":[20,21,22,23,24],"surfaceOp":"append"} +{"type":"step/end","seq":26,"time":1786173701315,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":27,"time":1786173701315,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/session.jsonl new file mode 100644 index 0000000000..ed155f158f --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/session.jsonl @@ -0,0 +1,28 @@ +{"type":"session","version":0,"id":"44444444-4444-4444-8444-444444444444","createdAt":2000,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"agent/inbox/spliced","seq":0,"time":1786173701174,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Delegate one question check. Ask the child to call ask_user_question once about the CUDA fallback and return any unresolved question in its final result."}],"source":{"kind":"user"},"role":"user","id":"851bea02-2961-471a-84ec-3b068c451db0"}]}} +{"type":"turn/start","seq":1,"time":1786173701175,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1786173701175,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1786173701216,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1786173701216,"data":{"content":[{"type":"text","text":"Delegate one question check. Ask the child to call ask_user_question once about the CUDA fallback and return any unresolved question in its final result."}],"source":{"kind":"user"},"role":"user","id":"851bea02-2961-471a-84ec-3b068c451db0"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1786173701217,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"8ef74c46-9e80-475c-9093-0e85ba92e346"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1786173701217,"data":{"title":"Delegate one question check. Ask","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1786173701218,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1786173701219,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_question_child","name":"subagent","argumentsDelta":"{\"description\":\"Check deployment question\",\"prompt\":\"Call ask_user_question once to ask whether deployment should use the CUDA fallback. If the tool returns an error, include the unresolved question verbatim in your final result.\"}"}}} +{"type":"assistant/chunk","seq":11,"time":1786173701233,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_question_child","name":"subagent","arguments":"{\"description\":\"Check deployment question\",\"prompt\":\"Call ask_user_question once to ask whether deployment should use the CUDA fallback. If the tool returns an error, include the unresolved question verbatim in your final result.\"}"}}}} +{"type":"assistant/chunk","seq":12,"time":1786173701233,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":13,"time":1786173701233,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":14,"time":1786173701233,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_question_child","name":"subagent","arguments":"{\"description\":\"Check deployment question\",\"prompt\":\"Call ask_user_question once to ask whether deployment should use the CUDA fallback. If the tool returns an error, include the unresolved question verbatim in your final result.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"f8909de9-23ae-4dbe-a8c1-eaf1e8f2aba5"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"tool/call","seq":15,"time":1786173701234,"data":{"turn":1,"step":1,"callId":"call_question_child","name":"subagent","arguments":"{\"description\":\"Check deployment question\",\"prompt\":\"Call ask_user_question once to ask whether deployment should use the CUDA fallback. If the tool returns an error, include the unresolved question verbatim in your final result.\"}"}} +{"type":"tool/result","seq":16,"time":1786173701316,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_question_child"},"content":[{"type":"tool-result","toolCallId":"call_question_child","content":[{"type":"text","text":"UNRESOLVED: Should deployment use the CUDA fallback?"}],"isError":false}],"role":"user","id":"fdc5b075-574b-48c6-bdda-1b6442edfbef"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"step/end","seq":17,"time":1786173701317,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":18,"time":1786173701334,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":19,"time":1786173701339,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":20,"time":1786173701339,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"PARENT_COMPLETED"}}} +{"type":"assistant/chunk","seq":21,"time":1786173701340,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_COMPLETED"}}}} +{"type":"assistant/chunk","seq":22,"time":1786173701340,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":23,"time":1786173701340,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":24,"time":1786173701340,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_COMPLETED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"700b9e56-965e-406a-bf5c-2db06b96c536"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} +{"type":"step/end","seq":25,"time":1786173701340,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":26,"time":1786173701340,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/stdout.expected.jsonl new file mode 100644 index 0000000000..ef130490e8 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PARENT_COMPLETED"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/tool-schemas.expected.json new file mode 100644 index 0000000000..81f30ee5ec --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/tool-schemas.expected.json @@ -0,0 +1,565 @@ +{ + "initial": [ + { + "name": "ask_user_question", + "description": "Ask the user a concise question when you need confirmation, a choice, or missing information before proceeding. Send one or more questions, each with a stable id that will be echoed in the answer.", + "parameters": { + "type": "object", + "properties": { + "questions": { + "type": "array", + "description": "Questions to ask the user before continuing.", + "items": { + "type": "object", + "additionalProperties": true, + "properties": { + "id": { + "type": "string", + "description": "Stable id for this question; echoed in the answer." + }, + "question": { + "type": "string", + "description": "The specific question to ask the user." + }, + "header": { + "type": "string", + "description": "Optional short heading for the question, such as \"Confirm\" or \"Choose Mode\"." + }, + "options": { + "type": "array", + "description": "Optional choices to show the user. If you recommend one, put it first and append \"(Recommended)\" to that label.", + "items": { + "type": "object", + "additionalProperties": true, + "properties": { + "label": { + "type": "string", + "description": "Short user-facing option label." + }, + "description": { + "type": "string", + "description": "One sentence explaining the tradeoff or impact." + } + }, + "required": [ + "label" + ] + } + }, + "multi_select": { + "type": "boolean", + "description": "Whether the user may select more than one option. Defaults to false." + } + }, + "required": [ + "id", + "question" + ] + } + } + }, + "required": [ + "questions" + ] + } + }, + { + "name": "bash", + "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to execute." + }, + "description": { + "type": "string", + "description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"." + }, + "timeoutMs": { + "type": "number", + "description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry." + }, + "workdir": { + "type": "string", + "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it." + }, + "run_in_background": { + "type": "boolean", + "description": "Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access." + } + }, + "required": [ + "command", + "description" + ] + } + }, + { + "name": "create_goal", + "description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The concrete completion objective inferred from the direct human request." + }, + "max_goal_rounds": { + "type": "number", + "description": "Optional positive safe-integer limit on automatic continuation rounds." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "edit", + "description": "Edit an existing UTF-8 text file by replacing literal text.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to edit, resolved by the filesystem backend." + }, + "old_string": { + "type": "string", + "description": "Literal text to replace. Must match exactly." + }, + "new_string": { + "type": "string", + "description": "Literal replacement text. Use an empty string to delete the match." + }, + "replace_all": { + "type": "boolean", + "description": "Replace all matches. Defaults to false; when false, old_string must appear exactly once." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "old_string", + "new_string" + ] + } + }, + { + "name": "get_goal", + "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "list_agents", + "description": "List your continuable background subagents by durable id and label. Status is a snapshot of the stored record: running means the subagent session is currently live in this process, complete means it exists only in storage and a `send_message` starts a new turn on the same conversation. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "ralph", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The immutable completion objective for every fresh Ralph round." + }, + "maxRounds": { + "type": "number", + "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "read", + "description": "Read a UTF-8 text file and return line-numbered content.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to read, resolved by the filesystem backend." + }, + "offset": { + "type": "number", + "description": "1-based first line to return. Defaults to 1." + }, + "limit": { + "type": "number", + "description": "Maximum number of lines to return. Defaults to 2000." + } + }, + "required": [ + "file_path" + ] + } + }, + { + "name": "send_message", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.", + "parameters": { + "type": "object", + "properties": { + "subagent_id": { + "type": "string", + "description": "The subagent id returned when the background subagent was started." + }, + "message": { + "type": "string", + "description": "The message to deliver to the subagent." + } + }, + "required": [ + "subagent_id", + "message" + ] + } + }, + { + "name": "skill", + "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The exact skill name from the available skills list." + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "subagent", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." + }, + "run_in_background": { + "type": "boolean", + "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "subagent_fork", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new." + }, + "run_in_background": { + "type": "boolean", + "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "task_kill", + "description": "Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the tool that started the background work." + }, + "reason": { + "type": "string", + "description": "Optional short reason, recorded in the log and forwarded to the task." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "task_list", + "description": "List your background tasks (running and finished) with their ids, kinds, and statuses.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "task_output", + "description": "Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the tool that started the background work." + }, + "wait": { + "type": "boolean", + "description": "Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive." + }, + "timeout_ms": { + "type": "number", + "description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "todo_write", + "description": "Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).", + "parameters": { + "type": "object", + "properties": { + "todos": { + "type": "array", + "description": "The COMPLETE task list, replacing any previous list.", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "content": { + "type": "string", + "description": "What the task is — a short imperative line." + }, + "status": { + "type": "string", + "description": "pending (not started) | in_progress (now) | completed (done).", + "enum": [ + "pending", + "in_progress", + "completed" + ] + } + }, + "required": [ + "content", + "status" + ] + } + } + }, + "required": [ + "todos" + ] + } + }, + { + "name": "update_goal", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "parameters": { + "type": "object", + "properties": { + "goal_id": { + "type": "string", + "description": "Exact id returned by get_goal." + }, + "revision": { + "type": "number", + "description": "Exact positive revision returned by get_goal." + }, + "action": { + "type": "string", + "description": "edit | pause | resume | complete | blocked", + "enum": [ + "edit", + "pause", + "resume", + "complete", + "blocked" + ] + }, + "objective": { + "type": "string", + "description": "Replacement objective; valid only with action edit." + }, + "max_goal_rounds": { + "type": "number", + "description": "Replacement cap; valid only with action edit." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] + } + }, + { + "name": "workflow", + "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", + "parameters": { + "type": "object", + "properties": { + "script": { + "type": "string", + "description": "The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)." + }, + "meta": { + "type": "object", + "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, + "properties": { + "name": { + "type": "string", + "description": "Short kebab-case workflow name." + }, + "description": { + "type": "string", + "description": "One-line description of what the workflow does." + }, + "whenToUse": { + "type": "string", + "description": "Optional guidance on when this workflow applies." + }, + "phases": { + "type": "array", + "description": "Optional phase declarations matched by phase() calls.", + "items": { + "type": "object", + "additionalProperties": true, + "properties": { + "title": { + "type": "string", + "description": "The phase title phase() calls match by exact string." + }, + "detail": { + "type": "string", + "description": "Optional one-line description of the phase." + }, + "provider": { + "type": "string", + "description": "Optional provider override this phase is expected to use." + }, + "model": { + "type": "string", + "description": "Optional model override this phase is expected to use." + } + }, + "required": [ + "title" + ] + } + } + }, + "required": [ + "name", + "description" + ] + }, + "args": { + "type": "object", + "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).", + "additionalProperties": true + } + }, + "required": [ + "script", + "meta" + ] + } + }, + { + "name": "write", + "description": "Create or fully replace a UTF-8 text file.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to write, resolved by the filesystem backend." + }, + "content": { + "type": "string", + "description": "Full UTF-8 text content to write." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "content" + ] + } + } + ], + "changes": [] +} diff --git a/examples/headless-agent/README.i18n.yaml b/examples/headless-agent/README.i18n.yaml index de50cce818..7b6139f28b 100644 --- a/examples/headless-agent/README.i18n.yaml +++ b/examples/headless-agent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write examples/headless-agent/README.md -README.md: 670a91ab402cf98585f2ece70787beb3b4aaf4dc -README.zh.md: 6c8b3b5694403c5e09f2904f5c3ca18fe569163e +README.md: e00f3d2d4fd21a860239f3d3a3e5eb2d7520f14a +README.zh.md: 6cd845783b1c112ba73676474b176ec28a4d0b78 diff --git a/examples/headless-agent/README.md b/examples/headless-agent/README.md index 670a91ab40..e00f3d2d4f 100644 --- a/examples/headless-agent/README.md +++ b/examples/headless-agent/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Headless one-shot agent wiring: DeepSeek V4 + local bash and filesystem tools + subagent delegation + workflows and fresh-agent Ralph iteration + `todo_write` + JSONL persistence, with [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo) as the app front door. +This directory owns the replay and real-model test composition for a headless coding agent: DeepSeek V4 + local bash and filesystem tools + subagent delegation + workflows and fresh-agent Ralph iteration + `todo_write` + JSONL persistence. It explicitly mounts the shared agent spine, one root agent, persistence, and checkpoint policy; it is not a second product front door. ## Run it @@ -10,17 +10,13 @@ Headless one-shot agent wiring: DeepSeek V4 + local bash and filesystem tools + # repo root .env (gitignored) or exported env: # DEEPSEEK_API_KEY=sk-… # DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API -pnpm run demo:headless "fix the failing test in this workspace" -pnpm run demo:headless --output-format json -- "summarize the implementation" -pnpm run demo:headless --output-format stream-json -- "run the focused tests" +pnpm run dsh run "fix the failing test in this workspace" ``` -Exactly one nonblank positional task is required; quote tasks containing spaces. There is no `-p` flag. `text` prints the last text-bearing assistant message, `json` prints one DSH-native result record, and `stream-json` emits the top-level session's canonical task-turn events before that record. Child sessions surface only through parent tool events and results. +The product command is [`dsh run`](../../apps/cli/README.md): it accepts one nonblank task, creates and persists a fresh session, prints the final assistant text, and exits. The root `demo:headless` script is only an alias of that command. -Each invocation creates and persists a fresh session, runs all model and tool steps in one turn, flushes, disposes, and exits. This is non-interactive automation: there is no prompt, approval, resume, second turn, or stdin context. The configured tools can mutate the launch workspace, run commands, spawn child agents, and consume provider tokens. +Snapshot suites run this directory's configuration through [`tests/fixtures/headless-driver.ts`](tests/fixtures/headless-driver.ts), an unexported test-only process that emits canonical session events as JSONL before its result record. That stream is test infrastructure, not a supported CLI output format. Child sessions surface only through parent tool events and results. ## Advanced configuration -[`advanced.cordis.yml`](advanced.cordis.yml) adds Code Mode and the Cordis tools to the shipped leaf. - -The package-level [CLI contract](../../packages/examples/cli-demo/README.md) documents output records, exit status, cancellation, persistence, and model/token effects. +[`advanced.cordis.yml`](advanced.cordis.yml) adds Code Mode and the Cordis tools to the test composition. diff --git a/examples/headless-agent/README.zh.md b/examples/headless-agent/README.zh.md index 6c8b3b5694..6cd845783b 100644 --- a/examples/headless-agent/README.zh.md +++ b/examples/headless-agent/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -无头单次 agent(智能体)接线:DeepSeek V4 + 本地 bash 与文件系统工具 + subagent 委托 + 工作流与全新 agent Ralph 迭代 + `todo_write` + JSONL 持久化,并以 [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo) 作为应用入口。 +本目录负责 headless coding agent(智能体)的回放和真实模型测试组装:DeepSeek V4 + 本地 bash 与文件系统工具 + subagent 委托 + 工作流与全新 agent Ralph 迭代 + `todo_write` + JSONL 持久化。本目录显式挂载共享 agent 主干、一个根 agent、持久化和检查点策略;它不是第二个产品入口。 ## 运行 @@ -10,17 +10,13 @@ # repo root .env (gitignored) or exported env: # DEEPSEEK_API_KEY=sk-… # DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API -pnpm run demo:headless "fix the failing test in this workspace" -pnpm run demo:headless --output-format json -- "summarize the implementation" -pnpm run demo:headless --output-format stream-json -- "run the focused tests" +pnpm run dsh run "fix the failing test in this workspace" ``` -必须提供一个且仅一个非空的任务位置参数;含空格的任务需要加引号。没有 `-p` 标志。`text` 打印最后一条包含文本的 assistant 消息,`json` 打印一条 DSH 原生结果记录,`stream-json` 则在该记录之前发出顶层会话的规范任务轮次事件。子会话只通过父会话的工具事件和结果对外显示。 +产品命令是 [`dsh run`](../../apps/cli/README.md):它接受一项非空任务,创建并持久化新会话,打印最终 assistant 文本,然后退出。根目录的 `demo:headless` 脚本只是该命令的别名。 -每次调用都会创建并持久化新会话,在一个轮次中运行所有模型和工具步骤,然后刷写持久化数据、执行 dispose(资源释放),再退出。这是非交互式自动化:没有提示符、批准、恢复、第二轮次或 stdin 上下文。已配置工具可以修改启动时所在的工作区、运行命令、spawn 子 agent,并消耗提供方 token。 +快照套件通过 [`tests/fixtures/headless-driver.ts`](tests/fixtures/headless-driver.ts) 运行本目录的配置。这个未导出且仅供测试使用的进程会在结果记录之前,以 JSONL 发出规范会话事件。该事件流属于测试基础设施,不是受支持的 CLI(命令行界面)输出格式。子会话只通过父会话的工具事件和结果对外显示。 ## 高级配置 -[`advanced.cordis.yml`](advanced.cordis.yml) 在已交付叶节点上添加 Code Mode 和 Cordis 工具。 - -这份包级 [CLI(命令行界面)契约](../../packages/examples/cli-demo/README.md) 说明输出记录、退出状态、取消、持久化以及模型/token 影响。 +[`advanced.cordis.yml`](advanced.cordis.yml) 在测试组装中添加 Code Mode 和 Cordis 工具。 diff --git a/examples/headless-agent/advanced.cordis.snapshot.yml b/examples/headless-agent/advanced.cordis.snapshot.yml index 2cbdeb3698..881a976e33 100644 --- a/examples/headless-agent/advanced.cordis.snapshot.yml +++ b/examples/headless-agent/advanced.cordis.snapshot.yml @@ -1,6 +1,6 @@ # Replay counterpart to advanced.cordis.yml. It includes the base `cordis.yml` # directly — a config patch cannot target an entry behind a nested include — and -# restates advanced.cordis.yml's overlay (the cli-agent config plus the +# restates advanced.cordis.yml's overlay (the agent and persistence configs plus the # code-runtime and tool-cordis inserts) so the whole app config lives in one patch. # It re-pins `deepseek-v4-flash`: `cordis.yml` ships `deepseek-v4-pro`, but the # recorded corpus (request headers, provenance) was captured on flash, so replay @@ -15,15 +15,14 @@ - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' disabled: true - - id: cli-agent - name: '@deepseek-ai/dsh-cli-demo' + - id: agent-spine + name: '@deepseek-ai/dsh-agent-spine-demo' config: - provider: deepseek-official - model: deepseek-v4-flash - persistenceRoot: './.sessions' - # Replay fixtures are raw JSONL; the whole-config patch must restate - # the compression choice or the default zstd frames hide the logs. - persistenceCompression: none + agents: + - id: main + provider: deepseek-official + model: deepseek-v4-flash + cwd: !!js process.cwd() workspaceContext: maxBytes: 65536 tools: @@ -32,6 +31,13 @@ You are headless-agent, a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Verify your work by running the code or tests. Keep answers brief and factual. + - id: persistence + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: './.sessions' + # Replay fixtures are raw JSONL; the whole-config patch must restate + # the compression choice or the default zstd frames hide the logs. + compression: none - insert: - id: code-runtime name: '@deepseek-ai/dsh-code-runtime-worker' diff --git a/examples/headless-agent/advanced.cordis.yml b/examples/headless-agent/advanced.cordis.yml index a344e5e66a..09ecec6ea2 100644 --- a/examples/headless-agent/advanced.cordis.yml +++ b/examples/headless-agent/advanced.cordis.yml @@ -4,13 +4,14 @@ config: path: ./cordis.yml patches: - - id: cli-agent - name: '@deepseek-ai/dsh-cli-demo' + - id: agent-spine + name: '@deepseek-ai/dsh-agent-spine-demo' config: - provider: deepseek-official - model: deepseek-v4-pro - persistenceRoot: './.sessions' - persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'" + agents: + - id: main + provider: deepseek-official + model: deepseek-v4-pro + cwd: !!js process.cwd() workspaceContext: maxBytes: 65536 tools: @@ -19,6 +20,11 @@ You are headless-agent, a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Verify your work by running the code or tests. Keep answers brief and factual. + - id: persistence + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: './.sessions' + compression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'" - insert: - id: code-runtime name: '@deepseek-ai/dsh-code-runtime-worker' diff --git a/examples/headless-agent/compaction.cordis.snapshot.yml b/examples/headless-agent/compaction.cordis.snapshot.yml new file mode 100644 index 0000000000..13515cf5cc --- /dev/null +++ b/examples/headless-agent/compaction.cordis.snapshot.yml @@ -0,0 +1,24 @@ +# Keyless context-overflow composition for the assembled compaction snapshot. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - id: compact-basic + name: '@deepseek-ai/dsh-compact-basic' + config: + thresholdRatio: 0.99 + retainTokens: 20 + maxTokens: 32 + - insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + models: + - id: deepseek-v4-flash + contextWindow: 128000 diff --git a/examples/headless-agent/composition.md b/examples/headless-agent/composition.md index 6ef139d3d9..d319623702 100644 --- a/examples/headless-agent/composition.md +++ b/examples/headless-agent/composition.md @@ -1,9 +1,9 @@ -# Headless Agent App Composition +# Headless Agent Snapshot Composition -The headless demo combines the real DeepSeek adapter and coding capabilities with the one-shot app package, format-pure stdout, and one fresh persisted top-level session. +The headless snapshot composition combines the real DeepSeek adapter and coding capabilities with one explicitly configured persisted top-level agent; its JSONL driver is test-only. ```mermaid flowchart LR @@ -18,15 +18,12 @@ flowchart LR cfg --> plugin_headless_subprocess plugin_headless_bash["bash
@deepseek-ai/dsh-bash-local"] cfg --> plugin_headless_bash - plugin_headless_cli_agent["cli-agent
@deepseek-ai/dsh-cli-demo"] - cfg --> plugin_headless_cli_agent - plugin_headless_cli_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"] - plugin_headless_cli_agent --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"] - plugin_headless_cli_agent --> frontdoor_cli["one-shot driver
format-pure stdout
fresh top-level agent"] - bundle_agent_core --> spine_llm["ctx.llm"] - bundle_agent_core --> spine_sessions["ctx.sessions"] - bundle_agent_core --> spine_tools["ctx.tools + tool-bash"] - bundle_agent_core --> spine_loop["ctx.agents + ctx.agentLoop"] + plugin_headless_agent_spine["agent-spine
@deepseek-ai/dsh-agent-spine-demo"] + cfg --> plugin_headless_agent_spine + plugin_headless_persistence["persistence
@deepseek-ai/dsh-session-persistence-jsonl"] + cfg --> plugin_headless_persistence + plugin_headless_checkpoint_policy["checkpoint-policy
@deepseek-ai/dsh-session-checkpoint-policy"] + cfg --> plugin_headless_checkpoint_policy plugin_headless_token_meter["token-meter
@deepseek-ai/dsh-token-meter"] cfg --> plugin_headless_token_meter plugin_headless_compact_basic["compact-basic
@deepseek-ai/dsh-compact-basic"] @@ -70,7 +67,9 @@ flowchart LR | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | | `subprocess` | `@deepseek-ai/dsh-subprocess-local` | | `bash` | `@deepseek-ai/dsh-bash-local` | -| `cli-agent` | `@deepseek-ai/dsh-cli-demo` | +| `agent-spine` | `@deepseek-ai/dsh-agent-spine-demo` | +| `persistence` | `@deepseek-ai/dsh-session-persistence-jsonl` | +| `checkpoint-policy` | `@deepseek-ai/dsh-session-checkpoint-policy` | | `token-meter` | `@deepseek-ai/dsh-token-meter` | | `compact-basic` | `@deepseek-ai/dsh-compact-basic` | | `session-projection` | `@deepseek-ai/dsh-session-projection` | diff --git a/examples/headless-agent/cordis.yml b/examples/headless-agent/cordis.yml index f2d197d662..b6f1b774f2 100644 --- a/examples/headless-agent/cordis.yml +++ b/examples/headless-agent/cordis.yml @@ -9,7 +9,7 @@ - id: settings name: '@deepseek-ai/dsh-settings-local' -# Credential store: the live process environment over `$DSH_HOME/.env` +# Credential store: the live process environment over `$DSH_HOME/.credentials.yaml` # (owner-only file, hot-reloaded). The adapter resolves `DEEPSEEK_API_KEY` # through it at each request, so no key is inlined in this file. - id: credentials @@ -40,17 +40,18 @@ config: timeoutMs: 60000 -# The app bundle pre-creates one fresh `main` agent per invocation. -- id: cli-agent - name: '@deepseek-ai/dsh-cli-demo' +# The example composition pre-creates one fresh `main` agent for its test driver. +- id: agent-spine + name: '@deepseek-ai/dsh-agent-spine-demo' config: - provider: deepseek-official - # Stays on flash: the goal/ralph replay corpora were recorded on it, and - # their nested-include overlays cannot re-pin the app config (a config - # patch cannot target an entry behind a nested include). - model: deepseek-v4-flash - persistenceRoot: './.sessions' - persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'" + agents: + - id: main + provider: deepseek-official + # Stays on flash: the goal/ralph replay corpora were recorded on it, and + # their nested-include overlays cannot re-pin the app config (a config + # patch cannot target an entry behind a nested include). + model: deepseek-v4-flash + cwd: !!js process.cwd() workspaceContext: maxBytes: 65536 persona: | @@ -59,6 +60,15 @@ Verify your work by running the code or tests. Keep answers brief and factual. +- id: persistence + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: './.sessions' + compression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'" + +- id: checkpoint-policy + name: '@deepseek-ai/dsh-session-checkpoint-policy' + # Summarize an older range when derived history approaches the context window. - id: token-meter name: '@deepseek-ai/dsh-token-meter' diff --git a/examples/headless-agent/tests/compaction.e2e.ts b/examples/headless-agent/tests/compaction.e2e.ts index 07f239a73e..6fe6f4055b 100644 --- a/examples/headless-agent/tests/compaction.e2e.ts +++ b/examples/headless-agent/tests/compaction.e2e.ts @@ -11,8 +11,8 @@ import { SessionId } from '@deepseek-ai/dsh-session' * Key-gated smoke for mid-session compaction. It verifies the compact event * pair, replacement of older surface nodes, and a final answer after compaction. */ -// FIXME(compaction-snapshot): this is the only full compaction coverage because -// replay cannot serve the summarizer's unlogged model call. +// The keyless headless snapshot pins deterministic overflow recovery; this test +// remains the independent live-provider smoke for organic pressure and summary quality. let workdir: string | undefined let ctx: Context | undefined diff --git a/examples/headless-agent/tests/fixtures/cli.cordis.yml b/examples/headless-agent/tests/fixtures/cli.cordis.yml index 72e71ec775..21a26bcd73 100644 --- a/examples/headless-agent/tests/fixtures/cli.cordis.yml +++ b/examples/headless-agent/tests/fixtures/cli.cordis.yml @@ -12,15 +12,21 @@ - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' disabled: true - - id: cli-agent - name: '@deepseek-ai/dsh-cli-demo' + - id: agent-spine + name: '@deepseek-ai/dsh-agent-spine-demo' config: - provider: cli-mock - model: cli-mock - persistenceRoot: './.sessions' + agents: + - id: main + provider: cli-mock + model: cli-mock + cwd: !!js process.cwd() workspaceContext: false dshHome: './.dsh-home' skills: local: agentsHome: './.agents-home' persona: 'Keyless headless-agent smoke.' + - id: persistence + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: './.sessions' diff --git a/examples/headless-agent/tests/fixtures/deepseek-defaults.cordis.yml b/examples/headless-agent/tests/fixtures/deepseek-defaults.cordis.yml index cd472f737d..274a8804ee 100644 --- a/examples/headless-agent/tests/fixtures/deepseek-defaults.cordis.yml +++ b/examples/headless-agent/tests/fixtures/deepseek-defaults.cordis.yml @@ -5,13 +5,17 @@ patches: - id: llm-deepseek config: - apiKey: snapshot-key baseURL: !!js process.env.DSH_SNAPSHOT_BASE_URL thinking: disabled - - id: cli-agent + - id: agent-spine config: - provider: deepseek-official - model: deepseek-v4-flash - persistenceRoot: './.sessions' + agents: + - id: main + provider: deepseek-official + model: deepseek-v4-flash + cwd: !!js process.cwd() workspaceContext: false persona: 'Keyless DeepSeek adapter defaults snapshot.' + - id: persistence + config: + root: './.sessions' diff --git a/examples/headless-agent/tests/fixtures/dsh-run.cordis.yml b/examples/headless-agent/tests/fixtures/dsh-run.cordis.yml new file mode 100644 index 0000000000..7d411f7a0f --- /dev/null +++ b/examples/headless-agent/tests/fixtures/dsh-run.cordis.yml @@ -0,0 +1,8 @@ +- id: api-gateway + config: + provider: cli-mock + model: cli-mock + +- insert: + - id: cli-mock-llm + name: './snapshot-fixtures/cli-mock-llm.ts' diff --git a/examples/headless-agent/tests/fixtures/goal-domain/cordis.yml b/examples/headless-agent/tests/fixtures/goal-domain/cordis.yml index d66713526e..6502dd845f 100644 --- a/examples/headless-agent/tests/fixtures/goal-domain/cordis.yml +++ b/examples/headless-agent/tests/fixtures/goal-domain/cordis.yml @@ -17,12 +17,22 @@ - id: seed-goal name: './seed-goal.ts' -- id: cli-agent - name: '@deepseek-ai/dsh-cli-demo' +- id: agent-spine + name: '@deepseek-ai/dsh-agent-spine-demo' config: - provider: cli-mock - model: cli-mock + agents: + - id: main + provider: cli-mock + model: cli-mock + cwd: !!js process.cwd() persona: 'Test the persisted goal domain.' - persistenceRoot: './.sessions' - persistenceCompression: none workspaceContext: false + +- id: persistence + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: './.sessions' + compression: none + +- id: checkpoint-policy + name: '@deepseek-ai/dsh-session-checkpoint-policy' diff --git a/examples/headless-agent/tests/fixtures/headless-driver.ts b/examples/headless-agent/tests/fixtures/headless-driver.ts new file mode 100644 index 0000000000..88d7d73b67 --- /dev/null +++ b/examples/headless-agent/tests/fixtures/headless-driver.ts @@ -0,0 +1,33 @@ +#!/usr/bin/env node +/** Snapshot-only Loader driver: stream one fixture turn as canonical JSONL. */ + +import type { Context } from 'cordis' +import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' +import { runFixtureTurn } from '@deepseek-ai/dsh-loader-smoke' +import type { SessionEvent } from '@deepseek-ai/dsh-session' + +const NAME = 'headless-test-driver' +const [configPath, ...taskParts] = process.argv.slice(2) +if (configPath === undefined || taskParts.length === 0 || taskParts.every(part => part.trim() === '')) { + throw new Error(`${NAME}: expected `) +} + +const uninstallFailLoud = installFailLoud(NAME) +let ctx: Context | undefined +try { + loadEnv(NAME) + ctx = await boot(NAME, resolveConfigPath(configPath, undefined)) + const result = await runFixtureTurn(ctx, { + task: taskParts.join(' '), + onEvent: (sessionId: string, event: SessionEvent) => { + process.stdout.write(`${JSON.stringify({ type: 'session_event', sessionId, event })}\n`) + }, + }) + process.stdout.write(`${JSON.stringify(result)}\n`) +} catch (error: unknown) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`) + process.exitCode = 1 +} finally { + await ctx?.fiber.dispose() + uninstallFailLoud() +} diff --git a/examples/headless-agent/tests/fixtures/telemetry-otel-driver.ts b/examples/headless-agent/tests/fixtures/telemetry-otel-driver.ts index 72305f0724..afb00b0eca 100644 --- a/examples/headless-agent/tests/fixtures/telemetry-otel-driver.ts +++ b/examples/headless-agent/tests/fixtures/telemetry-otel-driver.ts @@ -10,8 +10,8 @@ import { writeFile } from 'node:fs/promises' import { createServer } from 'node:http' import { once } from 'node:events' import { boot, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' -import { runOneShot } from '@deepseek-ai/dsh-cli-demo/src/cli.ts' import { recordFeedback } from '@deepseek-ai/dsh-command-feedback' +import { runFixtureTurn } from '@deepseek-ai/dsh-loader-smoke' const configPath = process.argv[2] if (configPath === undefined) throw new Error('telemetry-otel driver requires a config path') @@ -35,14 +35,14 @@ const ctx = await boot('telemetry-otel-e2e', resolveConfigPath(configPath, undef try { // The fixture credential rides the model-visible user message; the exported // copy must scrub it while the canonical log keeps the original bytes. - await runOneShot(ctx, { task: 'prove telemetry with key sk-e2efixture1234567890' }) + await runFixtureTurn(ctx, { task: 'prove telemetry with key sk-e2efixture1234567890' }) const mode = process.env.DSH_TELEMETRY_E2E_MODE ?? 'FULL' if (mode !== 'FULL') { const [agent] = ctx.get('agents')?.roots() ?? [] if (agent === undefined) throw new Error('telemetry-otel driver requires one root agent') recordFeedback(agent.session, 'fixture feedback') if (mode === 'FEEDBACK_ONLY') { - await runOneShot(ctx, { task: 'post-feedback private suffix' }) + await runFixtureTurn(ctx, { task: 'post-feedback private suffix' }) } } } finally { diff --git a/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml b/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml index d398c4c825..27172b40d8 100644 --- a/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml +++ b/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml @@ -30,12 +30,22 @@ exporter: url: !!js process.env.DSH_TELEMETRY_E2E_URL -- id: cli-agent - name: '@deepseek-ai/dsh-cli-demo' +- id: agent-spine + name: '@deepseek-ai/dsh-agent-spine-demo' config: - provider: cli-mock - model: cli-mock + agents: + - id: main + provider: cli-mock + model: cli-mock + cwd: !!js process.cwd() persona: 'Test the session-telemetry-otel plugin.' - persistenceRoot: './.sessions' - persistenceCompression: 'none' workspaceContext: false + +- id: persistence + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: './.sessions' + compression: 'none' + +- id: checkpoint-policy + name: '@deepseek-ai/dsh-session-checkpoint-policy' diff --git a/examples/headless-agent/tests/fixtures/time-context-driver.ts b/examples/headless-agent/tests/fixtures/time-context-driver.ts index cac81daeec..00479b4221 100644 --- a/examples/headless-agent/tests/fixtures/time-context-driver.ts +++ b/examples/headless-agent/tests/fixtures/time-context-driver.ts @@ -2,15 +2,15 @@ /** Test driver that sends two turns through one Headless Loader composition. */ import { boot, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' -import { runOneShot } from '@deepseek-ai/dsh-cli-demo/src/cli.ts' +import { runFixtureTurn } from '@deepseek-ai/dsh-loader-smoke' const configPath = process.argv[2] if (configPath === undefined) throw new Error('time-context driver requires a config path') const ctx = await boot('time-context-e2e', resolveConfigPath(configPath, undefined)) try { - await runOneShot(ctx, { task: 'first' }) - await runOneShot(ctx, { task: 'second' }) + await runFixtureTurn(ctx, { task: 'first' }) + await runFixtureTurn(ctx, { task: 'second' }) } finally { await ctx.fiber.dispose() } diff --git a/examples/headless-agent/tests/fixtures/time-context.cordis.yml b/examples/headless-agent/tests/fixtures/time-context.cordis.yml index a105652e9c..ec6359d167 100644 --- a/examples/headless-agent/tests/fixtures/time-context.cordis.yml +++ b/examples/headless-agent/tests/fixtures/time-context.cordis.yml @@ -12,12 +12,22 @@ - id: time-context name: '@deepseek-ai/dsh-time-context' -- id: cli-agent - name: '@deepseek-ai/dsh-cli-demo' +- id: agent-spine + name: '@deepseek-ai/dsh-agent-spine-demo' config: - provider: time-context-mock - model: time-context-mock + agents: + - id: main + provider: time-context-mock + model: time-context-mock + cwd: !!js process.cwd() persona: 'Test the time-context plugin.' - persistenceRoot: './.sessions' - persistenceCompression: 'none' workspaceContext: false + +- id: persistence + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: './.sessions' + compression: 'none' + +- id: checkpoint-policy + name: '@deepseek-ai/dsh-session-checkpoint-policy' diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index 9de860d2b5..87261439da 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -1,4 +1,4 @@ -import { readFile, readdir, writeFile } from 'node:fs/promises' +import { copyFile, mkdir, readFile, readdir, writeFile } from 'node:fs/promises' import { createServer } from 'node:http' import type { IncomingMessage, ServerResponse } from 'node:http' import { delimiter, dirname, join } from 'node:path' @@ -14,6 +14,10 @@ import { type NormalizeContext, } from '@deepseek-ai/dsh-acp-snapshot' import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' +import { + decompressZstdFrame, + scanZstdFrames, +} from '@deepseek-ai/dsh-session-persistence-jsonl/src/zstd.ts' import { describe, expect, it } from 'vitest' const snapshotsDir = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') @@ -29,6 +33,10 @@ const goalScenarioDir = join(snapshotsDir, 'goal-tools') const goalConfigPath = fileURLToPath(new URL('../goal.cordis.snapshot.yml', import.meta.url)) const retryScenarioDir = join(snapshotsDir, 'provider-retry') const retryConfigPath = fileURLToPath(new URL('../retry.cordis.snapshot.yml', import.meta.url)) +const compactionScenarioDir = join(snapshotsDir, 'compaction-recovery') +const compactionSessionFixture = join(compactionScenarioDir, 'session.jsonl') +const compactionStreamExpected = join(compactionScenarioDir, 'stream-json.expected.jsonl') +const compactionConfigPath = fileURLToPath(new URL('../compaction.cordis.snapshot.yml', import.meta.url)) const credentialsScenarioDir = join(snapshotsDir, 'missing-credential') const credentialsConfigPath = fileURLToPath(new URL('../credentials.cordis.snapshot.yml', import.meta.url)) // Same keyless composition as the missing-credential scenario: the endpoint is @@ -39,10 +47,14 @@ const ralphScenarioDir = join(snapshotsDir, 'ralph-loop') const ralphConfigPath = fileURLToPath(new URL('../ralph.cordis.snapshot.yml', import.meta.url)) const startupFailureConfigPath = fileURLToPath(new URL('./fixtures/startup-activation-error/cordis.yml', import.meta.url)) const startupFailureExpected = join(snapshotsDir, 'startup-activation-error', 'stderr.expected.txt') -const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url)) +const binScript = fileURLToPath(new URL('./fixtures/headless-driver.ts', import.meta.url)) +const dshBinScript = fileURLToPath(new URL('../../../apps/cli/src/bin.ts', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) const reasoningConfigPath = fileURLToPath(new URL('./fixtures/cli.cordis.yml', import.meta.url)) const deepseekDefaultsConfigPath = fileURLToPath(new URL('./fixtures/deepseek-defaults.cordis.yml', import.meta.url)) +const dshRunOverlayPath = fileURLToPath(new URL('./fixtures/dsh-run.cordis.yml', import.meta.url)) +const dshRunSessionExpected = join(snapshotsDir, 'dsh-run', 'session.expected.jsonl') +const cliMockLlmPluginPath = fileURLToPath(new URL('./fixtures/cli-mock-llm.ts', import.meta.url)) const refreshing = process.env.DSH_SNAPSHOT === 'refresh' interface JsonObject { @@ -163,23 +175,76 @@ async function scenarioPrompt(dir: string, label: string): Promise { return prompt } -async function persistedLogs(cwd: string): Promise { - const root = join(cwd, '.sessions') - const files = (await readdir(root, { recursive: true })).filter(file => file.endsWith('.jsonl')) +async function readPersistedLog(file: string): Promise { + const content = await readFile(file) + if (!file.endsWith('.zstd')) return content.toString('utf8') + const scan = scanZstdFrames(content) + if (scan.tornStart !== undefined) throw new Error(`persisted snapshot log has a torn Zstandard frame: ${file}`) + const decoded: Buffer[] = [] + for (const frame of scan.frames) { + decoded.push(await decompressZstdFrame(content.subarray(frame.start, frame.end))) + } + return Buffer.concat(decoded).toString('utf8') +} + +async function persistedLogs(cwd: string, root: string = join(cwd, '.sessions')): Promise { + const files = (await readdir(root, { recursive: true })) + .filter(file => file.endsWith('.jsonl') || file.endsWith('.jsonl.zstd')) return Promise.all(files.map(async (file) => { - const content = await readFile(join(root, file), 'utf8') + const content = await readPersistedLog(join(root, file)) return { content, header: parseJsonl(content)[0] ?? {} } })) } describe('headless stream-json snapshots', () => { + it('runs one task through the product dsh run command', async () => { + const task = 'Prove the product dsh run path with one real tool round trip.' + const result = await runLoaderSmoke({ + label: 'product dsh run snapshot', + tempDirPrefix: 'headless-snapshot-dsh-run-', + binScript: dshBinScript, + configPath: dshRunOverlayPath, + binArgs: ['run', '--patch', dshRunOverlayPath, task], + tsconfigPath, + env: { + DSH_PERMISSION_MODE: 'danger-full-access', + DSH_TELEMETRY_DISABLED: '1', + NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), + }, + prepare: async (cwd) => { + const fixtureDir = join(cwd, '.dsh', 'profiles', 'headless', 'snapshot-fixtures') + await mkdir(fixtureDir, { recursive: true }) + await Promise.all([ + copyFile(cliMockLlmPluginPath, join(fixtureDir, 'cli-mock-llm.ts')), + writeFile(join(fixtureDir, 'package.json'), '{"type":"module"}\n'), + ]) + }, + inspect: async (cwd) => { + const logs = await persistedLogs(cwd, join(cwd, '.dsh', 'sessions')) + expect(logs).toHaveLength(1) + const actual = logs[0] + if (actual === undefined) throw new Error('dsh run did not persist its session') + const context = contextFromLogs([actual.content]) + const session = scrubRequestHeaders(normalizeSessionLog(actual.content, context)) + if (refreshing) await writeFile(dshRunSessionExpected, session) + expect(session).toBe(await readFile(dshRunSessionExpected, 'utf8')) + expect(session).toContain(task) + expect(session).toContain('CLI tool round trip complete: CLI_TOOL_ROUND_TRIP') + }, + }) + + expect(result.stdout).toBe('CLI tool round trip complete: CLI_TOOL_ROUND_TRIP\n') + expect(result.stderr).toMatch(/^dsh: observing at http:\/\/127\.0\.0\.1:\d+\n$/u) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('prints the original Loader activation error through the assembled one-shot app', async () => { const result = await runLoaderSmoke({ label: 'headless startup activation error snapshot', tempDirPrefix: 'headless-snapshot-startup-error-', binScript, + libBinScript: binScript, configPath: startupFailureConfigPath, - binArgs: ['--config', startupFailureConfigPath, '--output-format', 'stream-json', 'unreachable task'], + binArgs: [startupFailureConfigPath, 'unreachable task'], tsconfigPath, expectedExitCode: 1, }) @@ -195,8 +260,9 @@ describe('headless stream-json snapshots', () => { label: 'provider retry headless stream-json snapshot', tempDirPrefix: 'headless-snapshot-provider-retry-', binScript, + libBinScript: binScript, configPath: retryConfigPath, - binArgs: ['--config', retryConfigPath, '--output-format', 'stream-json', prompt], + binArgs: [retryConfigPath, prompt], tsconfigPath, env: { DSH_SNAPSHOT: 'replay', @@ -227,6 +293,76 @@ describe('headless stream-json snapshots', () => { expect(normalized).toBe(await readFile(streamExpected, 'utf8')) }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('recovers from context overflow through an assembled compaction', async () => { + const prompt = await scenarioPrompt(compactionScenarioDir, 'compaction-recovery') + let expectedSession = await readFile(compactionSessionFixture, 'utf8') + let runCwd = '' + const result = await runLoaderSmoke({ + label: 'compaction recovery headless stream-json snapshot', + tempDirPrefix: 'headless-snapshot-compaction-recovery-', + binScript, + libBinScript: binScript, + configPath: compactionConfigPath, + binArgs: [compactionConfigPath, prompt], + tsconfigPath, + env: { + DSH_SNAPSHOT: 'replay', + DSH_SNAPSHOT_FILE: compactionSessionFixture, + NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), + }, + prepare: (cwd) => { runCwd = cwd }, + inspect: async (cwd) => { + const logs = await persistedLogs(cwd) + expect(logs).toHaveLength(1) + const actual = logs[0] + if (actual === undefined) throw new Error('compaction snapshot did not persist its session') + const records = parseJsonl(actual.content) + const types = records.map(record => record.type) + expect(types.filter(type => type === 'compact/start')).toHaveLength(1) + expect(types.filter(type => type === 'compact/summary')).toHaveLength(1) + expect(types.filter(type => type === 'compact/end')).toHaveLength(1) + const start = types.indexOf('compact/start') + const summary = types.indexOf('compact/summary') + const replacement = records.findIndex((record) => { + if (record.type !== 'user/message') return false + const surfaceOp = record.surfaceOp as JsonObject | undefined + return surfaceOp?.op === 'replace' + }) + const end = types.indexOf('compact/end') + expect(start).toBeLessThan(summary) + expect(summary).toBeLessThan(replacement) + expect(replacement).toBeLessThan(end) + const summaryRecord = records[summary] + const summaryData = summaryRecord?.data as JsonObject | undefined + expect(summaryData?.shadowedSeqs).toEqual(expect.arrayContaining([expect.any(Number)])) + const final = [...records].reverse().find(record => record.type === 'assistant/message') + expect(JSON.stringify(final)).toContain('COMPACTION RECOVERED') + + const actualContext = contextFromLogs([actual.content]) + if (refreshing) { + const harvested: HarvestedLog = { + id: String(actual.header.id), + createdAt: Number(actual.header.createdAt), + content: actual.content, + } + const replacements = refreshFixtureReplacements([harvested], [expectedSession]) + expectedSession = tokenizeSessionFixtureCwd( + stabilizeRefreshLog(actual.content, expectedSession, replacements, actualContext), + ) + await writeFile(compactionSessionFixture, expectedSession) + } + const expectedContext = contextFromLogs([expectedSession]) + expect(scrubRequestHeaders(normalizeSessionLog(actual.content, actualContext))) + .toBe(scrubRequestHeaders(normalizeSessionLog(expectedSession, expectedContext))) + }, + }) + + expect(result.stderr).toBe('') + const normalized = normalizeHeadlessStream(result.stdout, runCwd) + if (refreshing) await writeFile(compactionStreamExpected, normalized) + expect(normalized).toBe(await readFile(compactionStreamExpected, 'utf8')) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('logs actionable missing-credential guidance through the one-shot app', async () => { const streamExpected = join(credentialsScenarioDir, 'stream-json.expected.jsonl') let runCwd = '' @@ -234,8 +370,9 @@ describe('headless stream-json snapshots', () => { label: 'missing-credential headless stream-json snapshot', tempDirPrefix: 'headless-snapshot-missing-credential-', binScript, + libBinScript: binScript, configPath: credentialsConfigPath, - binArgs: ['--config', credentialsConfigPath, '--output-format', 'stream-json', 'say pong'], + binArgs: [credentialsConfigPath, 'say pong'], tsconfigPath, env: { // First-run posture: no key in the environment, none under ./.dsh. @@ -246,16 +383,22 @@ describe('headless stream-json snapshots', () => { prepare: (cwd) => { runCwd = cwd }, }) + // The failure reaches the caller through the stream, not stderr; the + // recorded transcript below pins the guidance text itself, which names + // both places a credential can come from and nothing else. expect(result.stderr).toBe('') const normalized = normalizeHeadlessStream(result.stdout, runCwd) if (refreshing) await writeFile(streamExpected, normalized) expect(normalized).toBe(await readFile(streamExpected, 'utf8')) // The durable failure leads with the credential store — the path that - // keeps the secret out of configuration files — and offers a literal key last. + // keeps the secret out of configuration files — then names the launching + // environment, and stops there: configuration carries the reference, so + // there is no literal-key escape hatch left to offer. expect(normalized).toContain( 'store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it),', ) - expect(normalized).toContain('as a last resort') + expect(normalized).toContain('or export DEEPSEEK_API_KEY in the launching environment') + expect(normalized).not.toContain('as a last resort') }, LOADER_SMOKE_TEST_TIMEOUT_MS) it('logs actionable invalid-credential guidance through the one-shot app', async () => { @@ -265,8 +408,9 @@ describe('headless stream-json snapshots', () => { label: 'invalid-credential headless stream-json snapshot', tempDirPrefix: 'headless-snapshot-invalid-credential-', binScript, + libBinScript: binScript, configPath: credentialsConfigPath, - binArgs: ['--config', credentialsConfigPath, '--output-format', 'stream-json', 'say pong'], + binArgs: [credentialsConfigPath, 'say pong'], tsconfigPath, env: { // A key that exists but no HTTP header can carry — the paste this @@ -299,8 +443,9 @@ describe('headless stream-json snapshots', () => { label: 'reasoning effort headless stream-json snapshot', tempDirPrefix: 'headless-snapshot-reasoning-effort-', binScript, + libBinScript: binScript, configPath: reasoningConfigPath, - binArgs: ['--config', reasoningConfigPath, '--output-format', 'stream-json', 'prove dynamic reasoning effort'], + binArgs: [reasoningConfigPath, 'prove dynamic reasoning effort'], tsconfigPath, }) @@ -341,16 +486,17 @@ describe('headless stream-json snapshots', () => { label: 'DeepSeek adapter defaults headless stream-json snapshot', tempDirPrefix: 'headless-snapshot-deepseek-defaults-', binScript, + libBinScript: binScript, configPath: deepseekDefaultsConfigPath, binArgs: [ - '--config', deepseekDefaultsConfigPath, - '--output-format', - 'stream-json', 'return the deterministic response', ], tsconfigPath, env: { + // Configuration carries only the reference; the key rides the + // launching environment, which is the whole credential plane here. + DEEPSEEK_API_KEY: 'snapshot-key', DSH_SNAPSHOT_BASE_URL: server.url, NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), }, @@ -398,8 +544,9 @@ describe('headless stream-json snapshots', () => { label: 'advanced headless stream-json snapshot', tempDirPrefix: 'headless-snapshot-advanced-', binScript, + libBinScript: binScript, configPath: advancedConfigPath, - binArgs: ['--config', advancedConfigPath, '--output-format', 'stream-json', prompt], + binArgs: [advancedConfigPath, prompt], tsconfigPath, env: { DSH_SNAPSHOT: 'replay', @@ -469,8 +616,9 @@ describe('headless stream-json snapshots', () => { label: 'goal tools headless stream-json snapshot', tempDirPrefix: 'headless-snapshot-goal-tools-', binScript, + libBinScript: binScript, configPath: goalConfigPath, - binArgs: ['--config', goalConfigPath, '--output-format', 'stream-json', prompt], + binArgs: [goalConfigPath, prompt], tsconfigPath, env: { DSH_SNAPSHOT: 'replay', @@ -525,8 +673,9 @@ describe('headless stream-json snapshots', () => { label: 'Ralph loop headless stream-json snapshot', tempDirPrefix: 'headless-snapshot-ralph-loop-', binScript, + libBinScript: binScript, configPath: ralphConfigPath, - binArgs: ['--config', ralphConfigPath, '--output-format', 'stream-json', prompt], + binArgs: [ralphConfigPath, prompt], tsconfigPath, env: { DSH_SNAPSHOT: 'replay', @@ -606,8 +755,9 @@ describe('headless stream-json snapshots', () => { label: 'headless persistent PTY snapshot', tempDirPrefix: 'headless-snapshot-pty-', binScript, + libBinScript: binScript, configPath: ptyConfigPath, - binArgs: ['--config', ptyConfigPath, '--output-format', 'stream-json', prompt], + binArgs: [ptyConfigPath, prompt], tsconfigPath, env: { DSH_SNAPSHOT: 'replay', diff --git a/examples/headless-agent/tests/keyless-smoke.e2e.ts b/examples/headless-agent/tests/keyless-smoke.e2e.ts index 2547d04ac1..8b69406c53 100644 --- a/examples/headless-agent/tests/keyless-smoke.e2e.ts +++ b/examples/headless-agent/tests/keyless-smoke.e2e.ts @@ -9,7 +9,7 @@ import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-l import { PREPARED_ENTRY_FILENAME, prepareDshPlugin } from '@deepseek-ai/dsh-repository-plugin' import type { SessionEvent } from '@deepseek-ai/dsh-session' -const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url)) +const binScript = fileURLToPath(new URL('./fixtures/headless-driver.ts', import.meta.url)) const configPath = fileURLToPath(new URL('./fixtures/cli.cordis.yml', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) const decompress = promisify(zstdDecompress) @@ -21,8 +21,9 @@ describe('headless-agent keyless smoke', () => { label: 'headless-agent', tempDirPrefix: 'headless-agent-smoke-', binScript, + libBinScript: binScript, configPath, - binArgs: ['--config', configPath, '--output-format', 'stream-json', 'prove the tool path'], + binArgs: [configPath, 'prove the tool path'], tsconfigPath, inspect: async (cwd) => { const files = await readdir(cwd, { recursive: true }) diff --git a/examples/headless-agent/tests/real-model.e2e.ts b/examples/headless-agent/tests/real-model.e2e.ts index 653f5ab624..6276077aaa 100644 --- a/examples/headless-agent/tests/real-model.e2e.ts +++ b/examples/headless-agent/tests/real-model.e2e.ts @@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' -const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url)) +const binScript = fileURLToPath(new URL('./fixtures/headless-driver.ts', import.meta.url)) const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) const hasKey = Boolean(process.env.DEEPSEEK_API_KEY) @@ -16,9 +16,9 @@ describe.skipIf(!hasKey)('headless-agent with real model', () => { label: 'headless-agent real model', tempDirPrefix: 'headless-agent-real-', binScript, + libBinScript: binScript, configPath, binArgs: [ - '--config', configPath, 'Read task.txt, replace its complete contents with exactly "value=after" followed by a newline, read it again, and report briefly.', ], diff --git a/examples/headless-agent/tests/semantic-checkpoint.snapshot.ts b/examples/headless-agent/tests/semantic-checkpoint.snapshot.ts index de41799b76..a8b8f02b9a 100644 --- a/examples/headless-agent/tests/semantic-checkpoint.snapshot.ts +++ b/examples/headless-agent/tests/semantic-checkpoint.snapshot.ts @@ -14,7 +14,7 @@ const replayFixture = join(fixtureDir, 'replay.jsonl') const replayOverride = join(fixtureDir, 'replay.override.json') const sessionExpected = join(fixtureDir, 'session.expected.jsonl') const configPath = fileURLToPath(new URL('../semantic-checkpoint.cordis.snapshot.yml', import.meta.url)) -const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url)) +const binScript = fileURLToPath(new URL('./fixtures/headless-driver.ts', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) const sessionId = SessionId('semantic-checkpoint-unknown-outcome') const refreshing = process.env.DSH_SNAPSHOT === 'refresh' @@ -87,8 +87,9 @@ describe('semantic checkpoint recovery snapshot', () => { label: 'semantic checkpoint headless stream-json snapshot', tempDirPrefix: 'dsh-semantic-snapshot-', binScript, + libBinScript: binScript, configPath, - binArgs: ['--config', configPath, '--output-format', 'stream-json', task], + binArgs: [configPath, task], tsconfigPath, env: { DSH_SNAPSHOT_FILE: replayFixture, diff --git a/examples/headless-agent/tests/snapshots/compaction-recovery/input.json b/examples/headless-agent/tests/snapshots/compaction-recovery/input.json new file mode 100644 index 0000000000..3ccad96b83 --- /dev/null +++ b/examples/headless-agent/tests/snapshots/compaction-recovery/input.json @@ -0,0 +1,8 @@ +{ + "steps": [ + { + "op": "prompt", + "text": "Establish a durable compaction premise before continuing. Record every part of this historical evidence: the snapshot uses keyless replay; persistence uses JSONL; the assembled headless application loads its real Cordis composition; model-visible inputs remain logged; tool calls and results remain paired and ordered; context overflow retains the original failure while recovery is attempted; compaction opens with compact/start and closes with compact/end; a successful auxiliary summary records compact/summary provenance; the replacement surface shadows only an older balanced range; the checkpoint remains smaller than the history it replaces; the newest tool result remains verbatim; the retried request sees that checkpoint; the final response proves the same turn continued; deterministic snapshot evidence stays separate from the live-provider smoke; and no external API key is needed. Emit one alpha marker through bash, then finish the task after any required recovery with the exact words COMPACTION RECOVERED." + } + ] +} diff --git a/examples/headless-agent/tests/snapshots/compaction-recovery/session.jsonl b/examples/headless-agent/tests/snapshots/compaction-recovery/session.jsonl new file mode 100644 index 0000000000..38056019e2 --- /dev/null +++ b/examples/headless-agent/tests/snapshots/compaction-recovery/session.jsonl @@ -0,0 +1,32 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"agent/inbox/spliced","seq":0,"time":1786123401613,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Establish a durable compaction premise before continuing. Record every part of this historical evidence: the snapshot uses keyless replay; persistence uses JSONL; the assembled headless application loads its real Cordis composition; model-visible inputs remain logged; tool calls and results remain paired and ordered; context overflow retains the original failure while recovery is attempted; compaction opens with compact/start and closes with compact/end; a successful auxiliary summary records compact/summary provenance; the replacement surface shadows only an older balanced range; the checkpoint remains smaller than the history it replaces; the newest tool result remains verbatim; the retried request sees that checkpoint; the final response proves the same turn continued; deterministic snapshot evidence stays separate from the live-provider smoke; and no external API key is needed. Emit one alpha marker through bash, then finish the task after any required recovery with the exact words COMPACTION RECOVERED."}],"source":{"kind":"user"},"role":"user","id":"6335ca4a-a577-47dd-8219-aa81f39cdbc0"}]}} +{"type":"turn/start","seq":1,"time":1786123401614,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1786123401614,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1786123401667,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1786123401667,"data":{"content":[{"type":"text","text":"Establish a durable compaction premise before continuing. Record every part of this historical evidence: the snapshot uses keyless replay; persistence uses JSONL; the assembled headless application loads its real Cordis composition; model-visible inputs remain logged; tool calls and results remain paired and ordered; context overflow retains the original failure while recovery is attempted; compaction opens with compact/start and closes with compact/end; a successful auxiliary summary records compact/summary provenance; the replacement surface shadows only an older balanced range; the checkpoint remains smaller than the history it replaces; the newest tool result remains verbatim; the retried request sees that checkpoint; the final response proves the same turn continued; deterministic snapshot evidence stays separate from the live-provider smoke; and no external API key is needed. Emit one alpha marker through bash, then finish the task after any required recovery with the exact words COMPACTION RECOVERED."}],"source":{"kind":"user"},"role":"user","id":"6335ca4a-a577-47dd-8219-aa81f39cdbc0"},"surfaceOp":"append"} +{"type":"session/title","seq":5,"time":1786123401667,"data":{"title":"Establish a durable compaction premise","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":6,"time":1786123401668,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/context","seq":7,"time":1786123401669,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}} +{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_compaction_marker","name":"bash","argumentsDelta":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}}} +{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}}}} +{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":24,"outputTokens":6}}}} +{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":13,"time":1786123401680,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e54f73a8-572a-40ee-b908-8a8a27b83bf8"},"usage":{"inputTokens":24,"outputTokens":6}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} +{"type":"tool/call","seq":14,"time":1786123401680,"data":{"turn":1,"step":1,"callId":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}} +{"type":"tool/result","seq":15,"time":1786123401700,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_compaction_marker"},"content":[{"type":"tool-result","toolCallId":"call_compaction_marker","content":[{"type":"text","text":"alpha\n"}],"isError":false}],"role":"user","id":"b4a6504e-f39d-40b0-b51a-b11fbd60b135"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"step/end","seq":16,"time":1786123401700,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":17,"time":1786123401710,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":18,"time":1786123401715,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"snapshot request exceeded the model context window","code":"CONTEXT_WINDOW_EXCEEDED"}}}}} +{"type":"compact/start","seq":19,"time":1786123401715,"data":{"turn":1}} +{"type":"compact/summary","seq":20,"time":1786123401725,"data":{"summary":[{"type":"text","text":"The request established a durable compaction premise."}],"rawOutput":[{"type":"text","text":"The request established a durable compaction premise."}],"llmStreamCall":true,"shadowedRange":{"start":4,"end":4},"shadowedSeqs":[4],"shadowedTokenCount":264,"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":32,"usage":{"inputTokens":20,"outputTokens":4}}} +{"type":"user/message","seq":21,"time":1786123401725,"data":{"content":[{"type":"text","text":"This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.\n\n"},{"type":"text","text":"The request established a durable compaction premise."},{"type":"text","text":""}],"source":{"kind":"plugin","plugin":"compact"},"role":"user","id":"6d2afb13-a37b-48d6-9ea5-fc8734127377"},"sourceEventSeqs":[19,20,4],"surfaceOp":{"op":"replace","start":4,"end":4}} +{"type":"compact/end","seq":22,"time":1786123401725,"data":{"turn":1}} +{"type":"assistant/chunk","seq":23,"time":1786123401730,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":24,"time":1786123401730,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"COMPACTION RECOVERED"}}} +{"type":"assistant/chunk","seq":25,"time":1786123401730,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"COMPACTION RECOVERED"}}}} +{"type":"assistant/chunk","seq":26,"time":1786123401730,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":4}}}} +{"type":"assistant/chunk","seq":27,"time":1786123401730,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":28,"time":1786123401730,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"COMPACTION RECOVERED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"346742d0-50e3-4594-b53c-f26c7da82c56"},"usage":{"inputTokens":20,"outputTokens":4}},"sourceEventSeqs":[23,24,25,26,27],"surfaceOp":"append"} +{"type":"step/end","seq":29,"time":1786123401730,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":30,"time":1786123401730,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/compaction-recovery/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/compaction-recovery/stream-json.expected.jsonl new file mode 100644 index 0000000000..62cc357cc7 --- /dev/null +++ b/examples/headless-agent/tests/snapshots/compaction-recovery/stream-json.expected.jsonl @@ -0,0 +1,32 @@ +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":0,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Establish a durable compaction premise before continuing. Record every part of this historical evidence: the snapshot uses keyless replay; persistence uses JSONL; the assembled headless application loads its real Cordis composition; model-visible inputs remain logged; tool calls and results remain paired and ordered; context overflow retains the original failure while recovery is attempted; compaction opens with compact/start and closes with compact/end; a successful auxiliary summary records compact/summary provenance; the replacement surface shadows only an older balanced range; the checkpoint remains smaller than the history it replaces; the newest tool result remains verbatim; the retried request sees that checkpoint; the final response proves the same turn continued; deterministic snapshot evidence stays separate from the live-provider smoke; and no external API key is needed. Emit one alpha marker through bash, then finish the task after any required recovery with the exact words COMPACTION RECOVERED."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":1,"time":0,"data":{"turn":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":2,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":4,"time":0,"data":{"content":[{"type":"text","text":"Establish a durable compaction premise before continuing. Record every part of this historical evidence: the snapshot uses keyless replay; persistence uses JSONL; the assembled headless application loads its real Cordis composition; model-visible inputs remain logged; tool calls and results remain paired and ordered; context overflow retains the original failure while recovery is attempted; compaction opens with compact/start and closes with compact/end; a successful auxiliary summary records compact/summary provenance; the replacement surface shadows only an older balanced range; the checkpoint remains smaller than the history it replaces; the newest tool result remains verbatim; the retried request sees that checkpoint; the final response proves the same turn continued; deterministic snapshot evidence stays separate from the live-provider smoke; and no external API key is needed. Emit one alpha marker through bash, then finish the task after any required recovery with the exact words COMPACTION RECOVERED."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":5,"time":0,"data":{"title":"Establish a durable compaction premise","messageSeqs":[4],"source":{"kind":"fallback"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":6,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":7,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_compaction_marker","name":"bash","argumentsDelta":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":24,"outputTokens":6}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":13,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":24,"outputTokens":6}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":14,"time":0,"data":{"turn":1,"step":1,"callId":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":15,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_compaction_marker"},"content":[{"type":"tool-result","toolCallId":"call_compaction_marker","content":[{"type":"text","text":"alpha\n"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":16,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":17,"time":0,"data":{"turn":1,"step":2}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"snapshot request exceeded the model context window","code":"CONTEXT_WINDOW_EXCEEDED"}}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"compact/start","seq":19,"time":0,"data":{"turn":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"compact/summary","seq":20,"time":0,"data":{"summary":[{"type":"text","text":"The request established a durable compaction premise."}],"rawOutput":[{"type":"text","text":"The request established a durable compaction premise."}],"llmStreamCall":true,"shadowedRange":{"start":4,"end":4},"shadowedSeqs":[4],"shadowedTokenCount":264,"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":32,"usage":{"inputTokens":20,"outputTokens":4}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":21,"time":0,"data":{"content":[{"type":"text","text":"This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.\n\n"},{"type":"text","text":"The request established a durable compaction premise."},{"type":"text","text":""}],"source":{"kind":"plugin","plugin":"compact"},"role":"user","id":"{{sessionId}}"},"sourceEventSeqs":[19,20,4],"surfaceOp":{"op":"replace","start":4,"end":4}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"compact/end","seq":22,"time":0,"data":{"turn":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":24,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"COMPACTION RECOVERED"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"COMPACTION RECOVERED"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":4}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":28,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"COMPACTION RECOVERED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":20,"outputTokens":4}},"sourceEventSeqs":[23,24,25,26,27],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":29,"time":0,"data":{"turn":1,"step":2}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":30,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} +{"type":"result","sessionId":"{{sessionId}}","output":"COMPACTION RECOVERED","usage":{"inputTokens":44,"outputTokens":10}} diff --git a/examples/headless-agent/tests/snapshots/dsh-run/session.expected.jsonl b/examples/headless-agent/tests/snapshots/dsh-run/session.expected.jsonl new file mode 100644 index 0000000000..1312eb6511 --- /dev/null +++ b/examples/headless-agent/tests/snapshots/dsh-run/session.expected.jsonl @@ -0,0 +1,33 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"permission/preset","seq":0,"time":0,"data":{"preset":"danger-full-access"}} +{"type":"sandbox/mode","seq":1,"time":0,"data":{"mode":"danger-full-access"}} +{"type":"approval/policy","seq":2,"time":0,"data":{"policy":"never"}} +{"type":"agent/inbox/spliced","seq":3,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Prove the product dsh run path with one real tool round trip."}],"source":{"kind":"user","rpcId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"}]}} +{"type":"turn/start","seq":4,"time":0,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":5,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":7,"time":0,"data":{"content":[{"type":"text","text":"Prove the product dsh run path with one real tool round trip."}],"source":{"kind":"user","rpcId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"session/title","seq":9,"time":0,"data":{"title":"Prove the product dsh run","messageSeqs":[7],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"cli-mock","model":"cli-mock","reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":11,"time":0,"data":{"provider":"cli-mock","model":"cli-mock"}} +{"type":"session/title-llm-request","seq":12,"time":0,"data":{"titleProvider":"session-title-first-message-llm","messageSeqs":[7],"route":{"provider":"cli-mock","model":"cli-mock"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.","messages":[{"content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":7,\"text\":\"Prove the product dsh run path with one real tool round trip.\"}]"}],"source":{"kind":"plugin","plugin":"dsh-session-title-llm"},"role":"user","id":"{{sessionId}}"}],"maxTokens":64}} +{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"cli-smoke-call","name":"bash","argumentsDelta":"{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"}}} +{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cli-smoke-call","name":"bash","arguments":"{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"}}}} +{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":11,"outputTokens":3,"cacheReadTokens":2}}}} +{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":18,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cli-smoke-call","name":"bash","arguments":"{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"}],"source":{"kind":"model","provider":"cli-mock","model":"cli-mock"},"id":"{{sessionId}}"},"usage":{"inputTokens":11,"outputTokens":3,"cacheReadTokens":2}},"sourceEventSeqs":[13,14,15,16,17],"surfaceOp":"append"} +{"type":"tool/call","seq":19,"time":0,"data":{"turn":1,"step":1,"callId":"cli-smoke-call","name":"bash","arguments":"{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"}} +{"type":"tool/result","seq":20,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"cli-smoke-call"},"content":[{"type":"tool-result","toolCallId":"cli-smoke-call","content":[{"type":"text","text":"CLI_TOOL_ROUND_TRIP"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[19],"surfaceOp":"append"} +{"type":"step/end","seq":21,"time":0,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":22,"time":0,"data":{"turn":1,"step":2}} +{"type":"request/header","seq":23,"time":0,"data":{"header":{"config":{"provider":"cli-mock","model":"cli-mock","reasoningEffort":"off"},"system":"{{system}}","tools":"{{tools}}"},"reason":"change"}} +{"type":"assistant/chunk","seq":24,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"CLI tool round trip complete: CLI_TOOL_ROUND_TRIP"}}} +{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CLI tool round trip complete: CLI_TOOL_ROUND_TRIP"}}}} +{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":7,"outputTokens":5,"reasoningTokens":1}}}} +{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":29,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"CLI tool round trip complete: CLI_TOOL_ROUND_TRIP"}],"source":{"kind":"model","provider":"cli-mock","model":"cli-mock"},"id":"{{sessionId}}"},"usage":{"inputTokens":7,"outputTokens":5,"reasoningTokens":1}},"sourceEventSeqs":[24,25,26,27,28],"surfaceOp":"append"} +{"type":"step/end","seq":30,"time":0,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":31,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl index 16a690dd88..0870c833f1 100644 --- a/examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl +++ b/examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl @@ -6,7 +6,7 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":5,"time":0,"data":{"title":"say pong","messageSeqs":[4],"source":{"kind":"fallback"}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":6,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":7,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":1000000}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"llm-deepseek: no API key for provider route \"deepseek-official\"; store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it), export DEEPSEEK_API_KEY in the launching environment, or — as a last resort — set a literal \"apiKey\" in the llm-deepseek settings section","code":"MISSING_CREDENTIAL"}}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"llm-deepseek: no API key for provider route \"deepseek-official\"; store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it), or export DEEPSEEK_API_KEY in the launching environment","code":"MISSING_CREDENTIAL"}}}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":9,"time":0,"data":{"turn":1,"step":1}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":10,"time":0,"data":{"turn":1,"reason":{"kind":"error","error":{"message":"llm-deepseek: no API key for provider route \"deepseek-official\"; store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it), export DEEPSEEK_API_KEY in the launching environment, or — as a last resort — set a literal \"apiKey\" in the llm-deepseek settings section","code":"MISSING_CREDENTIAL"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":10,"time":0,"data":{"turn":1,"reason":{"kind":"error","error":{"message":"llm-deepseek: no API key for provider route \"deepseek-official\"; store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it), or export DEEPSEEK_API_KEY in the launching environment","code":"MISSING_CREDENTIAL"}}}}} {"type":"result","sessionId":"{{sessionId}}","output":""} diff --git a/examples/headless-agent/tests/snapshots/startup-activation-error/stderr.expected.txt b/examples/headless-agent/tests/snapshots/startup-activation-error/stderr.expected.txt index cd688cd471..f3252180b9 100644 --- a/examples/headless-agent/tests/snapshots/startup-activation-error/stderr.expected.txt +++ b/examples/headless-agent/tests/snapshots/startup-activation-error/stderr.expected.txt @@ -1,3 +1,3 @@ -dsh-cli-demo: dsh-cli-demo: plugin tree failed to load: failed to apply loader entry include (cordis:include): failed to apply loader entry activation-error (./activation-error.mjs): startup activation snapshot failure +headless-test-driver: plugin tree failed to load: failed to apply loader entry include (cordis:include): failed to apply loader entry activation-error (./activation-error.mjs): startup activation snapshot failure Error: startup activation snapshot failure at activation-error-fixture diff --git a/examples/headless-agent/tests/subagent-diagnostic.snapshot.ts b/examples/headless-agent/tests/subagent-diagnostic.snapshot.ts index dc978b37f6..998c9129a5 100644 --- a/examples/headless-agent/tests/subagent-diagnostic.snapshot.ts +++ b/examples/headless-agent/tests/subagent-diagnostic.snapshot.ts @@ -19,7 +19,7 @@ const fixtureDir = fileURLToPath(new URL('./subagent-diagnostic-snapshots/descri const replayOverride = join(fixtureDir, 'replay.override.json') const parentExpected = join(fixtureDir, 'parent.expected.jsonl') const configPath = fileURLToPath(new URL('../subagent-diagnostic.cordis.snapshot.yml', import.meta.url)) -const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url)) +const binScript = fileURLToPath(new URL('./fixtures/headless-driver.ts', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) const parentId = SessionId('subagent-diagnostic-parent') const childId = SessionId('subagent-diagnostic-child') @@ -77,8 +77,9 @@ describe('descriptor-less cold child diagnostic snapshot', () => { label: 'subagent diagnostic headless stream-json snapshot', tempDirPrefix: 'dsh-subagent-diag-', binScript, + libBinScript: binScript, configPath, - binArgs: ['--config', configPath, '--output-format', 'stream-json', task], + binArgs: [configPath, task], tsconfigPath, env: { DSH_SNAPSHOT_FILE: replayOverride, diff --git a/examples/headless-agent/tests/subagent-inheritance.snapshot.ts b/examples/headless-agent/tests/subagent-inheritance.snapshot.ts index e5f6994d0c..be5be2b357 100644 --- a/examples/headless-agent/tests/subagent-inheritance.snapshot.ts +++ b/examples/headless-agent/tests/subagent-inheritance.snapshot.ts @@ -20,7 +20,7 @@ const childReplay = join(fixtureDir, 'child.replay.jsonl') const parentExpected = join(fixtureDir, 'parent.expected.jsonl') const childExpected = join(fixtureDir, 'child.expected.jsonl') const configPath = fileURLToPath(new URL('../subagent-inheritance.cordis.snapshot.yml', import.meta.url)) -const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url)) +const binScript = fileURLToPath(new URL('./fixtures/headless-driver.ts', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) const sessionId = SessionId('subagent-inheritance-parent') const refreshing = process.env.DSH_SNAPSHOT === 'refresh' @@ -59,8 +59,9 @@ describe('parent-only override inheritance snapshot', () => { label: 'subagent inheritance headless stream-json snapshot', tempDirPrefix: 'dsh-subagent-inherit-', binScript, + libBinScript: binScript, configPath, - binArgs: ['--config', configPath, '--output-format', 'stream-json', task], + binArgs: [configPath, task], tsconfigPath, env: { // The primary fixture path must exist for llm-replay's config guard; diff --git a/examples/headless-agent/tests/workspace-context-resume.snapshot.ts b/examples/headless-agent/tests/workspace-context-resume.snapshot.ts index 789a859746..afe2ba325a 100644 --- a/examples/headless-agent/tests/workspace-context-resume.snapshot.ts +++ b/examples/headless-agent/tests/workspace-context-resume.snapshot.ts @@ -28,7 +28,7 @@ const replayOverride = join(fixtureDir, 'replay.override.json') const sessionExpected = join(fixtureDir, 'session.expected.jsonl') const precedenceExpected = join(dirname(fixtureDir), 'precedence-change/session.expected.jsonl') const configPath = fileURLToPath(new URL('../workspace-context-resume.cordis.snapshot.yml', import.meta.url)) -const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url)) +const binScript = fileURLToPath(new URL('./fixtures/headless-driver.ts', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) const sessionId = SessionId('workspace-context-resume') const refreshing = process.env.DSH_SNAPSHOT === 'refresh' @@ -119,8 +119,9 @@ describe('workspace-context resume snapshot', () => { label: 'workspace-context resume headless stream-json snapshot', tempDirPrefix: 'dsh-workspace-context-resume-', binScript, + libBinScript: binScript, configPath, - binArgs: ['--config', configPath, '--output-format', 'stream-json', 'Acknowledge the current workspace instruction.'], + binArgs: [configPath, 'Acknowledge the current workspace instruction.'], tsconfigPath, env: { DSH_SNAPSHOT_FILE: replayFixture, @@ -175,8 +176,9 @@ describe('workspace-context resume snapshot', () => { label: 'workspace-context precedence-change resume snapshot', tempDirPrefix: 'dsh-workspace-context-precedence-', binScript, + libBinScript: binScript, configPath, - binArgs: ['--config', configPath, '--output-format', 'stream-json', 'Acknowledge the current workspace instruction.'], + binArgs: [configPath, 'Acknowledge the current workspace instruction.'], tsconfigPath, env: { DSH_SNAPSHOT_FILE: replayFixture, diff --git a/examples/jsonrpc-agent/cordis.yml b/examples/jsonrpc-agent/cordis.yml index 2c0890a825..39f228b1bc 100644 --- a/examples/jsonrpc-agent/cordis.yml +++ b/examples/jsonrpc-agent/cordis.yml @@ -12,8 +12,6 @@ - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL thinking: enabled reasoningEffort: max diff --git a/examples/jsonrpc-agent/persistent-tools.cordis.yml b/examples/jsonrpc-agent/persistent-tools.cordis.yml index b5ae81b100..6f42441ca7 100644 --- a/examples/jsonrpc-agent/persistent-tools.cordis.yml +++ b/examples/jsonrpc-agent/persistent-tools.cordis.yml @@ -8,8 +8,6 @@ - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL - id: sandbox name: '@deepseek-ai/dsh-sandbox-local' diff --git a/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/cordis.yml b/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/cordis.yml index 817196414c..9b0a7c7a36 100644 --- a/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/cordis.yml +++ b/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/cordis.yml @@ -32,12 +32,22 @@ # recursion budget, so the local numeric default cannot apply here. maxDepth: 'provider-managed' -- id: cli-agent - name: '@deepseek-ai/dsh-cli-demo' +- id: agent-spine + name: '@deepseek-ai/dsh-agent-spine-demo' config: - provider: mock - model: mock-delegate + agents: + - id: main + provider: mock + model: mock-delegate + cwd: !!js process.cwd() persona: 'Test SDK subagent cwd inheritance.' - persistenceRoot: './.sessions' - persistenceCompression: 'none' workspaceContext: false + +- id: persistence + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: './.sessions' + compression: 'none' + +- id: checkpoint-policy + name: '@deepseek-ai/dsh-session-checkpoint-policy' diff --git a/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/driver.ts b/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/driver.ts index 34412c1839..7ed378b8b5 100644 --- a/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/driver.ts +++ b/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/driver.ts @@ -2,14 +2,14 @@ /** Test driver: one delegation turn through a headless Loader composition. */ import { boot, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' -import { runOneShot } from '@deepseek-ai/dsh-cli-demo/src/cli.ts' +import { runFixtureTurn } from '@deepseek-ai/dsh-loader-smoke' const configPath = process.argv[2] if (configPath === undefined) throw new Error('sdk-subagent cwd driver requires a config path') const ctx = await boot('sdk-subagent-cwd-e2e', resolveConfigPath(configPath, undefined)) try { - await runOneShot(ctx, { task: 'delegate' }) + await runFixtureTurn(ctx, { task: 'delegate' }) } finally { await ctx.fiber.dispose() } diff --git a/examples/jsonrpc-agent/tests/sdk.snapshot.ts b/examples/jsonrpc-agent/tests/sdk.snapshot.ts index 0bef713571..4a29d6eb6a 100644 --- a/examples/jsonrpc-agent/tests/sdk.snapshot.ts +++ b/examples/jsonrpc-agent/tests/sdk.snapshot.ts @@ -9,6 +9,7 @@ * fixtures and rewrites expected outputs. */ +import { existsSync } from 'node:fs' import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { basename, delimiter, join } from 'node:path' @@ -19,6 +20,7 @@ import { normalizeStdout, refreshFixtureReplacements, scrubRequestHeaders, + stabilizeFixtureMessageIds, stabilizeRefreshLog, tokenizeSessionFixtureCwd, type HarvestedLog, @@ -319,20 +321,25 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => { const { result, notifications, logs, observedFiles, cwd } = await runScenario(scenario) const ordered = orderLogs(logs, scenario) const actualContext = contextOf(ordered, cwd) + const files = fixtureFiles(scenario) if (recording) { // Fixtures carry tokenized request headers; llm-replay reads only // assistant output and tool traffic, so scrubbing keeps prompts and // schemas out of the corpus without affecting replay. await mkdir(scenarioDir, { recursive: true }) - await Promise.all(ordered.map(async (log, index) => { - const file = fixtureFiles(scenario)[index] + const existing = await Promise.all(files.map(async file => existsSync(file) ? readFile(file, 'utf8') : '')) + const fixtures = stabilizeFixtureMessageIds( + ordered.map(log => scrubRequestHeaders(tokenizeSessionFixtureCwd(log.content))), + existing, + ) + await Promise.all(fixtures.map(async (fixture, index) => { + const file = files[index] if (file === undefined) throw new Error(`no fixture path for persisted log ${index}`) - await writeFile(file, scrubRequestHeaders(tokenizeSessionFixtureCwd(log.content))) + await writeFile(file, fixture) })) } - const files = fixtureFiles(scenario) let expectedContents = await Promise.all(files.map(file => readFile(file, 'utf8'))) if (refreshing) { @@ -343,15 +350,18 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => { content: log.content, })) const replacements = refreshFixtureReplacements(harvested, expectedContents) - expectedContents = await Promise.all(ordered.map(async (log, index) => { + const refreshed = ordered.map((log, index) => { const existing = expectedContents[index] - const file = files[index] - if (existing === undefined || file === undefined) throw new Error(`no fixture for persisted log ${index}`) - const stable = scrubRequestHeaders(tokenizeSessionFixtureCwd( + if (existing === undefined) throw new Error(`no fixture for persisted log ${index}`) + return scrubRequestHeaders(tokenizeSessionFixtureCwd( stabilizeRefreshLog(log.content, existing, replacements, actualContext), )) + }) + expectedContents = stabilizeFixtureMessageIds(refreshed, expectedContents) + await Promise.all(expectedContents.map(async (stable, index) => { + const file = files[index] + if (file === undefined) throw new Error(`no fixture for persisted log ${index}`) await writeFile(file, stable) - return stable })) } diff --git a/examples/mcp-memory/README.i18n.yaml b/examples/mcp-memory/README.i18n.yaml index f89035cfcc..7e8e5248e4 100644 --- a/examples/mcp-memory/README.i18n.yaml +++ b/examples/mcp-memory/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write examples/mcp-memory/README.md -README.md: 023e6aefce0e78cbbf52620426376e1dd0a6b8cf -README.zh.md: 44ace680cd583f41903437a69c62e30817308ba2 +README.md: 792bb31b668b427c8734286878a9ec98071190d8 +README.zh.md: 51020f5288c4fbd245914280b8e7e4772e8cad69 diff --git a/examples/mcp-memory/README.md b/examples/mcp-memory/README.md index 023e6aefce..792bb31b66 100644 --- a/examples/mcp-memory/README.md +++ b/examples/mcp-memory/README.md @@ -36,7 +36,7 @@ Without a repository checkout, download the selected overlay directly: mkdir -p "${DSH_HOME:-$HOME/.dsh}" curl --fail --location \ --output "${DSH_HOME:-$HOME/.dsh}/memory.cordis.yml" \ - https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/examples/mcp-memory/memorix.cordis.yml + https://raw.githubusercontent.com/deepseek-ai/deepseek-harness-sdk/master/examples/mcp-memory/memorix.cordis.yml dsh web --patch "${DSH_HOME:-$HOME/.dsh}/memory.cordis.yml" ``` diff --git a/examples/mcp-memory/README.zh.md b/examples/mcp-memory/README.zh.md index 44ace680cd..51020f5288 100644 --- a/examples/mcp-memory/README.zh.md +++ b/examples/mcp-memory/README.zh.md @@ -36,7 +36,7 @@ dsh web --patch "$PWD/examples/mcp-memory/memorix.cordis.yml" mkdir -p "${DSH_HOME:-$HOME/.dsh}" curl --fail --location \ --output "${DSH_HOME:-$HOME/.dsh}/memory.cordis.yml" \ - https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/examples/mcp-memory/memorix.cordis.yml + https://raw.githubusercontent.com/deepseek-ai/deepseek-harness-sdk/master/examples/mcp-memory/memorix.cordis.yml dsh web --patch "${DSH_HOME:-$HOME/.dsh}/memory.cordis.yml" ``` diff --git a/examples/package.json b/examples/package.json index dea5928576..1f6e99d240 100644 --- a/examples/package.json +++ b/examples/package.json @@ -18,7 +18,6 @@ "@deepseek-ai/dsh-bash-env": "workspace:*", "@deepseek-ai/dsh-bash-local": "workspace:*", "@deepseek-ai/dsh-bash-sandbox": "workspace:*", - "@deepseek-ai/dsh-cli-demo": "workspace:*", "@deepseek-ai/dsh-code-runtime-worker": "workspace:*", "@deepseek-ai/dsh-command-feedback": "workspace:*", "@deepseek-ai/dsh-command-goal": "workspace:*", @@ -40,6 +39,7 @@ "@deepseek-ai/dsh-llm-deepseek": "workspace:*", "@deepseek-ai/dsh-llm-pi-ai": "workspace:*", "@deepseek-ai/dsh-llm-replay": "workspace:*", + "@deepseek-ai/dsh-loader-smoke": "workspace:*", "@deepseek-ai/dsh-lsp": "workspace:*", "@deepseek-ai/dsh-lsp-local": "workspace:*", "@deepseek-ai/dsh-permission": "workspace:*", diff --git a/knip.json b/knip.json index 3d7836104f..ede8b2c738 100644 --- a/knip.json +++ b/knip.json @@ -5,6 +5,7 @@ ], "ignoreBinaries": [ "bwrap", + "musl-gcc", "python3", "sandbox-exec", "taskkill" @@ -45,6 +46,7 @@ "headless-agent/tests/fixtures/telemetry-otel-driver.ts", "headless-agent/tests/fixtures/telemetry-redact-rule.ts", "acp-agent/tests/snapshots/lsp-definition/workspace/subject.ts", + "acp-agent/tests/fixtures/child-question-tripwire.ts", "acp-agent/tests/fixtures/partial-landlock-sandbox.ts", "acp-agent/tests/fixtures/subagent-durability-failure.ts", "acp-agent/tests/fixtures/subagent-settlement-marker.ts", @@ -519,16 +521,6 @@ "tests/**/*.ts" ] }, - "packages/examples/cli-demo": { - "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, "packages/examples/jsonrpc-demo": { "project": [ "src/**/*.ts" @@ -689,7 +681,8 @@ "apps/cli": { "entry": [ "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" + "tests/**/*.e2e.ts", + "tests/**/*.snapshot.ts" ], "project": [ "src/**/*.ts", diff --git a/lefthook.yml b/lefthook.yml index bdab57a9a3..c2249adbde 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -15,7 +15,7 @@ pre-commit: glob: '*.{ts,tsx,mts,cts,mjs}' exclude: - 'vendor/*/src/**' - run: node_modules/.bin/tsx scripts/run-oxlint.ts --fix --no-error-on-unmatched-pattern {staged_files} + run: node_modules/.bin/tsx scripts/run-oxlint.ts --config .oxlintrc.staged.json --fix --no-error-on-unmatched-pattern {staged_files} stage_fixed: true # Regenerate rather than reject: a dependency edit that forgot the notices @@ -37,4 +37,4 @@ pre-commit: pre-push: jobs: - name: typecheck - run: node_modules/.bin/tsc -b --pretty false + run: pnpm run typecheck diff --git a/native/README.i18n.yaml b/native/README.i18n.yaml index a55273be29..31a86ebe73 100644 --- a/native/README.i18n.yaml +++ b/native/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write native/README.md -README.md: a79d9ca5747d4c4fbfa50745b3eece07b96aea58 -README.zh.md: 708430c0a087e5a9da1dae6fd078cb477db65d9b +README.md: 51c8da7b57df15e65b8e431ee18ce6cebb89d54b +README.zh.md: baf530157c6731893a66aea301c6dd962592defc diff --git a/native/README.md b/native/README.md index a79d9ca574..51c8da7b57 100644 --- a/native/README.md +++ b/native/README.md @@ -2,12 +2,10 @@ English | [中文](README.zh.md) -Source of record for `node-addon-landlock-run`, the Landlock self-restrict-then-exec launcher consumed by the harness. The [`landlock-run/` workspace](landlock-run/README.md) owns its architecture, package family, platform support, development workflow, and release procedure. The standalone repository is a release mirror. +Native source and public packages maintained with DeepSeek Harness. The [`landlock-run/` workspace](landlock-run/README.md) owns the Landlock self-restrict-then-exec launcher consumed by the harness, including its architecture, three-package npm family, platform support, development workflow, and [release procedure](landlock-run/docs/release.md). -## Release mirror +## Workspace and release boundary -| Directory | Mirror repo | Last exported release | Commit | -|---|---|---|---| -| `landlock-run/` | https://github.com/deepseek-harness/node-addon-landlock-run | `v0.0.1` | `614f7fd7dc11e6eaceefba9e7ff1fbe28b51ba22` | +`landlock-run/` and its packages belong to the repository's root pnpm workspace and lockfile. Harness consumers use the current workspace entry package during development and CI, so a launcher contract change and its consumer update can land and be tested together. -The subtree is a self-contained pnpm workspace and is not part of the harness workspace. The [launcher release reference](landlock-run/docs/release.md) owns the export and publication workflow. The mirror must not diverge: port any direct mirror hotfix back here before the next export. +The main repository's `Landlock Run` workflow builds and tests each supported architecture. `Landlock Run Release` assembles those native artifacts, packs and verifies the three npm tarballs, then optionally publishes them under one launcher version. The entry package retains platform packages as npm optional dependencies, so npm still installs only the package matching the user's operating system and CPU. diff --git a/native/README.zh.md b/native/README.zh.md index 708430c0a0..baf530157c 100644 --- a/native/README.zh.md +++ b/native/README.zh.md @@ -2,12 +2,10 @@ [English](README.md) | 中文 -`node-addon-landlock-run` 的真源;它是供 harness 使用、先施加 Landlock 自限再执行命令的启动器。[`landlock-run/` workspace](landlock-run/README.md)负责其架构、包家族、平台支持、开发工作流和发布流程。独立仓库是发布镜像。 +与 DeepSeek Harness 一同维护的原生源码和公开包。[`landlock-run/` workspace](landlock-run/README.md)负责 harness 使用的 Landlock 自限后执行启动器,包括其架构、由三个包组成的 npm 包家族、平台支持、开发工作流和[发布流程](landlock-run/docs/release.md)。 -## 发布镜像 +## Workspace 与发布边界 -| 目录 | 镜像仓库 | 最近导出的版本 | Commit | -|---|---|---|---| -| `landlock-run/` | https://github.com/deepseek-harness/node-addon-landlock-run | `v0.0.1` | `614f7fd7dc11e6eaceefba9e7ff1fbe28b51ba22` | +`landlock-run/` 及其包属于仓库根 pnpm workspace,并共用根锁文件。开发和 CI 中的 harness 消费方直接使用当前 workspace 的入口包,因此启动器契约变更与消费方更新可以在同一个改动中落地并一起测试。 -该子树是自包含的 pnpm workspace,不属于 harness workspace。[启动器发布参考](landlock-run/docs/release.md)负责导出和发布工作流。镜像不得发生分歧:下次导出前,必须把任何直接施加于镜像的热修复移植回此处。 +主仓库的 `Landlock Run` 工作流为每个受支持架构构建并测试。`Landlock Run Release` 汇集这些原生产物,打包并验证三个 npm tarball,随后可选择以同一个启动器版本发布。入口包继续将平台包声明为 npm 可选依赖,因此 npm 仍然只会安装与用户操作系统和 CPU 匹配的包。 diff --git a/native/landlock-run/AGENTS.md b/native/landlock-run/AGENTS.md index 31e12e177c..6742ff81d4 100644 --- a/native/landlock-run/AGENTS.md +++ b/native/landlock-run/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md -This workspace builds `landlock-run`, a Landlock self-restrict-then-exec launcher: a small, auditable confinement binary distributed as prebuilt per-platform npm packages, plus the thin JS entry package that resolves it and speaks its CLI contract. The source of record is the `deepseek-harness` repository's `native/landlock-run/`; the `node-addon-landlock-run` repository is the release mirror this tree is exported to for packing and publishing (procedure: `native/README.md` in the harness repo). Make changes in the source of record, never only in the mirror. +This directory builds `landlock-run`, a Landlock self-restrict-then-exec launcher: a small, auditable confinement binary distributed as prebuilt per-platform npm packages, plus the thin JS entry package that resolves it and speaks its CLI contract. It belongs to the repository's root pnpm workspace and lockfile. The main repository owns native CI, tarball assembly, verification, and npm publication; keep package-family changes coordinated with harness consumers in the same repository. ## Pre-release stance diff --git a/native/landlock-run/README.i18n.yaml b/native/landlock-run/README.i18n.yaml index bdcf985216..c204d571cf 100644 --- a/native/landlock-run/README.i18n.yaml +++ b/native/landlock-run/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write native/landlock-run/README.md -README.md: 19cc18830b90609f648cfb2ce1ee509ad9fe381b -README.zh.md: 5d3c1c2cd692bb87d5a759a0a6f9628a3f065863 +README.md: fcb8e8249e6728d925fd08c938a780b155d3d4ac +README.zh.md: 8206ab8074d8d80fb9b11cff436bbd10e6dc34dd diff --git a/native/landlock-run/README.md b/native/landlock-run/README.md index 19cc18830b..fcb8e8249e 100644 --- a/native/landlock-run/README.md +++ b/native/landlock-run/README.md @@ -1,4 +1,4 @@ -# node-addon-landlock-run +# @deepseek-ai/node-addon-landlock-run English | [中文](README.zh.md) @@ -9,15 +9,15 @@ The first tool is **`landlock-run`** — a self-restrict-then-exec [Landlock](ht ## Install ```sh -npm install node-addon-landlock-run +npm install @deepseek-ai/node-addon-landlock-run ``` Published packages use an entry package plus platform optional packages: ```text -node-addon-landlock-run -node-addon-landlock-run-linux-x64 -node-addon-landlock-run-linux-arm64 +@deepseek-ai/node-addon-landlock-run +@deepseek-ai/node-addon-landlock-run-linux-x64 +@deepseek-ai/node-addon-landlock-run-linux-arm64 ``` npm's `os`/`cpu` fields make installers fetch only the matching platform package. There is no install-time build fallback on purpose: on a host without a platform package the resolved path never exists, the probe reports `unusable`, and the consumer falls closed. @@ -25,7 +25,7 @@ npm's `os`/`cpu` fields make installers fetch only the matching platform package ## Usage ```js -import { grantArgs, launcherPath, probe } from 'node-addon-landlock-run'; +import { grantArgs, launcherPath, probe } from '@deepseek-ai/node-addon-landlock-run'; const launcher = launcherPath(); if (probe(launcher) !== 'unusable') { diff --git a/native/landlock-run/README.zh.md b/native/landlock-run/README.zh.md index 5d3c1c2cd6..8206ab8074 100644 --- a/native/landlock-run/README.zh.md +++ b/native/landlock-run/README.zh.md @@ -1,4 +1,4 @@ -# node-addon-landlock-run +# @deepseek-ai/node-addon-landlock-run [English](README.md) | 中文 @@ -9,15 +9,15 @@ ## 安装 ```sh -npm install node-addon-landlock-run +npm install @deepseek-ai/node-addon-landlock-run ``` 已发布包由一个入口包和可选平台包组成: ```text -node-addon-landlock-run -node-addon-landlock-run-linux-x64 -node-addon-landlock-run-linux-arm64 +@deepseek-ai/node-addon-landlock-run +@deepseek-ai/node-addon-landlock-run-linux-x64 +@deepseek-ai/node-addon-landlock-run-linux-arm64 ``` npm 的 `os`/`cpu` 字段使安装器只拉取匹配的平台包。系统有意不提供安装时构建回退:在没有对应平台包的宿主上,解析后的路径绝不存在,探测会报告 `unusable`,消费方以失败闭合方式处理。 @@ -25,7 +25,7 @@ npm 的 `os`/`cpu` 字段使安装器只拉取匹配的平台包。系统有意 ## 用法 ```js -import { grantArgs, launcherPath, probe } from 'node-addon-landlock-run'; +import { grantArgs, launcherPath, probe } from '@deepseek-ai/node-addon-landlock-run'; const launcher = launcherPath(); if (probe(launcher) !== 'unusable') { diff --git a/native/landlock-run/docs/architecture.md b/native/landlock-run/docs/architecture.md index e6974f4e50..b462b40635 100644 --- a/native/landlock-run/docs/architecture.md +++ b/native/landlock-run/docs/architecture.md @@ -6,8 +6,8 @@ This repository owns confinement *mechanism*, not policy: consumers (agent harne The family is one entry package plus per-platform binary packages: -- **Entry package** (`node-addon-landlock-run`): ESM JavaScript. Owns the tool's CLI contract — path resolution (`launcherPath`), the functional probe (`probe`), grant-argv construction (`grantArgs`), and the contract constants. Ships the C source in its tarball for auditability. Lists every platform package as an `optionalDependency`. -- **Platform packages** (`node-addon-landlock-run-linux-{x64,arm64}`): one prebuilt static binary under `bin/`, a `prebuilds.json` declaring it, and no JavaScript at all. npm's `os`/`cpu` fields select the matching one at install time; the entry package resolves it to a file path — there is nothing to import. +- **Entry package** (`@deepseek-ai/node-addon-landlock-run`): ESM JavaScript. Owns the tool's CLI contract — path resolution (`launcherPath`), the functional probe (`probe`), grant-argv construction (`grantArgs`), and the contract constants. Ships the C source in its tarball for auditability. Lists every platform package as an `optionalDependency`. +- **Platform packages** (`@deepseek-ai/node-addon-landlock-run-linux-{x64,arm64}`): one prebuilt static binary under `bin/`, a `prebuilds.json` declaring it, and no JavaScript at all. npm's `os`/`cpu` fields select the matching one at install time; the entry package resolves it to a file path — there is nothing to import. Because the contract parser and the binary version together in one family, probe-parsing drift against the binary is structurally impossible — the failure mode the split exists to prevent. @@ -15,7 +15,7 @@ There is no shared loader package: platform packages have nothing to load. If a ## Resolution and availability -`launcherPath()` resolves `node-addon-landlock-run--` and returns `/bin/landlock-run`. When the package is not resolvable it returns a deterministic fallback path inside the entry package's own `node_modules` that simply never exists. Existence is deliberately unchecked either way: `probe()` is the single availability signal, and a missing binary probes `unusable` exactly like an unenforcing kernel. Consumers get one degradation path, not two. +`launcherPath()` resolves `@deepseek-ai/node-addon-landlock-run--` and returns `/bin/landlock-run`. When the package is not resolvable it returns a deterministic fallback path inside the entry package's own `node_modules` that simply never exists. Existence is deliberately unchecked either way: `probe()` is the single availability signal, and a missing binary probes `unusable` exactly like an unenforcing kernel. Consumers get one degradation path, not two. The probe is functional — the launcher builds and enforces a real maximal ruleset in a short-lived child — because version checks would miss a kernel that has the syscalls but refuses enforcement. diff --git a/native/landlock-run/docs/naming.md b/native/landlock-run/docs/naming.md index 9de9f0b95f..a1f2665654 100644 --- a/native/landlock-run/docs/naming.md +++ b/native/landlock-run/docs/naming.md @@ -2,11 +2,11 @@ ## npm packages -The public package family is unscoped, using the `node-addon-landlock-run` package prefix; platform packages append platform information only: +The public package family belongs to the `@deepseek-ai` scope and uses the `node-addon-landlock-run` package prefix; platform packages append platform information only: ```text -node-addon-landlock-run -node-addon-landlock-run- +@deepseek-ai/node-addon-landlock-run +@deepseek-ai/node-addon-landlock-run- ``` Platform suffixes carry no libc component (binaries are static musl) and no variant component — variants stay inside `prebuilds.json` and binary filenames. diff --git a/native/landlock-run/docs/packaging.md b/native/landlock-run/docs/packaging.md index 9a1be47b2a..ec459eb655 100644 --- a/native/landlock-run/docs/packaging.md +++ b/native/landlock-run/docs/packaging.md @@ -5,9 +5,9 @@ The package family uses the same broad shape as native packages such as esbuild: ## Published packages ```text -node-addon-landlock-run -node-addon-landlock-run-linux-x64 -node-addon-landlock-run-linux-arm64 +@deepseek-ai/node-addon-landlock-run +@deepseek-ai/node-addon-landlock-run-linux-x64 +@deepseek-ai/node-addon-landlock-run-linux-arm64 ``` Unsupported platforms are intentionally absent from `optionalDependencies` — see [support-matrix.md](support-matrix.md). diff --git a/native/landlock-run/docs/release.md b/native/landlock-run/docs/release.md index e1ea65c411..d5eec50b6e 100644 --- a/native/landlock-run/docs/release.md +++ b/native/landlock-run/docs/release.md @@ -4,55 +4,57 @@ Pre-1.0: treat this as a release checklist, not a stability policy. ## Versioning -One version across every package in the repo. Use the bump helper: +The launcher workspace root and its three public packages share one version. Run the bump helper from the repository root: ```sh -pnpm release:bump patch # or minor / major / x.y.z +pnpm --dir native/landlock-run release:bump patch # or minor / major / x.y.z ``` -It updates the root and every `packages/*` manifest, refreshes the lockfile (`--ignore-scripts --lockfile-only`), and runs `release:verify`. Explicit versions accept full semver including prereleases (`pnpm release:bump 0.0.0-test.0`); the publish workflow puts prerelease versions under the `next` dist-tag, so `latest` never points at a test build. Keep `workspace:*` dependencies in source; pnpm converts them to concrete versions during pack. +It updates `native/landlock-run/package.json` and every `native/landlock-run/packages/*` manifest, refreshes the repository root lockfile (`--ignore-scripts --lockfile-only`), and runs `release:verify`. Explicit versions accept full semver including prereleases (`pnpm --dir native/landlock-run release:bump 0.0.0-test.0`); the publish workflow puts prerelease versions under the `next` dist-tag, so `latest` never points at a test build. Keep `workspace:*` dependencies in source; pnpm converts them to concrete versions during pack. -Version bumps are normal source changes: open a release PR (or commit) with the manifests and lockfile, merge it, then create the matching `vX.Y.Z` tag from that commit. The publish workflow validates that the tag matches every package version. +Version bumps are normal source changes: open a release PR (or commit) with the launcher manifests and root lockfile, merge it, then create the matching `landlock-run-vX.Y.Z` tag from that commit. The namespace avoids colliding with release tags for other package families in the repository. The publish workflow validates that the tag matches every launcher package version. ```sh -pnpm release:commit patch # bump + stage + commit in one command -git tag v0.0.2 +pnpm --dir native/landlock-run release:commit patch # bump + stage + commit in one command +git tag landlock-run-v0.0.2 ``` ## Preflight ```sh pnpm install --frozen-lockfile -pnpm build:ts -pnpm typecheck -pnpm test:entry +pnpm --dir native/landlock-run build:ts +pnpm --dir native/landlock-run typecheck +pnpm --dir native/landlock-run test:entry ``` On a Linux host, also rehearse the pack path locally: ```sh -pnpm build:native -pnpm test:launcher -node ./scripts/pack-release.mjs .release/npm --current-platform-only -node ./scripts/verify-packed-install.mjs .release/npm --current-platform-only +pnpm --dir native/landlock-run build:native +pnpm --dir native/landlock-run test:launcher +node native/landlock-run/scripts/pack-release.mjs native/landlock-run/.release/npm --current-platform-only +node native/landlock-run/scripts/verify-packed-install.mjs native/landlock-run/.release/npm --current-platform-only ``` ## Publish -Use the `Release` workflow so every binary is built on its matching native runner: +Use the main repository's `Landlock Run Release` workflow so every binary is built on its matching native runner: 1. Run it with `publish=false` (from the release commit) to build all platform binaries, assemble and verify the payloads, pack the tarballs in publish order, rehearse the packed install, and upload the `npm-tarballs` artifact for inspection. -2. Create and push the `vX.Y.Z` tag matching the package versions. +2. Create and push the `landlock-run-vX.Y.Z` tag matching the package versions. 3. Run the same workflow from that tag with `publish=true`. -The workflow publishes only from the final packed tarballs, in `publish-order.txt` order (platform packages before the entry that optionally depends on them). It supports npm trusted publishing through GitHub OIDC; without it, provide an `NPM_TOKEN` secret in the `npm-publish` environment. Packages publish with `--access public`. +The workflow publishes only from the final packed tarballs, in `publish-order.txt` order (platform packages before the entry that optionally depends on them). A current-platform rehearsal can still query npm for metadata about an incompatible optional platform package; that package cannot supply the host launcher, which comes from the matching local tarball. Publishing every platform package before the entry ensures a public entry version never points ahead of its platform packages. The workflow supports npm trusted publishing through GitHub OIDC; without it, provide an `NPM_TOKEN` secret in the `npm-publish` environment. Packages publish with `--access public`. + +The three scoped package names must be bootstrapped with an `@deepseek-ai` organization token through the `NPM_TOKEN` fallback: npm [requires a package to exist before a trusted publisher can be configured](https://docs.npmjs.com/cli/v11/commands/npm-trust/). After the first release creates all three packages, configure each package to trust `landlock-run-release.yml` in this repository with the `npm-publish` environment, then remove the fallback token when organization policy permits it. Manual local fallback (current platform's packages only) — always through `pack-release.mjs`, never `pnpm publish` directly (pnpm's pack path strips the launcher's executable bit; see [packaging.md](packaging.md)): ```sh -node ./scripts/pack-release.mjs dist/npm --current-platform-only -node ./scripts/verify-packed-install.mjs dist/npm --current-platform-only -while IFS= read -r tarball; do npm publish "dist/npm/${tarball}" --access public; done < dist/npm/publish-order.txt +node native/landlock-run/scripts/pack-release.mjs native/landlock-run/dist/npm --current-platform-only +node native/landlock-run/scripts/verify-packed-install.mjs native/landlock-run/dist/npm --current-platform-only +while IFS= read -r tarball; do npm publish "native/landlock-run/dist/npm/${tarball}" --access public; done < native/landlock-run/dist/npm/publish-order.txt ``` Do not commit `.npmrc` files with tokens or registry overrides. diff --git a/native/landlock-run/docs/support-matrix.md b/native/landlock-run/docs/support-matrix.md index 96d02b3cf6..e60ad201c1 100644 --- a/native/landlock-run/docs/support-matrix.md +++ b/native/landlock-run/docs/support-matrix.md @@ -4,8 +4,8 @@ | Platform package | GitHub runner (builder of record) | Notes | |---|---|---| -| `node-addon-landlock-run-linux-x64` | `ubuntu-24.04` | static musl — glibc and musl distros alike | -| `node-addon-landlock-run-linux-arm64` | `ubuntu-24.04-arm` | static musl — glibc and musl distros alike | +| `@deepseek-ai/node-addon-landlock-run-linux-x64` | `ubuntu-24.04` | static musl — glibc and musl distros alike | +| `@deepseek-ai/node-addon-landlock-run-linux-arm64` | `ubuntu-24.04-arm` | static musl — glibc and musl distros alike | Enforcement additionally requires a kernel with Landlock enabled (5.13+). The negotiated ABI level decides the probe verdict: every access this build knows governed → `full`; an older ABI governing a subset → `partial` (still confined for everything it supports); Landlock absent or disabled → `unusable`, and the launcher refuses to run commands at all. The probe — not the kernel version — is the authority: a kernel built without Landlock, or with the LSM disabled, probes `unusable` regardless of its version. diff --git a/native/landlock-run/package.json b/native/landlock-run/package.json index 6fe3f7eff9..0fb9b3ddfb 100644 --- a/native/landlock-run/package.json +++ b/native/landlock-run/package.json @@ -1,5 +1,5 @@ { - "name": "node-addon-landlock-run-workspace", + "name": "@deepseek-ai/node-addon-landlock-run-workspace", "version": "0.0.1", "private": true, "type": "module", @@ -22,7 +22,7 @@ "release:verify-packed-install": "node ./scripts/verify-packed-install.mjs" }, "devDependencies": { - "node-addon-landlock-run": "workspace:*", + "@deepseek-ai/node-addon-landlock-run": "workspace:*", "@types/node": "^26.0.1", "tsx": "^4.20.6", "typescript": "^6.0.3" diff --git a/native/landlock-run/packages/entry/README.i18n.yaml b/native/landlock-run/packages/entry/README.i18n.yaml index 47d33e0d70..7c67f49670 100644 --- a/native/landlock-run/packages/entry/README.i18n.yaml +++ b/native/landlock-run/packages/entry/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write native/landlock-run/packages/entry/README.md -README.md: e402cdfe71c4eb81b977a21955fe3fff6bf55fd3 -README.zh.md: e4fcd33a256b51c815cdd1c6771be328bc46f138 +README.md: fff722428c5d213d9fcce0ee87a1d48cdc189884 +README.zh.md: f462fbe3cb0cb8d1d83b4d6b1d8e2f61e88ff69c diff --git a/native/landlock-run/packages/entry/README.md b/native/landlock-run/packages/entry/README.md index e402cdfe71..fff722428c 100644 --- a/native/landlock-run/packages/entry/README.md +++ b/native/landlock-run/packages/entry/README.md @@ -1,11 +1,11 @@ -# node-addon-landlock-run +# @deepseek-ai/node-addon-landlock-run English | [中文](README.zh.md) Landlock self-restrict-then-exec launcher for confining subprocesses on Linux: this entry package resolves the per-platform prebuilt binary, runs its functional enforcement probe, and builds its grant argv — consumers never spell launcher flags or parse launcher output themselves. ```js -import { grantArgs, launcherPath, probe } from 'node-addon-landlock-run'; +import { grantArgs, launcherPath, probe } from '@deepseek-ai/node-addon-landlock-run'; const launcher = launcherPath(); if (probe(launcher) !== 'unusable') { @@ -15,4 +15,4 @@ if (probe(launcher) !== 'unusable') { The launcher installs a Landlock ruleset on itself and `exec`s the wrapped command; the ruleset is inherited across `execve`, so the whole process tree runs confined. Everything not granted is denied, and launcher failures exit `125` without running the command — fail-closed, never fail-open. The binary contract is pinned in the repo's `docs/cli-contract.md`; the C source rides this tarball (`src/main.c`) for audit. -Platform packages (`os`/`cpu`-selected optional dependencies, no JavaScript inside): `node-addon-landlock-run-linux-x64`, `node-addon-landlock-run-linux-arm64`. On hosts without one, `launcherPath()` returns a deterministic nonexistent path and `probe()` reports `'unusable'` — there is deliberately no install-time compile fallback. +Platform packages (`os`/`cpu`-selected optional dependencies, no JavaScript inside): `@deepseek-ai/node-addon-landlock-run-linux-x64`, `@deepseek-ai/node-addon-landlock-run-linux-arm64`. On hosts without one, `launcherPath()` returns a deterministic nonexistent path and `probe()` reports `'unusable'` — there is deliberately no install-time compile fallback. diff --git a/native/landlock-run/packages/entry/README.zh.md b/native/landlock-run/packages/entry/README.zh.md index e4fcd33a25..f462fbe3cb 100644 --- a/native/landlock-run/packages/entry/README.zh.md +++ b/native/landlock-run/packages/entry/README.zh.md @@ -1,11 +1,11 @@ -# node-addon-landlock-run +# @deepseek-ai/node-addon-landlock-run [English](README.md) | 中文 用于在 Linux 上限制子进程的 Landlock「先限制自身、再执行」启动器:此入口包定位对应平台的预构建二进制文件,运行功能性强制执行探测,并构建其授权 argv。消费方无需自行拼写启动器标志或解析启动器输出。 ```js -import { grantArgs, launcherPath, probe } from 'node-addon-landlock-run'; +import { grantArgs, launcherPath, probe } from '@deepseek-ai/node-addon-landlock-run'; const launcher = launcherPath(); if (probe(launcher) !== 'unusable') { @@ -15,4 +15,4 @@ if (probe(launcher) !== 'unusable') { 启动器在自身上安装 Landlock 规则集,再 `exec` 被包装的命令;该规则集会跨 `execve` 继承,因此整个进程树都在限制下运行。未授予的一切都被拒绝;启动器失败时以 `125` 退出且不运行命令:采用失败闭合策略,绝不在失败时放行。二进制契约锁定在仓库的 `docs/cli-contract.md` 中;C 源码作为 `src/main.c` 随该 tarball 分发,便于审计。 -平台包(由 `os`/`cpu` 选择的可选依赖,内部不含 JavaScript):`node-addon-landlock-run-linux-x64`、`node-addon-landlock-run-linux-arm64`。在缺少对应包的宿主上,`launcherPath()` 返回一个固定但不存在的路径,`probe()` 报告 `'unusable'`;系统有意不提供安装时编译回退。 +平台包(由 `os`/`cpu` 选择的可选依赖,内部不含 JavaScript):`@deepseek-ai/node-addon-landlock-run-linux-x64`、`@deepseek-ai/node-addon-landlock-run-linux-arm64`。在缺少对应包的宿主上,`launcherPath()` 返回一个固定但不存在的路径,`probe()` 报告 `'unusable'`;系统有意不提供安装时编译回退。 diff --git a/native/landlock-run/packages/entry/package.json b/native/landlock-run/packages/entry/package.json index f05e81f06b..1614df5ad2 100644 --- a/native/landlock-run/packages/entry/package.json +++ b/native/landlock-run/packages/entry/package.json @@ -1,8 +1,13 @@ { - "name": "node-addon-landlock-run", + "name": "@deepseek-ai/node-addon-landlock-run", "version": "0.0.1", "type": "module", "description": "Landlock self-restrict-then-exec launcher for sandboxing subprocesses on Linux: per-platform prebuilt static binaries plus the JS seam that resolves, probes, and speaks their CLI contract", + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-harness/deepseek-harness.git", + "directory": "native/landlock-run/packages/entry" + }, "main": "lib/index.js", "types": "lib/index.d.ts", "exports": { @@ -30,7 +35,7 @@ "access": "public" }, "optionalDependencies": { - "node-addon-landlock-run-linux-arm64": "workspace:*", - "node-addon-landlock-run-linux-x64": "workspace:*" + "@deepseek-ai/node-addon-landlock-run-linux-arm64": "workspace:*", + "@deepseek-ai/node-addon-landlock-run-linux-x64": "workspace:*" } } diff --git a/native/landlock-run/packages/entry/src/index.ts b/native/landlock-run/packages/entry/src/index.ts index 7a4349a5ca..ec909f928a 100644 --- a/native/landlock-run/packages/entry/src/index.ts +++ b/native/landlock-run/packages/entry/src/index.ts @@ -53,7 +53,7 @@ export interface LauncherGrants { /** * Path of the launcher binary for this host: resolved from the per-platform - * npm package `node-addon-landlock-run--` (npm's + * npm package `@deepseek-ai/node-addon-landlock-run--` (npm's * `os`/`cpu` fields make installers fetch only the matching one). When the * package is not resolvable — a platform without one, or an install that * skipped the optional dependency — the returned fallback path points inside @@ -69,7 +69,7 @@ export interface LauncherGrants { export function launcherPath( resolvePackageJson: (specifier: string) => string = createRequire(import.meta.url).resolve, ): string { - const platformPackage = `node-addon-landlock-run-${process.platform}-${process.arch}` + const platformPackage = `@deepseek-ai/node-addon-landlock-run-${process.platform}-${process.arch}` try { return join(dirname(resolvePackageJson(`${platformPackage}/package.json`)), 'bin', LAUNCHER_BIN) } catch { diff --git a/native/landlock-run/packages/entry/src/main.c b/native/landlock-run/packages/entry/src/main.c index af3c2eb3f0..e4e1f5c17e 100644 --- a/native/landlock-run/packages/entry/src/main.c +++ b/native/landlock-run/packages/entry/src/main.c @@ -31,7 +31,7 @@ * linked statically), so the whole audit surface is this file plus the * kernel's stable syscall contract. Built natively per architecture by * `scripts/build.ts` into the per-platform npm packages - * (`node-addon-landlock-run-linux-{x64,arm64}`); the argv grammar, + * (`@deepseek-ai/node-addon-landlock-run-linux-{x64,arm64}`); the argv grammar, * exit codes, and report lines are pinned in `docs/cli-contract.md`. */ diff --git a/native/landlock-run/packages/linux-arm64/README.i18n.yaml b/native/landlock-run/packages/linux-arm64/README.i18n.yaml index f7e057193c..fc5c8f9b11 100644 --- a/native/landlock-run/packages/linux-arm64/README.i18n.yaml +++ b/native/landlock-run/packages/linux-arm64/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write native/landlock-run/packages/linux-arm64/README.md -README.md: e5117988cf0bae2227edaa041700c2f75753899c -README.zh.md: e502b0239b5ed862af579b21e36b8c47d7d6107e +README.md: dfcc9e97dc1393a42ff4b89ac009cdfd31e1497b +README.zh.md: 350044e92f1d0247222cc16c82f03588ed0154c9 diff --git a/native/landlock-run/packages/linux-arm64/README.md b/native/landlock-run/packages/linux-arm64/README.md index e5117988cf..dfcc9e97dc 100644 --- a/native/landlock-run/packages/linux-arm64/README.md +++ b/native/landlock-run/packages/linux-arm64/README.md @@ -1,9 +1,9 @@ -# node-addon-landlock-run-linux-arm64 +# @deepseek-ai/node-addon-landlock-run-linux-arm64 English | [中文](README.zh.md) -Prebuilt `bin/landlock-run` Landlock launcher for linux-arm64 — a static musl binary compiled natively (no cross toolchain) from the C source shipped in [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run). npm's `os`/`cpu` fields select this package at install time; the entry package resolves it to a file path — it ships no JavaScript and is never imported. +Prebuilt `bin/landlock-run` Landlock launcher for linux-arm64 — a static musl binary compiled natively (no cross toolchain) from the C source shipped in [`@deepseek-ai/node-addon-landlock-run`](https://www.npmjs.com/package/@deepseek-ai/node-addon-landlock-run). npm's `os`/`cpu` fields select this package at install time; the entry package resolves it to a file path — it ships no JavaScript and is never imported. The binary is git-ignored and rides the npm tarball via the `files` list; the `prepack` gate refuses to pack when it is missing or has the wrong ELF architecture, and the release pipeline byte-pins the packed binary against the CI build it came from. Static musl linking means one binary for glibc and musl distros alike — hence no libc suffix in the name. -Sibling: `node-addon-landlock-run-linux-x64`. +Sibling: `@deepseek-ai/node-addon-landlock-run-linux-x64`. diff --git a/native/landlock-run/packages/linux-arm64/README.zh.md b/native/landlock-run/packages/linux-arm64/README.zh.md index e502b0239b..350044e92f 100644 --- a/native/landlock-run/packages/linux-arm64/README.zh.md +++ b/native/landlock-run/packages/linux-arm64/README.zh.md @@ -1,9 +1,9 @@ -# node-addon-landlock-run-linux-arm64 +# @deepseek-ai/node-addon-landlock-run-linux-arm64 [English](README.md) | 中文 -面向 linux-arm64 的预构建 `bin/landlock-run` Landlock 启动器:一个由 [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) 包所附的 C 源码原生编译而成的静态 musl 二进制文件(不使用交叉工具链)。npm 的 `os`/`cpu` 字段在安装时选择此包;入口包将其定位到文件路径。该包不包含 JavaScript,也绝不会被导入。 +面向 linux-arm64 的预构建 `bin/landlock-run` Landlock 启动器:一个由 [`@deepseek-ai/node-addon-landlock-run`](https://www.npmjs.com/package/@deepseek-ai/node-addon-landlock-run) 包所附的 C 源码原生编译而成的静态 musl 二进制文件(不使用交叉工具链)。npm 的 `os`/`cpu` 字段在安装时选择此包;入口包将其定位到文件路径。该包不包含 JavaScript,也绝不会被导入。 该二进制文件被 git 忽略,并通过 `files` 列表进入 npm tarball;如果文件缺失或 ELF 架构错误,`prepack` 门禁会拒绝打包,发布流水线则会按字节核验打包的二进制文件与其来源 CI 构建产物一致。静态 musl 链接使同一个二进制文件同时适用于 glibc 和 musl 发行版,因此名称中没有 libc 后缀。 -同级包:`node-addon-landlock-run-linux-x64`。 +同级包:`@deepseek-ai/node-addon-landlock-run-linux-x64`。 diff --git a/native/landlock-run/packages/linux-arm64/package.json b/native/landlock-run/packages/linux-arm64/package.json index 0067f77c8b..14190e4765 100644 --- a/native/landlock-run/packages/linux-arm64/package.json +++ b/native/landlock-run/packages/linux-arm64/package.json @@ -1,7 +1,12 @@ { - "name": "node-addon-landlock-run-linux-arm64", + "name": "@deepseek-ai/node-addon-landlock-run-linux-arm64", "version": "0.0.1", - "description": "Prebuilt landlock-run Landlock launcher binary for linux-arm64 (static musl) — resolved as a file path by node-addon-landlock-run, never imported", + "description": "Prebuilt landlock-run Landlock launcher binary for linux-arm64 (static musl) — resolved as a file path by @deepseek-ai/node-addon-landlock-run, never imported", + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-harness/deepseek-harness.git", + "directory": "native/landlock-run/packages/linux-arm64" + }, "os": [ "linux" ], diff --git a/native/landlock-run/packages/linux-x64/README.i18n.yaml b/native/landlock-run/packages/linux-x64/README.i18n.yaml index 7050c110ef..cb0022b138 100644 --- a/native/landlock-run/packages/linux-x64/README.i18n.yaml +++ b/native/landlock-run/packages/linux-x64/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write native/landlock-run/packages/linux-x64/README.md -README.md: 68b5dfc9b6f437a387c3792ee047a1f11630aca0 -README.zh.md: 3b9578a7eb78dfc05977795ca521cf3a881e9f1a +README.md: d08cc0c4abbc74f64c5d1075dea796427211bd8f +README.zh.md: ed6839aa6230b16b82c67a716fc0a4128e5a977c diff --git a/native/landlock-run/packages/linux-x64/README.md b/native/landlock-run/packages/linux-x64/README.md index 68b5dfc9b6..d08cc0c4ab 100644 --- a/native/landlock-run/packages/linux-x64/README.md +++ b/native/landlock-run/packages/linux-x64/README.md @@ -1,9 +1,9 @@ -# node-addon-landlock-run-linux-x64 +# @deepseek-ai/node-addon-landlock-run-linux-x64 English | [中文](README.zh.md) -Prebuilt `bin/landlock-run` Landlock launcher for linux-x64 — a static musl binary compiled natively (no cross toolchain) from the C source shipped in [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run). npm's `os`/`cpu` fields select this package at install time; the entry package resolves it to a file path — it ships no JavaScript and is never imported. +Prebuilt `bin/landlock-run` Landlock launcher for linux-x64 — a static musl binary compiled natively (no cross toolchain) from the C source shipped in [`@deepseek-ai/node-addon-landlock-run`](https://www.npmjs.com/package/@deepseek-ai/node-addon-landlock-run). npm's `os`/`cpu` fields select this package at install time; the entry package resolves it to a file path — it ships no JavaScript and is never imported. The binary is git-ignored and rides the npm tarball via the `files` list; the `prepack` gate refuses to pack when it is missing or has the wrong ELF architecture, and the release pipeline byte-pins the packed binary against the CI build it came from. Static musl linking means one binary for glibc and musl distros alike — hence no libc suffix in the name. -Sibling: `node-addon-landlock-run-linux-arm64`. +Sibling: `@deepseek-ai/node-addon-landlock-run-linux-arm64`. diff --git a/native/landlock-run/packages/linux-x64/README.zh.md b/native/landlock-run/packages/linux-x64/README.zh.md index 3b9578a7eb..ed6839aa62 100644 --- a/native/landlock-run/packages/linux-x64/README.zh.md +++ b/native/landlock-run/packages/linux-x64/README.zh.md @@ -1,9 +1,9 @@ -# node-addon-landlock-run-linux-x64 +# @deepseek-ai/node-addon-landlock-run-linux-x64 [English](README.md) | 中文 -面向 linux-x64 的预构建 `bin/landlock-run` Landlock 启动器:一个由 [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) 包所附的 C 源码原生编译而成的静态 musl 二进制文件(不使用交叉工具链)。npm 的 `os`/`cpu` 字段在安装时选择此包;入口包将其定位到文件路径。该包不包含 JavaScript,也绝不会被导入。 +面向 linux-x64 的预构建 `bin/landlock-run` Landlock 启动器:一个由 [`@deepseek-ai/node-addon-landlock-run`](https://www.npmjs.com/package/@deepseek-ai/node-addon-landlock-run) 包所附的 C 源码原生编译而成的静态 musl 二进制文件(不使用交叉工具链)。npm 的 `os`/`cpu` 字段在安装时选择此包;入口包将其定位到文件路径。该包不包含 JavaScript,也绝不会被导入。 该二进制文件被 git 忽略,并通过 `files` 列表进入 npm tarball;如果文件缺失或 ELF 架构错误,`prepack` 门禁会拒绝打包,发布流水线则会按字节核验打包的二进制文件与其来源 CI 构建产物一致。静态 musl 链接使同一个二进制文件同时适用于 glibc 和 musl 发行版,因此名称中没有 libc 后缀。 -同级包:`node-addon-landlock-run-linux-arm64`。 +同级包:`@deepseek-ai/node-addon-landlock-run-linux-arm64`。 diff --git a/native/landlock-run/packages/linux-x64/package.json b/native/landlock-run/packages/linux-x64/package.json index 8ea60b636c..43c092d17b 100644 --- a/native/landlock-run/packages/linux-x64/package.json +++ b/native/landlock-run/packages/linux-x64/package.json @@ -1,7 +1,12 @@ { - "name": "node-addon-landlock-run-linux-x64", + "name": "@deepseek-ai/node-addon-landlock-run-linux-x64", "version": "0.0.1", - "description": "Prebuilt landlock-run Landlock launcher binary for linux-x64 (static musl) — resolved as a file path by node-addon-landlock-run, never imported", + "description": "Prebuilt landlock-run Landlock launcher binary for linux-x64 (static musl) — resolved as a file path by @deepseek-ai/node-addon-landlock-run, never imported", + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-harness/deepseek-harness.git", + "directory": "native/landlock-run/packages/linux-x64" + }, "os": [ "linux" ], diff --git a/native/landlock-run/pnpm-lock.yaml b/native/landlock-run/pnpm-lock.yaml deleted file mode 100644 index dd309fb046..0000000000 --- a/native/landlock-run/pnpm-lock.yaml +++ /dev/null @@ -1,345 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - devDependencies: - '@types/node': - specifier: ^26.0.1 - version: 26.0.1 - node-addon-landlock-run: - specifier: workspace:* - version: link:packages/entry - tsx: - specifier: ^4.20.6 - version: 4.23.0 - typescript: - specifier: ^6.0.3 - version: 6.0.3 - - packages/entry: - optionalDependencies: - node-addon-landlock-run-linux-arm64: - specifier: workspace:* - version: link:../linux-arm64 - node-addon-landlock-run-linux-x64: - specifier: workspace:* - version: link:../linux-x64 - - packages/linux-arm64: {} - - packages/linux-x64: {} - -packages: - - '@esbuild/aix-ppc64@0.28.1': - resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.28.1': - resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.28.1': - resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.28.1': - resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.28.1': - resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.28.1': - resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.28.1': - resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.28.1': - resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.28.1': - resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.28.1': - resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.28.1': - resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.28.1': - resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.28.1': - resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.28.1': - resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.28.1': - resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.28.1': - resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.28.1': - resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.28.1': - resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.28.1': - resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.28.1': - resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.28.1': - resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.28.1': - resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/sunos-x64@0.28.1': - resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.28.1': - resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.28.1': - resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.28.1': - resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@types/node@26.0.1': - resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==} - - esbuild@0.28.1: - resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} - engines: {node: '>=18'} - hasBin: true - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - tsx@4.23.0: - resolution: {integrity: sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==} - engines: {node: '>=18.0.0'} - hasBin: true - - typescript@6.0.3: - resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} - engines: {node: '>=14.17'} - hasBin: true - - undici-types@8.3.0: - resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} - -snapshots: - - '@esbuild/aix-ppc64@0.28.1': - optional: true - - '@esbuild/android-arm64@0.28.1': - optional: true - - '@esbuild/android-arm@0.28.1': - optional: true - - '@esbuild/android-x64@0.28.1': - optional: true - - '@esbuild/darwin-arm64@0.28.1': - optional: true - - '@esbuild/darwin-x64@0.28.1': - optional: true - - '@esbuild/freebsd-arm64@0.28.1': - optional: true - - '@esbuild/freebsd-x64@0.28.1': - optional: true - - '@esbuild/linux-arm64@0.28.1': - optional: true - - '@esbuild/linux-arm@0.28.1': - optional: true - - '@esbuild/linux-ia32@0.28.1': - optional: true - - '@esbuild/linux-loong64@0.28.1': - optional: true - - '@esbuild/linux-mips64el@0.28.1': - optional: true - - '@esbuild/linux-ppc64@0.28.1': - optional: true - - '@esbuild/linux-riscv64@0.28.1': - optional: true - - '@esbuild/linux-s390x@0.28.1': - optional: true - - '@esbuild/linux-x64@0.28.1': - optional: true - - '@esbuild/netbsd-arm64@0.28.1': - optional: true - - '@esbuild/netbsd-x64@0.28.1': - optional: true - - '@esbuild/openbsd-arm64@0.28.1': - optional: true - - '@esbuild/openbsd-x64@0.28.1': - optional: true - - '@esbuild/openharmony-arm64@0.28.1': - optional: true - - '@esbuild/sunos-x64@0.28.1': - optional: true - - '@esbuild/win32-arm64@0.28.1': - optional: true - - '@esbuild/win32-ia32@0.28.1': - optional: true - - '@esbuild/win32-x64@0.28.1': - optional: true - - '@types/node@26.0.1': - dependencies: - undici-types: 8.3.0 - - esbuild@0.28.1: - optionalDependencies: - '@esbuild/aix-ppc64': 0.28.1 - '@esbuild/android-arm': 0.28.1 - '@esbuild/android-arm64': 0.28.1 - '@esbuild/android-x64': 0.28.1 - '@esbuild/darwin-arm64': 0.28.1 - '@esbuild/darwin-x64': 0.28.1 - '@esbuild/freebsd-arm64': 0.28.1 - '@esbuild/freebsd-x64': 0.28.1 - '@esbuild/linux-arm': 0.28.1 - '@esbuild/linux-arm64': 0.28.1 - '@esbuild/linux-ia32': 0.28.1 - '@esbuild/linux-loong64': 0.28.1 - '@esbuild/linux-mips64el': 0.28.1 - '@esbuild/linux-ppc64': 0.28.1 - '@esbuild/linux-riscv64': 0.28.1 - '@esbuild/linux-s390x': 0.28.1 - '@esbuild/linux-x64': 0.28.1 - '@esbuild/netbsd-arm64': 0.28.1 - '@esbuild/netbsd-x64': 0.28.1 - '@esbuild/openbsd-arm64': 0.28.1 - '@esbuild/openbsd-x64': 0.28.1 - '@esbuild/openharmony-arm64': 0.28.1 - '@esbuild/sunos-x64': 0.28.1 - '@esbuild/win32-arm64': 0.28.1 - '@esbuild/win32-ia32': 0.28.1 - '@esbuild/win32-x64': 0.28.1 - - fsevents@2.3.3: - optional: true - - tsx@4.23.0: - dependencies: - esbuild: 0.28.1 - optionalDependencies: - fsevents: 2.3.3 - - typescript@6.0.3: {} - - undici-types@8.3.0: {} diff --git a/native/landlock-run/pnpm-workspace.yaml b/native/landlock-run/pnpm-workspace.yaml deleted file mode 100644 index 22299bfea0..0000000000 --- a/native/landlock-run/pnpm-workspace.yaml +++ /dev/null @@ -1,8 +0,0 @@ -packages: - - packages/* - -# pnpm 10+ blocks any dependency shipping an install/build script until it is -# explicitly reviewed here. Deny by default; esbuild (tsx's bundled native -# binary) genuinely needs its script. -allowBuilds: - esbuild: true diff --git a/native/landlock-run/scripts/bump-release.mjs b/native/landlock-run/scripts/bump-release.mjs index 29a7777379..55033eed09 100644 --- a/native/landlock-run/scripts/bump-release.mjs +++ b/native/landlock-run/scripts/bump-release.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node /** - * Bump every package (workspace root + packages/*) to one version, refresh - * the lockfile, and verify. Usage: `pnpm release:bump `. + * Bump the launcher workspace root and packages/* to one version, refresh the + * repository lockfile, and verify. Usage: `pnpm release:bump `. */ import fs from 'node:fs'; @@ -11,14 +11,15 @@ import { packageDirs, readJson, root } from './repo.mjs'; const bump = process.argv[2]; const releaseTypes = new Set(['major', 'minor', 'patch']); +const repositoryRoot = path.resolve(root, '../..'); function writeJson(file, value) { fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`); } -function run(command, args) { +function run(command, args, cwd = root) { const result = spawnSync(command, args, { - cwd: root, + cwd, stdio: 'inherit', env: { ...process.env, CI: 'true' }, }); @@ -84,7 +85,7 @@ for (const file of files) { console.log(`${file}: ${targetVersion}`); } -run('pnpm', ['install', '--ignore-scripts', '--lockfile-only']); +run('pnpm', ['install', '--ignore-scripts', '--lockfile-only'], repositoryRoot); run('node', ['./scripts/verify-release.mjs']); console.log(`Release version bumped to ${targetVersion}`); diff --git a/native/landlock-run/scripts/commit-release.mjs b/native/landlock-run/scripts/commit-release.mjs index b7bf3e513b..1b1c78bce5 100644 --- a/native/landlock-run/scripts/commit-release.mjs +++ b/native/landlock-run/scripts/commit-release.mjs @@ -1,8 +1,8 @@ #!/usr/bin/env node /** * Bump, stage, and commit a release in one command: - * `pnpm release:commit `. The tag stays manual — - * create it from the merged release commit. + * `pnpm release:commit `. The namespaced tag stays + * manual — create it from the merged release commit. */ import path from 'node:path'; @@ -35,8 +35,8 @@ run('git', [ 'add', 'package.json', 'packages/*/package.json', - 'pnpm-lock.yaml', + '../../pnpm-lock.yaml', ]); -run('git', ['commit', '-m', `release: ${version}`]); +run('git', ['commit', '-m', `release(landlock-run): ${version}`]); -console.log(`Committed release ${version}. Create the tag manually: git tag v${version}`); +console.log(`Committed release ${version}. Create the tag manually: git tag landlock-run-v${version}`); diff --git a/native/landlock-run/scripts/repo.mjs b/native/landlock-run/scripts/repo.mjs index 8032d3da37..db5ffaf28d 100644 --- a/native/landlock-run/scripts/repo.mjs +++ b/native/landlock-run/scripts/repo.mjs @@ -12,10 +12,10 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; export const root = fileURLToPath(new URL('..', import.meta.url)); -export const packagesRoot = path.join(root, 'packages'); +const packagesRoot = path.join(root, 'packages'); /** ELF `e_machine` (offset 18, little-endian) per platform-package `cpu` value. */ -export const E_MACHINE = { x64: 62, arm64: 183 }; +const E_MACHINE = { x64: 62, arm64: 183 }; export function readJson(file) { return JSON.parse(fs.readFileSync(file, 'utf8')); diff --git a/native/landlock-run/scripts/verify-packed-install.mjs b/native/landlock-run/scripts/verify-packed-install.mjs index 60f225a9d2..928fff50f7 100644 --- a/native/landlock-run/scripts/verify-packed-install.mjs +++ b/native/landlock-run/scripts/verify-packed-install.mjs @@ -31,7 +31,7 @@ import { entryDirs, packageDirs, platformDirs, readJson, root } from './repo.mjs const args = process.argv.slice(2); const currentPlatformOnly = args.includes('--current-platform-only'); const tarballDir = path.resolve(args.find((arg) => !arg.startsWith('--')) || path.join(root, 'dist', 'npm')); -const entryPackageName = 'node-addon-landlock-run'; +const entryPackageName = '@deepseek-ai/node-addon-landlock-run'; function tarballName(manifest) { if (manifest.name.startsWith('@')) { @@ -180,10 +180,10 @@ import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { grantArgs, launcherPath, probe } from 'node-addon-landlock-run'; +import { grantArgs, launcherPath, probe } from '@deepseek-ai/node-addon-landlock-run'; const requireLandlock = process.env.NALR_REQUIRE_LANDLOCK === '1'; -const platformPackage = 'node-addon-landlock-run-' + process.platform + '-' + process.arch; +const platformPackage = '@deepseek-ai/node-addon-landlock-run-' + process.platform + '-' + process.arch; const resolved = launcherPath(); assert.ok(path.isAbsolute(resolved), 'launcherPath must be absolute'); assert.ok(resolved.includes(path.join(...platformPackage.split('/'))), 'launcherPath must point into the platform package: ' + resolved); diff --git a/native/landlock-run/scripts/verify-release.mjs b/native/landlock-run/scripts/verify-release.mjs index e812b34a14..ff3917b4b5 100644 --- a/native/landlock-run/scripts/verify-release.mjs +++ b/native/landlock-run/scripts/verify-release.mjs @@ -1,8 +1,8 @@ #!/usr/bin/env node /** * Release verification. Always: every published package carries one shared - * version, and — when running from a tag or publishing — the `vX.Y.Z` tag - * matches it. With `--prebuilds`: every platform package's declared + * version, and — when running from a tag or publishing — the + * `landlock-run-vX.Y.Z` tag matches it. With `--prebuilds`: every platform package's declared * binaries exist with the right ELF architecture (run after * `assemble-prebuilds.mjs` or a local `build:native`). */ @@ -10,6 +10,8 @@ import path from 'node:path'; import { packageDirs, platformDirs, readJson, root, verifyPlatformBinaries } from './repo.mjs'; +const TAG_PREFIX = 'refs/tags/landlock-run-v'; + function verifyVersions() { const packages = packageDirs().map((dir) => ({ dir, @@ -26,13 +28,13 @@ function verifyVersions() { const version = packages[0].manifest.version; const ref = process.env.GITHUB_REF || ''; const publish = process.env.RELEASE_PUBLISH === 'true'; - if (publish && !ref.startsWith('refs/tags/v')) { - throw new Error('publishing requires running the workflow from a v* tag'); + if (publish && !ref.startsWith(TAG_PREFIX)) { + throw new Error('publishing requires running the workflow from a landlock-run-v* tag'); } - if (ref.startsWith('refs/tags/v')) { - const tagVersion = ref.slice('refs/tags/v'.length); + if (ref.startsWith(TAG_PREFIX)) { + const tagVersion = ref.slice(TAG_PREFIX.length); if (tagVersion !== version) { - throw new Error(`tag/version mismatch: tag v${tagVersion}, packages ${version}`); + throw new Error(`tag/version mismatch: tag landlock-run-v${tagVersion}, packages ${version}`); } } diff --git a/native/landlock-run/test/entry.test.js b/native/landlock-run/test/entry.test.js index 2e2cfe8f17..2b535559a2 100644 --- a/native/landlock-run/test/entry.test.js +++ b/native/landlock-run/test/entry.test.js @@ -15,7 +15,7 @@ import { grantArgs, launcherPath, probe, -} from 'node-addon-landlock-run'; +} from '@deepseek-ai/node-addon-landlock-run'; // --- constants are part of the CLI contract --- assert.equal(LAUNCHER_BIN, 'landlock-run'); @@ -31,7 +31,7 @@ assert.deepEqual( assert.deepEqual(grantArgs({ readWrite: ['/a'], readOnly: ['/b'] }), ['--ro', '/b', '--rw', '/a']); // --- launcherPath: resolves the platform package next to its package.json --- -const platformPackage = `node-addon-landlock-run-${process.platform}-${process.arch}`; +const platformPackage = `@deepseek-ai/node-addon-landlock-run-${process.platform}-${process.arch}`; const resolvedViaSeam = launcherPath((specifier) => { assert.equal(specifier, `${platformPackage}/package.json`); return path.join('/fake-install', specifier); diff --git a/native/landlock-run/test/launcher.test.js b/native/landlock-run/test/launcher.test.js index 55385d2156..a78501cd4a 100644 --- a/native/landlock-run/test/launcher.test.js +++ b/native/landlock-run/test/launcher.test.js @@ -22,7 +22,7 @@ import { grantArgs, launcherPath, probe, -} from 'node-addon-landlock-run'; +} from '@deepseek-ai/node-addon-landlock-run'; const FATAL_PREFIX = 'landlock-run: '; const PARTIAL_NOTICE = 'landlock-run: partial enforcement (older Landlock ABI)'; diff --git a/package.json b/package.json index 81cb40acad..423cbf813c 100644 --- a/package.json +++ b/package.json @@ -10,21 +10,25 @@ "workspaces": [ "vendor/*", "packages/*/*", + "native/landlock-run", + "native/landlock-run/packages/*", "apps/*", "website" ], "scripts": { "build": "npm run build:lib && npm run build:web", "build:lib": "npm run build:lib:host && npm run build:lib:client", - "build:lib:host": "npm run build:lib:contracts && tsc -b tsconfig.host.json", - "build:lib:contracts": "tsc -b packages/typert/generator && tsdown --config tsdown.typert-host.config.ts", - "build:lib:client": "tsc -b tsconfig.client.json && tsdown", + "build:lib:host": "tsc -b tsconfig.host.json && tsdown --env.DSH_BUILD_FACE host", + "build:lib:client": "tsc -b tsconfig.client.json && tsdown --env.DSH_BUILD_FACE client", "build:web": "pnpm --filter @deepseek-ai/dsh-frontend run build", "clean": "tsx scripts/clean.ts", "change-scope": "tsx scripts/change-scope.ts", - "typecheck": "npm run build:lib:contracts && tsc -b", - "lint": "tsx scripts/run-oxlint.ts .", - "lint:fix": "eslint --config eslint.format.config.mjs --fix . && tsx scripts/run-oxlint.ts . --fix", + "typecheck": "npm run build:lib:host && npm run typecheck:contracts-ready", + "typecheck:contracts-ready": "tsc -b tsconfig.client.json", + "lint": "npm run build:lib:host && npm run lint:contracts-ready", + "lint:contracts-ready": "tsx scripts/run-oxlint.ts .", + "lint:fix": "npm run build:lib:host && npm run lint:fix:contracts-ready", + "lint:fix:contracts-ready": "eslint --config eslint.format.config.mjs --fix . && tsx scripts/run-oxlint.ts . --fix", "duplication": "jscpd --config .jscpd.json packages scripts", "test": "vitest run", "test:coverage": "vitest run --coverage", @@ -45,7 +49,7 @@ "check:ci": "tsx scripts/run-gates.ts ci-primary", "check:ci:linux-primary": "tsx scripts/run-gates.ts ci-linux-primary", "check:ci:static": "tsx scripts/run-gates.ts ci-static", - "check:ci:lint": "tsx scripts/run-gates.ts ci-lint", + "check:ci:lint:contracts-ready": "tsx scripts/run-gates.ts ci-lint-contracts-ready", "check:ci:coverage": "tsx scripts/run-gates.ts ci-coverage", "check:ci:snapshot": "tsx scripts/run-gates.ts ci-snapshot", "check:ci:artifacts": "tsx scripts/run-gates.ts ci-artifacts", @@ -57,11 +61,14 @@ "check:node-compat": "tsx scripts/run-gates.ts node-compat", "knip": "knip --treat-config-hints-as-errors", "publint": "tsx scripts/publint-all.ts", - "doc-typecheck": "tsx scripts/doc-typecheck.ts", + "doc-typecheck": "npm run build:lib:host && npm run doc-typecheck:contracts-ready", + "doc-typecheck:contracts-ready": "tsx scripts/doc-typecheck.ts", "verify-md-wrap": "tsx scripts/verify-md-wrap.ts", "verify-md-links": "tsx scripts/verify-md-links.ts", + "verify-public-repository-links": "tsx scripts/verify-public-repository-links.ts", "verify-doc-refs": "tsx scripts/verify-doc-refs.ts", "verify-package-paths": "tsx scripts/verify-package-paths.ts", + "verify-config-source-ownership": "tsx scripts/verify-config-source-ownership.ts", "verify-package-invariants": "tsx scripts/verify-package-invariants.ts", "verify-built-package-invariants": "node scripts/verify-built-package-invariants.mjs", "verify-package-readme-model-experience": "tsx scripts/verify-package-readme-model-experience.ts", @@ -111,7 +118,7 @@ "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-package-invariants && pnpm run verify-built-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure && pnpm run verify-vendored-links", "publish:npm-baseline": "tsx scripts/publish-npm-baseline.ts", "dsh": "node --import tsx/esm apps/cli/src/bin.ts", - "demo:headless": "node --import tsx packages/examples/cli-demo/src/bin.ts --config examples/headless-agent/cordis.yml", + "demo:headless": "node --import tsx/esm apps/cli/src/bin.ts run", "demo:code-mode": "node scripts/demo-code-mode.mjs", "demo:cordis": "node scripts/demo-cordis.mjs", "demo:acp": "node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml", diff --git a/packages/AGENTS.md b/packages/AGENTS.md index 0e8c62841e..58db577ee5 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -19,7 +19,7 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md Naming notes: -- **Package tsconfig shape:** extends `tsconfig.base.json` (client: `tsconfig.base.client.json`), `rootDir: src`, `outDir: lib/types`, a `references` entry per workspace dependency plus `support/invariants`; registered in exactly one aggregate — host packages in `tsconfig.host.json`, client in `tsconfig.client.json` ([layout](../docs/development.md#typescript-project-layout)). +- **Package tsconfig:** extends `tsconfig.base.json` (Client: `tsconfig.base.client.json`), uses `rootDir: src`, `outDir: lib/types`, and references each workspace dependency plus `support/invariants`; registers in exactly one aggregate. Only `api/remotes` splits for generated contracts; ordinary two-entry Client plugins do not ([layout](../docs/development.md#typescript-project-layout)). - `src/types.ts` contains only types — no runtime code. - Tests live at package level under `tests/`, not `src/__tests__/`. - A package's README and JSDoc are part of the change: altered behavior (config keys, defaults, error codes, wire fields) updates them in the same commit. `doc-sync` gates what it can; apply [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for complete, concise prose and verify accuracy against code. diff --git a/packages/api/remotes/README.i18n.yaml b/packages/api/remotes/README.i18n.yaml index 82947331c5..f8f7a6400b 100644 --- a/packages/api/remotes/README.i18n.yaml +++ b/packages/api/remotes/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/remotes/README.md -README.md: 7f6a2114d900413d972584c0f1c141b7f835ba36 -README.zh.md: cce263747d696570f362811556fa6f5c0be0a0f5 +README.md: 3d9de0955faefe37c95ff8bb792d57c4fa1f1a3a +README.zh.md: 7490d68781d3a7b0002b73fe06056ec86c144575 diff --git a/packages/api/remotes/README.md b/packages/api/remotes/README.md index 7f6a2114d9..3d9de0955f 100644 --- a/packages/api/remotes/README.md +++ b/packages/api/remotes/README.md @@ -10,6 +10,14 @@ The current Client assembly mounts only the Goal Remote contribution. Cordis eff This package contains no transport or Host service discovery logic. Its Client face can be reused by Web or a future TUI that provides the same React-free `ctx.remote` contract. +## Build boundary + +An ordinary repository package belongs to one TypeScript face: Host packages are registered in the root `tsconfig.host.json`, and Client packages in the root `tsconfig.client.json`. `api-remotes` is the only deliberate exception because its Host entry must participate in the Host TypeRT graph, while `src/client/index.ts` cannot compile until Host tsdown has generated the business packages' `/remote` declarations. + +This package's root `tsconfig.json` is only a solution that references `tsconfig.host.json` and `tsconfig.client.json`. The Host aggregate and direct Host consumers reference the former, while the Client aggregate and direct Client consumers reference the latter; the package-root solution must not enter either aggregate's dependency graph. The two projects own disjoint source files and `.tsbuildinfo` files but share the `lib/types` output directory. + +The package-local `clientBundle(..., { hostPhase: true })` makes Host tsdown bundle the Host entry and the later Client tsdown bundle only the browser entry. Ordinary Client plugins remain single Client projects and produce both their Node loader entry and browser bundle during Client tsdown; do not copy this package's split merely because a package has both `src/index.ts` and `src/client/index.ts`. + ## Model Experience None, as this BFF selects Remote application methods and identity policy but registers no model surface. diff --git a/packages/api/remotes/README.zh.md b/packages/api/remotes/README.zh.md index cce263747d..7490d68781 100644 --- a/packages/api/remotes/README.zh.md +++ b/packages/api/remotes/README.zh.md @@ -10,6 +10,14 @@ 本包不包含传输逻辑或 Host 服务发现逻辑。Web 或未来的 TUI 只要提供同一份不依赖 React 的 `ctx.remote` 契约,均可复用其 Client face。 +## 构建边界 + +仓库中的普通包只属于一个 TypeScript face:Host 包登记在根 `tsconfig.host.json`,Client 包登记在根 `tsconfig.client.json`。`api-remotes` 是唯一刻意拆分的特例,因为它的 Host 入口要参与 Host TypeRT 图,而 `src/client/index.ts` 必须等 Host tsdown 生成业务包的 `/remote` 声明后才能编译。 + +本包根 `tsconfig.json` 只是引用 `tsconfig.host.json` 与 `tsconfig.client.json` 的 solution。Host aggregate 和 Host 直接消费方引用前者,Client aggregate 和 Client 直接消费方引用后者;禁止把包根 solution 放进任一 aggregate 的依赖图。两个 project 拥有互不重叠的源码和 `.tsbuildinfo`,但共享 `lib/types` 输出目录。 + +包内 `clientBundle(..., { hostPhase: true })` 让 Host tsdown 打包 Host 入口,让后续 Client tsdown 只打包 browser 入口。普通 Client 插件仍使用单一 Client project,并在 Client tsdown 阶段一起生成 Node loader 入口和 browser bundle;不得因一个包同时存在 `src/index.ts` 与 `src/client/index.ts` 就复制本包的拆分。 + ## 模型体验 无,因为该 BFF 只选择 Remote 应用方法和身份策略,不注册任何模型接口。 diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json new file mode 100644 index 0000000000..bc26c0b13f --- /dev/null +++ b/packages/api/remotes/tsconfig.client.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo" + }, + "files": [ + "src/client/index.ts" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../goal/goal" + }, + { + "path": "../../typert/type-meta" + } + ] +} diff --git a/packages/api/remotes/tsconfig.host.json b/packages/api/remotes/tsconfig.host.json new file mode 100644 index 0000000000..1d4c35a9e2 --- /dev/null +++ b/packages/api/remotes/tsconfig.host.json @@ -0,0 +1,36 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo" + }, + "files": [ + "src/agent-lookup.ts", + "src/index.ts", + "src/invariant.ts" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/session" + }, + { + "path": "../../session-persistence/session-persistence" + }, + { + "path": "../../support/invariants" + }, + { + "path": "../../typert/registry" + }, + { + "path": "../../typert/type-meta" + } + ] +} diff --git a/packages/api/remotes/tsconfig.json b/packages/api/remotes/tsconfig.json index 148804dc0f..2eca820546 100644 --- a/packages/api/remotes/tsconfig.json +++ b/packages/api/remotes/tsconfig.json @@ -1,42 +1,11 @@ { - "extends": "../../../tsconfig.base.client.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], + "files": [], "references": [ { - "path": "../../../vendor/cordis" + "path": "./tsconfig.host.json" }, { - "path": "../../core/agent" - }, - { - "path": "../../core/session" - }, - { - "path": "../../session-persistence/session-persistence" - }, - { - "path": "../../typert/type-meta" - }, - { - "path": "../../typert/registry" - }, - { - "path": "../../ui/commands" - }, - { - "path": "../../goal/goal" - }, - { - "path": "../../session-title/session-title" - }, - { - "path": "../../support/invariants" + "path": "./tsconfig.client.json" } ] } diff --git a/packages/api/remotes/tsdown.config.ts b/packages/api/remotes/tsdown.config.ts index 287b2c7975..3c72df8718 100644 --- a/packages/api/remotes/tsdown.config.ts +++ b/packages/api/remotes/tsdown.config.ts @@ -1,3 +1,7 @@ import { clientBundle } from '../../client/tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-api-remotes', ['lib/types/index.js', 'lib/types/invariant.js']) +export default clientBundle( + '@deepseek-ai/dsh-api-remotes', + ['lib/types/index.js', 'lib/types/invariant.js'], + { hostPhase: true }, +) diff --git a/packages/bash/bash-sandbox/package.json b/packages/bash/bash-sandbox/package.json index e3d3c3deb3..a771acb1d3 100644 --- a/packages/bash/bash-sandbox/package.json +++ b/packages/bash/bash-sandbox/package.json @@ -41,6 +41,6 @@ "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "cordis": "^4.0.0-rc.7", - "node-addon-landlock-run": "0.0.0-test.0" + "@deepseek-ai/node-addon-landlock-run": "workspace:*" } } diff --git a/packages/bash/bash-sandbox/tests/landlock.e2e.ts b/packages/bash/bash-sandbox/tests/landlock.e2e.ts index 0c5cfbe563..7255ee43c9 100644 --- a/packages/bash/bash-sandbox/tests/landlock.e2e.ts +++ b/packages/bash/bash-sandbox/tests/landlock.e2e.ts @@ -5,7 +5,7 @@ import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' -import { launcherPath } from 'node-addon-landlock-run' +import { launcherPath } from '@deepseek-ai/node-addon-landlock-run' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' @@ -13,14 +13,14 @@ import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' /** * KEYLESS consumer-integration proof: the REAL `LocalSandboxProvider` (bwrap - * rung forced off, so the npm-distributed `landlock-run` confines) underneath the + * rung forced off, so the workspace `landlock-run` launcher confines) underneath the * REAL `SandboxBashExecutor`, driven through the executor's public run/start * paths. Verifies the WORLD (files exist or don't) plus the stamped result * facts; the backend-only confinement proofs live with * `@deepseek-ai/dsh-sandbox-local`. * - * Self-skips when the running kernel does not enforce Landlock; the - * launcher binary itself arrives with `pnpm install` (`node-addon-landlock-run`). + * Self-skips when the running kernel does not enforce Landlock. CI builds the launcher from + * `native/landlock-run` before running this file. */ const probe = spawnSync(launcherPath(), ['--probe'], { timeout: 5_000, encoding: 'utf8' }) diff --git a/packages/bash/bash-sandbox/tests/partial-landlock.spec.ts b/packages/bash/bash-sandbox/tests/partial-landlock.spec.ts index 23546e5d92..b578716c43 100644 --- a/packages/bash/bash-sandbox/tests/partial-landlock.spec.ts +++ b/packages/bash/bash-sandbox/tests/partial-landlock.spec.ts @@ -9,7 +9,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' -import { LAUNCHER_FAILURE_EXIT } from 'node-addon-landlock-run' +import { LAUNCHER_FAILURE_EXIT } from '@deepseek-ai/node-addon-landlock-run' import { SANDBOX_UNAVAILABLE, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' diff --git a/packages/bash/bash-sandbox/tsconfig.json b/packages/bash/bash-sandbox/tsconfig.json index fcd79e0296..7a7d67f0cb 100644 --- a/packages/bash/bash-sandbox/tsconfig.json +++ b/packages/bash/bash-sandbox/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../../native/landlock-run/packages/entry" + }, { "path": "../../util/brand" }, diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index 11b23c27be..799affc16b 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -77,12 +77,10 @@ - id: settings name: '@deepseek-ai/dsh-settings-local' - # Credential store: the live process environment over `$DSH_HOME/.env` - # (owner-only file, hot-reloaded). Adapters resolve their key references - # through it at each request, so no key is inlined in this file. The web - # Models page's key inputs write it through `credentials.set`; nothing hoists - # the document into the process environment, which would make every stored key - # read as an unrotatable ambient override. + # Credential sources: inherited environment over the managed + # `$DSH_HOME/.credentials.yaml`, with project and user `.env` fallbacks. + # Adapters resolve references per request; the Models page writes only the + # managed document, which is never materialized into the process environment. - id: credentials name: '@deepseek-ai/dsh-credentials-local' @@ -220,6 +218,10 @@ - id: skill-local name: '@deepseek-ai/dsh-skill-local' + - id: skill-badge + name: '@deepseek-ai/dsh-skill-badge' + disabled: true + - id: tool-skill name: '@deepseek-ai/dsh-tool-skill' @@ -382,7 +384,6 @@ name: '@deepseek-ai/dsh-web-search-deepseek' config: apiKeyEnv: DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_SEARCH_BASE_URL - id: tool-web name: '@deepseek-ai/dsh-tool-web' diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 9895d8834c..507952b43a 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -70,6 +70,7 @@ "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^", "@deepseek-ai/dsh-settings-local": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", + "@deepseek-ai/dsh-skill-badge": "workspace:^", "@deepseek-ai/dsh-skill-local": "workspace:^", "@deepseek-ai/dsh-spill-local": "workspace:^", "@deepseek-ai/dsh-spill-policy": "workspace:^", diff --git a/packages/bundle/base/tests/fixtures/root.cordis.yml b/packages/bundle/base/tests/fixtures/root.cordis.yml new file mode 100644 index 0000000000..fe51488c70 --- /dev/null +++ b/packages/bundle/base/tests/fixtures/root.cordis.yml @@ -0,0 +1 @@ +[] diff --git a/packages/bundle/headless/README.i18n.yaml b/packages/bundle/headless/README.i18n.yaml index 08e4a5a5b5..f1a9d53be8 100644 --- a/packages/bundle/headless/README.i18n.yaml +++ b/packages/bundle/headless/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/headless/README.md -README.md: d08fb08e2aca3c4e5ccd733b37fc415d492974ca -README.zh.md: 99a64ef04c4fd8fb0c6a979d3f09f1bd98b434a0 +README.md: 661b377817482d22f58f22b573075722646729a2 +README.zh.md: a6b91a8e60fdcc06ba23e07dcb2f4208ea1020f7 diff --git a/packages/bundle/headless/README.md b/packages/bundle/headless/README.md index d08fb08e2a..661b377817 100644 --- a/packages/bundle/headless/README.md +++ b/packages/bundle/headless/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md) + [`dsh-web-app`](../web-app/README.md): it moves the webserver to an OS-assigned port (parallel runs never collide), silences the URL line, and inserts this package's `headless-runner` plugin (config `{task}`). The runner drives one task turn through the in-process API carrier (`InProcessApiClient` over `toFetchHandler(ctx.apiProxy)`, so the full wire chain — serialization, zod, SSE framing — really runs), aggregates the turn's final assistant text, writes it to stdout, and requests exit (completed → 0, else 1) through the launcher-provided `ctx.headlessIo` seam. The Web composition stays mounted, so the running session is observable in a browser at the stderr-announced URL. The launcher patches the task text in (`dsh --profile headless "task"`), and fails loud when a task is given to a profile without this row. +The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md) + [`dsh-web-app`](../web-app/README.md): it moves the webserver to an OS-assigned port (parallel runs never collide), silences the URL line, and inserts this package's `headless-runner` plugin (config `{task}`). The runner drives one task turn through the in-process API carrier (`InProcessApiClient` over `toFetchHandler(ctx.apiProxy)`, so the full wire chain — serialization, zod, SSE framing — really runs), waits at idle until that mux has consumed the session's final event sequence, aggregates the turn's final assistant text, writes it to stdout, and requests exit (completed → 0, else 1) through the launcher-provided `ctx.headlessIo` seam. The Web composition stays mounted, so the running session is observable in a browser at the stderr-announced URL. The launcher patches the task text in (`dsh run "task"`), and fails loud when the selected profile lacks this row. ## Model Experience diff --git a/packages/bundle/headless/README.zh.md b/packages/bundle/headless/README.zh.md index 99a64ef04c..a6b91a8e60 100644 --- a/packages/bundle/headless/README.zh.md +++ b/packages/bundle/headless/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) + [`dsh-web-app`](../web-app/README.md) 之上:把 webserver 移到 OS 分配的端口(并行运行绝不冲突),关闭 URL 行输出,并插入本包的 `headless-runner` 插件(配置为 `{task}`)。runner 通过进程内 API 载体(架在 `toFetchHandler(ctx.apiProxy)` 之上的 `InProcessApiClient`,因此序列化、zod、SSE(Server-Sent Events)帧封装这整条 wire 链路都会真实运行)驱动一个任务轮次,聚合该轮次最终的 assistant 文本,写到 stdout,再经启动器提供的 `ctx.headlessIo` seam 请求退出(完成 → 0,否则 1)。Web 组合保持挂载,因此运行中的会话可在浏览器中通过 stderr 公告的 URL 观察。启动器把任务文本 patch 进来(`dsh --profile headless "task"`);如果向没有这一行的 profile 传入任务,则大声失败。 +dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) + [`dsh-web-app`](../web-app/README.md) 之上:把 webserver 移到 OS 分配的端口(并行运行绝不冲突),关闭 URL 行输出,并插入本包的 `headless-runner` 插件(配置为 `{task}`)。runner 通过进程内 API 载体(架在 `toFetchHandler(ctx.apiProxy)` 之上的 `InProcessApiClient`,因此序列化、zod、SSE(Server-Sent Events)帧封装这整条 wire 链路都会真实运行)驱动一个任务轮次,在 idle 时等待该 mux 消费完会话的最终事件序号,再聚合该轮次最终的 assistant 文本,写到 stdout,并经启动器提供的 `ctx.headlessIo` seam 请求退出(完成 → 0,否则 1)。Web 组合保持挂载,因此运行中的会话可在浏览器中通过 stderr 公告的 URL 观察。启动器把任务文本 patch 进来(`dsh run "task"`);若所选 profile 缺少该行,则显式报错。 ## 模型体验 diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts index 572f11b487..8db505c3ac 100644 --- a/packages/bundle/headless/src/index.ts +++ b/packages/bundle/headless/src/index.ts @@ -6,8 +6,7 @@ * (InProcessApiClient over toFetchHandler(ctx.apiProxy), so the full wire * chain — serialization, zod, SSE framing — really runs), prints the final * assistant text at agent quiescence, and exits (completed → 0, else 1). The - * task text arrives as launcher-patched config - * (`dsh --profile headless "task"`). + * task text arrives as launcher-patched config (`dsh run "task"`). * @module @deepseek-ai/dsh-headless */ @@ -86,26 +85,31 @@ async function unwrap(response: RpcResponse, io: HeadlessIo): Promise { * `agent/status` subscription; the stream itself carries no status frame. * @param frames - the mux stream opened before the prompt. * @param sessionId - the headless session. - * @param idle - resolves when the agent reaches quiescence. + * @param idle - resolves to the final session-event sequence when the agent reaches quiescence. * @param io - process-facing effects for stream diagnostics. * @returns the aggregated outcome. */ async function consumeUntilIdle( frames: AsyncIterable>, sessionId: SessionId, - idle: Promise, + idle: Promise, io: HeadlessIo, ): Promise { let started = false let text = '' let reason: string = 'error' - void (async () => { + let observedSeq = -1 + let resolveProgress: (() => void) | undefined + const streamDone = (async () => { try { for await (const frame of frames) { const payload = frame.payload if (payload.type === 'stream/error') return if (payload.type !== 'session/event' || payload.sessionId !== sessionId) continue const event = payload.event + observedSeq = event.seq + resolveProgress?.() + resolveProgress = undefined if (event.type === 'turn/start') { started = true continue @@ -121,7 +125,12 @@ async function consumeUntilIdle( io.stderr.write(`dsh: event stream failed: ${String(error)}\n`) } })() - await idle + const streamEnded = streamDone.then(() => 'ended' as const) + const idleSeq = await idle + while (observedSeq < idleSeq) { + const progress = new Promise<'progress'>((resolve) => { resolveProgress = () => { resolve('progress') } }) + if (await Promise.race([progress, streamEnded]) === 'ended') break + } return { text, reason } } @@ -154,9 +163,9 @@ export function apply(ctx: Context, config: Config): void { // port of this runner must replace it with a wire-visible idle signal. const abort = new AbortController() const frames = api.events.mux({}, abort.signal) - const idle = new Promise((resolve) => { + const idle = new Promise((resolve) => { ctx.on('agent/status', ({ agent, status }) => { - if (agent.id === created.sessionId && status === 'idle') resolve() + if (agent.id === created.sessionId && status === 'idle') resolve(agent.session.seq - 1) }) }) const done = consumeUntilIdle(frames, created.sessionId, idle, io) diff --git a/packages/bundle/headless/tests/headless.spec.ts b/packages/bundle/headless/tests/headless.spec.ts index 6ff96619ff..9408ee62bf 100644 --- a/packages/bundle/headless/tests/headless.spec.ts +++ b/packages/bundle/headless/tests/headless.spec.ts @@ -21,26 +21,45 @@ function stamped(event: ScriptedEvent): ScriptedEvent { interface RpcShapedRequest { rpcId: string } +interface ScriptedApiOptions { + promptFails?: boolean + framesAfterPrompt?: boolean + onPrompt?: () => void +} + /** Build a fake apiProxy (echoing rpcIds like the real gateway) whose mux stream replays `events` for the created session. */ -function scriptedApi(events: ScriptedEvent[], options: { promptFails?: boolean } = {}): unknown { +function scriptedApi(events: ScriptedEvent[], options: ScriptedApiOptions = {}): unknown { + let releaseFrames = (): void => {} + const framesReady = options.framesAfterPrompt === true + ? new Promise((resolve) => { releaseFrames = resolve }) + : Promise.resolve() + const prepared = events.map((event) => { + if (event.type === 'stream/error') return { streamError: true } as const + const { sessionId = 'S1', ...rest } = event + return { streamError: false, sessionId, event: stamped(rest) } as const + }) return { sessions: { create: (request: RpcShapedRequest) => Promise.resolve({ rpcId: request.rpcId, result: { ok: true, value: { sessionId: 'S1' } } }), - prompt: (request: RpcShapedRequest) => Promise.resolve(options.promptFails === true - // A code from the closed wire union: the carrier schema rejects invented codes. - ? { rpcId: request.rpcId, result: { ok: false, error: { code: 'agent-busy', message: 'agent is busy', details: { reason: 'test' } } } } - : { rpcId: request.rpcId, result: { ok: true, value: { accepted: true } } }), + prompt: (request: RpcShapedRequest) => { + releaseFrames() + options.onPrompt?.() + return Promise.resolve(options.promptFails === true + // A code from the closed wire union: the carrier schema rejects invented codes. + ? { rpcId: request.rpcId, result: { ok: false, error: { code: 'agent-busy', message: 'agent is busy', details: { reason: 'test' } } } } + : { rpcId: request.rpcId, result: { ok: true, value: { accepted: true } } }) + }, }, events: { mux: async function* () { - for (const event of events) { - if (event.type === 'stream/error') { + await framesReady + for (const item of prepared) { + if (item.streamError) { yield { rpcId: 'e', payload: { type: 'stream/error', error: { code: 'cancelled', message: 'stream broke', details: {} } } } continue } - const { sessionId = 'S1', ...rest } = event - yield { rpcId: 'e', payload: { type: 'session/event', sessionId, event: stamped(rest) } } + yield { rpcId: 'e', payload: { type: 'session/event', sessionId: item.sessionId, event: item.event } } } }, }, @@ -51,7 +70,10 @@ function scriptedApi(events: ScriptedEvent[], options: { promptFails?: boolean } * Mount the runner against a scripted API, emit the idle transition after the * scripted frames drain, and wait for its exit request. */ -async function run(events: ScriptedEvent[], options: { promptFails?: boolean } = {}): Promise<{ code: number; out: string; err: string }> { +async function run( + events: ScriptedEvent[], + options: { promptFails?: boolean; framesAfterPrompt?: boolean; idleInPrompt?: boolean } = {}, +): Promise<{ code: number; out: string; err: string }> { const ctx = new Context() let out = '' let err = '' @@ -63,16 +85,25 @@ async function run(events: ScriptedEvent[], options: { promptFails?: boolean } = } ctx.provide('headlessIo', io) }) - ctx.provide('apiProxy', scriptedApi(events, options) as never) + const emitIdle = (): void => { + ctx.emit('agent/status', { agent: { id: 'S1', session: { seq: nextSeq + 1 } } as Agent, status: 'idle' }) + } + ctx.provide('apiProxy', scriptedApi(events, { + ...options.promptFails === undefined ? {} : { promptFails: options.promptFails }, + ...options.framesAfterPrompt === undefined ? {} : { framesAfterPrompt: options.framesAfterPrompt }, + ...options.idleInPrompt === true ? { onPrompt: emitIdle } : {}, + }) as never) ctx.provide('httpServer', { port: 12345 } as never) apply(ctx, { task: 'do the thing' }) // Quiescence is out of band: give the scripted stream a beat to drain, then // flip the agent idle exactly as the loop would. Foreign agents and // non-idle transitions must not settle the run. - await new Promise(resolve => setTimeout(resolve, 10)) - ctx.emit('agent/status', { agent: { id: 'OTHER' } as Agent, status: 'idle' }) - ctx.emit('agent/status', { agent: { id: 'S1' } as Agent, status: 'running' }) - ctx.emit('agent/status', { agent: { id: 'S1' } as Agent, status: 'idle' }) + if (options.idleInPrompt !== true) { + await new Promise(resolve => setTimeout(resolve, 10)) + ctx.emit('agent/status', { agent: { id: 'OTHER' } as Agent, status: 'idle' }) + ctx.emit('agent/status', { agent: { id: 'S1' } as Agent, status: 'running' }) + emitIdle() + } const code = await exited await ctx.fiber.dispose() return { code, out, err } @@ -106,6 +137,15 @@ describe('headless runner', () => { expect(err).toContain('observing at http://127.0.0.1:12345') }) + it('consumes through the idle sequence when queued frames arrive after the status transition', async () => { + const { code, out } = await run( + [messageTurn, text(1, 'race-free answer'), end(1, 'completed')], + { framesAfterPrompt: true, idleInPrompt: true }, + ) + expect(code).toBe(0) + expect(out).toBe('race-free answer\n') + }) + it('exits 1 when the final turn ends for any other reason', async () => { const { code } = await run([messageTurn, end(1, 'aborted')]) expect(code).toBe(1) @@ -168,7 +208,7 @@ describe('headless runner', () => { ctx.provide('httpServer', { port: 1 } as never) apply(ctx, { task: 't' }) await new Promise(resolve => setTimeout(resolve, 10)) - ctx.emit('agent/status', { agent: { id: 'S1' } as Agent, status: 'idle' }) + ctx.emit('agent/status', { agent: { id: 'S1', session: { seq: nextSeq + 1 } } as Agent, status: 'idle' }) expect(await exited).toBe(1) expect(err).toContain('event stream failed') await ctx.fiber.dispose() diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index dc3212a36c..739f045ce6 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -35,11 +35,6 @@ # once the web UI owns the choice per session. mode: !!js process.env.DSH_TOOLS_MODE -- id: llm-deepseek - config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL - # ── web-only host rows, the transport layer, and the browser roster ───────── # `dshClient` rows are the browser roster the modules node half scans into diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 776d21fd46..a6cf9c1d7c 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2449,7 +2449,8 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { if (missing !== undefined) return missing return ok(request, { skills: [ - { name: 'fixture-demo', description: 'fixture 技能样本', whenToUse: '仅供 UI 目录渲染验收' }, + { name: 'fixture-demo', description: 'fixture 技能样本', whenToUse: '仅供 UI 目录渲染验收', modelInvocable: true }, + { name: 'fixture-user-only', description: 'fixture 仅用户技能样本', modelInvocable: false }, ], }) }, diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index cc1062843e..cc4504e538 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -163,6 +163,7 @@ export class FakeApiClient implements IApiClient { onSkillList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ skills: [] })) + readonly commands: IApiClient['commands'] = { list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)), execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)), diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 3f5bd9263b..3d0d5dd2bd 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/runtime/README.md -README.md: 767352a0682f16abcbfce3c226cda790adcc8011 -README.zh.md: 791a74691cd20705614ac782d6b55d9af290955c +README.md: 8b96ef38c67e4548b3a7d274d8154509ad1a24c2 +README.zh.md: a9e8e54f2e86e098b6e3fb41ddaae800bfa465cf diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 767352a068..8b96ef38c6 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -24,6 +24,8 @@ Workspace and Session lists have independent monotone `pending` → `ready` base SlotsService gives the renderer separate bare observables for `useSessions` and `useWorkspaces`; web-react creates the hooks. Workspace business state does not enter `SessionListState` or an entry store. +`indexSubagentDescendants()` derives per-parent total and running descendant counts from the retained list mirror. It follows only uninterrupted `origin: 'subagent'` ancestry, so an ordinary fork starts a separate ownership subtree; cycles stop without throwing, and a missing parent remains a harmless key until its summary arrives. + `SessionsService.search(query, signal)` is a stateless one-shot action over the `session.search` RPC. It returns ranked session/snippet pairs without putting query, loading, or error state into the shared Session list, so each UI owner controls debounce, cancellation, stale-response suppression, and fallback presentation. `searchResultLimit` re-exposes `SESSION_SEARCH_RESULT_LIMIT` — the bound the response schema itself enforces — as injected presentation data, so client plugins do not duplicate it. It is a protocol constant rather than per-connection state, so the connection handle does not carry it. ## New Session and the blank mirror @@ -38,7 +40,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and `ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. `SteeringHistory` replays the durable `agent/inbox/spliced` records in that window: a user-origin message claimed from `next-step` becomes a `SteeringMessageNode` when its matching `user/message` lands, a `next-turn` claim stays a user node, and non-user next-step input stays context. `ConversationSnapshot.turnEnds` maps each completed turn in that window to its `turn/end` seq, retaining turn completion independently from the transcript so presentation can require a real boundary before enabling an action. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. Each context node also carries a `provenance` view: `contextProvenance()` reads the durable source alone to decide whether the row is an `inject` or a cross-session `recall`, and to name its producer from the instruction paths, referenced session titles, or plugin id that source already records. The client holds no table of plugin ids, so a renamed or newly mounted producer stays identifiable without a client release and a resumed or foreign log projects exactly like a live one; a source with no readable kind degrades to an unnamed injection. Beside it, `contextForm()` reads the producer-declared `ContextForm` — the second, independent axis: `kind` says who produced the context, `form` says what shape of information it is, so several producers may share one form. A form this UI version does not present projects as null and renders opaque. The adapter's plugin literal is pinned to the seam's own declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's). -Because the projection is log-ordered, the node array is seq-monotonic by construction: log-only `command/run` / `command/done` nodes splice in by seq, `Session` merges interrupted frozen nodes by their fractional seqs, and a window whose checkpoint cites a shadowed range outside it renders the marker with nothing logged. The marker's summary text comes from the checkpoint's `compact/summary` provenance; a window cut that left the provenance outside makes the row non-expandable rather than empty, and a later page that supplies it resolves the text. Performance contract: one append materializes at most one node and copies the projection only when it adds that node; an event that changes no node keeps the previous array reference (a chunk storm costs nothing), and unchanged nodes keep their object identity. +Because the projection is log-ordered, the node array is seq-monotonic by construction: log-only `command/run` / `command/done` nodes splice in by seq, `Session` merges interrupted frozen nodes by their fractional seqs, and a window whose checkpoint cites a shadowed range outside it renders the marker with nothing logged. The marker's summary text, replaced-item count, and estimated shadowed-token count come from the checkpoint's `compact/summary` provenance; a window cut that left the provenance outside makes those fields unavailable, and a later page that supplies it resolves them. `CommandNode.outcome.sourceEventSeq` preserves a successful command's explicit reference to that summary event, allowing the presentation layer to pair `/compact` with its checkpoint without parsing settlement copy or assuming the two rows are adjacent. Performance contract: one append materializes at most one node and copies the projection only when it adds that node; an event that changes no node keeps the previous array reference (a chunk storm costs nothing), and unchanged nodes keep their object identity. ## Request inspection diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 791a74691c..a9e8e54f2e 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -24,6 +24,8 @@ Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线 SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 observable;web-react 创建钩子。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。 +`indexSubagentDescendants()` 从保留的列表镜像中派生每个 parent 的后代总数与运行中后代数。它只沿不间断的 `origin: 'subagent'` 祖先链追踪,因此普通 fork 会开启独立的归属子树;遇到环时,追踪会停止但不会抛出异常,缺失的 parent 则会保留为无害的键,直至其摘要到达。 + `SessionsService.search(query, signal)` 是基于 `session.search` RPC 的无状态单次操作。它返回经过排序的会话/snippet 对,但不会将查询条件、加载状态或错误状态写入共享 Session 列表,因此每个 UI 所有者都自行负责防抖、取消、抑制陈旧响应和回退呈现。`searchResultLimit` 将 `SESSION_SEARCH_RESULT_LIMIT`——即响应 schema 自身强制执行的上限——作为注入的呈现数据重新公开,使客户端插件无需复制该值。它是协议常量而非逐连接状态,因此连接 handle 不携带它。 ## New Session 与 blank 镜像 @@ -38,7 +40,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 `ConversationSnapshot.nodes` 是面向人的 transcript,不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口。每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,每次落地的压缩(compaction)检查点还会贡献一个 `CompactionSummaryNode` 标记;适配器从不查询 surface 顺序。`SteeringHistory` 会重放该窗口中的持久 `agent/inbox/spliced` 记录:用户来源的消息从 `next-step` 被领取,并以相同身份落成 `user/message` 时,会投影为 `SteeringMessageNode`;从 `next-turn` 领取的消息仍是用户节点,非用户来源的 next-step 输入仍是上下文。`ConversationSnapshot.turnEnds` 把该窗口中的每个已完成轮次映射到其 `turn/end` seq;它独立于 transcript 保留轮次完成状态,使呈现层能够在启用操作前要求存在真实边界。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。每个上下文节点还携带一份 `provenance` 视图:`contextProvenance()` 只读取持久来源,据此判定该行是 `inject`(注入)还是跨会话的 `recall`(召回),并用该来源已经记录的指令文件路径、被引用会话标题或插件 id 命名其生产者。客户端不保存任何插件 id 表,因此重命名或新挂载的生产者无需客户端发版即可保持可辨识,恢复的会话日志与外部日志的投影结果和实时会话完全一致;没有可读 kind 的来源则降级为无名注入。与之并列的 `contextForm()` 读取生产方声明的 `ContextForm`,这是相互独立的第二根轴:`kind` 说明上下文由谁产生,`form` 说明它是何种形态的信息,因此多个生产方可以共用一种形态。本 UI 版本不呈现的形态投影为 null,按 opaque 渲染。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败;而对该包(package)做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。 -由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本来自检查点的 `compact/summary` 溯源;窗口切分把溯源留在窗口外时该行不可展开而非空白,后续补上溯源的分页会解析出文本。性能契约:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。 +由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本、被替换条目数量和估算的被遮蔽 token 数量都来自检查点的 `compact/summary` 溯源;窗口切分把溯源留在窗口外时这些字段不可用,后续补上溯源的分页会解析出它们。`CommandNode.outcome.sourceEventSeq` 保留成功命令对该摘要事件的显式引用,使呈现层能够配对 `/compact` 与其检查点,而无须解析结算文案或假定两行相邻。性能契约:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。 ## 请求检查 diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json index f507cecf21..33b52aca33 100644 --- a/packages/client/runtime/package.json +++ b/packages/client/runtime/package.json @@ -25,7 +25,6 @@ "dshClient": { "inject": [ "@deepseek-ai/dsh-client-connection", - "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-typert-registry" ], "platform": "web", @@ -50,14 +49,12 @@ }, "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-api-remotes": "^0.0.1", "@deepseek-ai/dsh-type-meta": "^0.0.1", "@deepseek-ai/dsh-typert-registry": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-type-meta": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index c2b4409c33..c09b45f24d 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -1,7 +1,6 @@ /** Browser runtime services for slots, sessions, workspaces, and connection-stream delivery. */ import type { Context } from 'cordis' import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client' -import type {} from '@deepseek-ai/dsh-api-remotes/client' import type { TypeRTContext } from '@deepseek-ai/dsh-type-meta' import type { MaybeSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import { SlotsService } from './slots.ts' @@ -16,6 +15,8 @@ export { SlotsService } from './slots.ts' export type { RootOwnerProps } from './slots.ts' export { SessionCreateError, SessionsService, scopeOf, workspaceTitleOf } from './sessions/service.ts' export { SessionHistoryService } from './session-history/service.ts' +export { indexSubagentDescendants } from './sessions/subagent-lineage.ts' +export type { SubagentDescendantSummary } from './sessions/subagent-lineage.ts' // The provide channel is shared with the client test runtime (one // materialization/projection implementation; no test-side mirror to drift). export { SessionProvideChannel } from './sessions/provide.ts' @@ -181,8 +182,8 @@ declare module 'cordis' { } } -/** Required services: the Remote root, wire handle, and Client TypeRT registry. */ -export const inject = ['remote', 'connection', 'typert'] +/** Required services: the wire handle and Client TypeRT registry. */ +export const inject = ['connection', 'typert'] /** Mounts the browser runtime services and connection stream. * @param ctx - Client Cordis context. diff --git a/packages/client/runtime/src/client/sessions/context-provenance.ts b/packages/client/runtime/src/client/sessions/context-provenance.ts index 5d231b6bd8..6f46a510c1 100644 --- a/packages/client/runtime/src/client/sessions/context-provenance.ts +++ b/packages/client/runtime/src/client/sessions/context-provenance.ts @@ -83,6 +83,9 @@ export function contextProvenance(source: unknown): ContextProvenanceView { return { role: 'inject', label: joined(collect(record, 'changes', 'path')) ?? kind } case 'plugin': return { role: 'inject', label: readString(record, 'plugin') ?? kind } + // A user-explicit skill invocation names the skill it injected. + case 'skill-invocation': + return { role: 'inject', label: readString(record, 'name') ?? kind } // Documented default arm of the merge-extensible source map: an unknown // producer still identifies itself by its own durable kind. default: diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index fb2c281331..a881158525 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -192,6 +192,12 @@ export interface CompactionSummaryNode { /** Summary text from the checkpoint's `compact/summary` provenance; null when * the window cut left that provenance outside (the marker is then not expandable). */ summary: string | null + /** Seq of the loaded `compact/summary` event, or null when that provenance is outside the window. */ + summaryEventSeq: number | null + /** Number of surface items replaced, or null when summary provenance is unavailable or malformed. */ + shadowedItemCount: number | null + /** Estimated token price of the replaced items, or null when summary provenance is unavailable or malformed. */ + shadowedTokenCount: number | null } /** @@ -236,7 +242,12 @@ export interface CommandNode { */ args: string | null /** Settlement outcome (done payload); null while the command is still executing. */ - outcome: { kind: 'success' | 'error'; text?: string } | null + outcome: { + kind: 'success' | 'error' + text?: string + /** Earlier authoritative domain event for a richer client-computed presentation. */ + sourceEventSeq?: number + } | null } /** Finalized conversation node union (kind discriminates; seq is the React key). */ diff --git a/packages/client/runtime/src/client/sessions/subagent-lineage.ts b/packages/client/runtime/src/client/sessions/subagent-lineage.ts new file mode 100644 index 0000000000..fb56b4eb8c --- /dev/null +++ b/packages/client/runtime/src/client/sessions/subagent-lineage.ts @@ -0,0 +1,50 @@ +/** + * Pure subagent-lineage aggregation over the retained session-list mirror. + * Ordinary forks terminate propagation so each visible session owns only its + * uninterrupted subagent subtree. + * @module @deepseek-ai/dsh-client-runtime/client/sessions/subagent-lineage + */ +import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionSummary } from './service.ts' + +/** Descendant counts projected for one possible parent session. */ +export interface SubagentDescendantSummary { + /** All descendants connected through uninterrupted subagent-origin lineage. */ + readonly count: number + /** Descendants whose exact session summary is currently running. */ + readonly runningCount: number +} + +/** + * Index every subagent descendant under each ancestor it reaches through an + * uninterrupted subagent-origin chain. Cycles fail soft and orphan owners + * remain harmless map keys until their summaries arrive. + * @param summaries - retained session summaries keyed by id. + * @returns descendant totals and running totals keyed by possible parent id. + */ +export function indexSubagentDescendants( + summaries: Readonly>, +): ReadonlyMap { + const indexed = new Map() + for (const descendant of Object.values(summaries)) { + if (descendant.origin !== 'subagent') continue + const seen = new Set() + let current: SessionSummary | undefined = descendant + while (current?.origin === 'subagent' && current.parentId !== undefined + && !seen.has(current.id)) { + seen.add(current.id) + const aggregate = indexed.get(current.parentId) + if (aggregate === undefined) { + indexed.set(current.parentId, { + count: 1, + runningCount: descendant.running ? 1 : 0, + }) + } else { + aggregate.count += 1 + if (descendant.running) aggregate.runningCount += 1 + } + current = summaries[current.parentId] + } + } + return indexed +} diff --git a/packages/client/runtime/src/client/sessions/transcript-adapter.ts b/packages/client/runtime/src/client/sessions/transcript-adapter.ts index d970be596b..ae7643f0de 100644 --- a/packages/client/runtime/src/client/sessions/transcript-adapter.ts +++ b/packages/client/runtime/src/client/sessions/transcript-adapter.ts @@ -57,10 +57,11 @@ function materializeNode( stepTimings: ReadonlyMap, ): ConversationNode { switch (event.type) { - case 'user/message': - // Injected context (plugin/goal source) folds to a context node, not a - // user message; only a direct human prompt is a user node. A compaction - // checkpoint never reaches here (isCompactCheckpoint routes it away). + case 'user/message': { + // Injected context (plugin/goal/skill-invocation source) folds to a + // context node, not a user message; only a direct human prompt is a + // user node. A compaction checkpoint never reaches here + // (isCompactCheckpoint routes it away). if (event.data.source.kind !== 'user') { return { kind: 'context', seq: event.seq, time: event.time, @@ -80,6 +81,7 @@ function materializeNode( kind: 'user', seq: event.seq, time: event.time, content: event.data.content, source: event.data.source, } + } case 'assistant/message': return { kind: 'assistant', seq: event.seq, time: event.time, @@ -156,6 +158,29 @@ function compactSummaryText(event: SessionEvent): string | null { return text.trim() === '' ? null : text } +interface CompactSummaryDetails { + readonly summary: string | null + readonly shadowedItemCount: number | null + readonly shadowedTokenCount: number | null +} + +/** Recover human-facing summary material from one structurally narrowed wire event. */ +function compactSummaryDetails(event: SessionEvent): CompactSummaryDetails { + const data = event.data as unknown as { shadowedSeqs?: unknown; shadowedTokenCount?: unknown } + const shadowedSeqs = data.shadowedSeqs + const tokenCount = data.shadowedTokenCount + return { + summary: compactSummaryText(event), + shadowedItemCount: Array.isArray(shadowedSeqs) + && shadowedSeqs.every((seq: unknown) => Number.isSafeInteger(seq) && (seq as number) >= 0) + ? shadowedSeqs.length + : null, + shadowedTokenCount: Number.isSafeInteger(tokenCount) && (tokenCount as number) >= 0 + ? tokenCount as number + : null, + } +} + /** * One landed checkpoint -> the human-facing compaction marker. The summary text * comes from the checkpoint's own provenance (`sourceEventSeqs` names the @@ -170,13 +195,28 @@ function materializeCompaction( ): CompactionSummaryNode { const sources = (checkpoint as SessionEvent & { sourceEventSeqs?: number[] }).sourceEventSeqs let summary: string | null = null + let summaryEventSeq: number | null = null + let shadowedItemCount: number | null = null + let shadowedTokenCount: number | null = null for (const seq of sources ?? []) { const candidate = eventIndex.get(seq) if (candidate === undefined || (candidate.type as string) !== 'compact/summary') continue - summary = compactSummaryText(candidate) + const details = compactSummaryDetails(candidate) + summary = details.summary + summaryEventSeq = candidate.seq + shadowedItemCount = details.shadowedItemCount + shadowedTokenCount = details.shadowedTokenCount break } - return { kind: 'compaction', seq: checkpoint.seq, time: checkpoint.time, summary } + return { + kind: 'compaction', + seq: checkpoint.seq, + time: checkpoint.time, + summary, + summaryEventSeq, + shadowedItemCount, + shadowedTokenCount, + } } /** Log-ordered human transcript over a paged raw event window (never consults surface order). */ @@ -321,9 +361,22 @@ export class TranscriptAdapter { return true } if ((event.type as string) !== 'command/done') return false - const data = event.data as unknown as { commandId: CommandId; kind: 'success' | 'error'; text?: string } + const data = event.data as unknown as { + commandId: CommandId + kind: 'success' | 'error' + text?: string + sourceEventSeq?: number + } const run = this.commandIdx.get(data.commandId) - const outcome = { kind: data.kind, ...data.text === undefined ? {} : { text: data.text } } + const sourceEventSeq = data.kind === 'success' + && Number.isSafeInteger(data.sourceEventSeq) && (data.sourceEventSeq as number) >= 0 + ? data.sourceEventSeq as number + : undefined + const outcome = { + kind: data.kind, + ...data.text === undefined ? {} : { text: data.text }, + ...sourceEventSeq === undefined ? {} : { sourceEventSeq }, + } if (run === undefined) { // Cross-window cut: the run page fell out of the window — build the // node from the done alone (same soft-fall as a call-less tool result). diff --git a/packages/client/runtime/src/client/slots.ts b/packages/client/runtime/src/client/slots.ts index 3698f62aab..9da8755b2d 100644 --- a/packages/client/runtime/src/client/slots.ts +++ b/packages/client/runtime/src/client/slots.ts @@ -19,7 +19,7 @@ import type { Context } from 'cordis' import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots' import type { LocaleFace, OwnerOf, SlotEntryDef, SlotMap, SlotRenderer, SlotRendererHost, - SlotScope, SlotSpec, StoreDecl, StoredEntry, StoreInstanceLike, + SlotScope, SlotSpec, StoreDecl, StoreFactory, StoredEntry, StoreInstanceLike, } from '@deepseek-ai/dsh-client-ui-slots' declare module '@deepseek-ai/dsh-client-ui-slots' { @@ -35,16 +35,11 @@ export interface RootOwnerProps { children?: never } /** Instance key for root-scoped store records (session records key by session id, so the literal cannot collide). */ const ROOT_INSTANCE_KEY = 'root' -// FIXME(slot-parity): the engine's arbitrated persist extensions — create() -// takes the scope key (per-session localStorage suffix) and instances expose -// clearPersisted() — are not yet on ui-slots' StoreHandle/StoreInstanceLike; -// these local structural faces bridge until fw-slots lifts them. +/** Canonical type-erased store handle used by the runtime lifecycle map. */ +type EngineStoreHandle = Exclude -/** Store handle face as the engine actually ships it (scope-key-aware create). */ -interface EngineStoreHandle { create(scopeKey?: string): EngineStoreInstance } - -/** Engine instance face: the host-contract shape plus persisted-state cleanup. */ -interface EngineStoreInstance extends StoreInstanceLike { clearPersisted(): void } +/** Canonical engine instance derived from the handle's create contract. */ +type EngineStoreInstance = ReturnType /** Store axis record: one per live handle, dropped when the last holding entry unloads. */ interface StoreAxisRecord { diff --git a/packages/client/runtime/tests/compact-checkpoint-pin.spec.ts b/packages/client/runtime/tests/compact-checkpoint-pin.spec.ts index ddc6c8adc5..aed658af51 100644 --- a/packages/client/runtime/tests/compact-checkpoint-pin.spec.ts +++ b/packages/client/runtime/tests/compact-checkpoint-pin.spec.ts @@ -36,7 +36,10 @@ describe('compaction checkpoint recognition', () => { it('recognizes a checkpoint carrying the seam-canonical source', () => { const adapter = new TranscriptAdapter() adapter.reset([canonicalCheckpoint(1)]) - expect(adapter.nodes()).toEqual([{ kind: 'compaction', seq: 1, time: 1_700_000_000_001, summary: null }]) + expect(adapter.nodes()).toEqual([{ + kind: 'compaction', seq: 1, time: 1_700_000_000_001, summary: null, + summaryEventSeq: null, shadowedItemCount: null, shadowedTokenCount: null, + }]) }) it("agrees with the seam's own predicate on the source it recognizes", () => { diff --git a/packages/client/runtime/tests/event-script.ts b/packages/client/runtime/tests/event-script.ts index 51e982e8f4..bc3c10e762 100644 --- a/packages/client/runtime/tests/event-script.ts +++ b/packages/client/runtime/tests/event-script.ts @@ -92,8 +92,19 @@ export const ev = { at(seq, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }), commandRunWithoutInput: (seq: number, commandId: string, name: string): SessionEvent => at(seq, { type: 'command/run', data: { commandId, name, source: { kind: 'user' } } }), - commandDone: (seq: number, commandId: string, kind: 'success' | 'error' = 'success', text?: string): SessionEvent => - at(seq, { type: 'command/done', data: { commandId, kind, ...text === undefined ? {} : { text } } }), + commandDone: ( + seq: number, + commandId: string, + kind: 'success' | 'error' = 'success', + text?: string, + sourceEventSeq?: number, + ): SessionEvent => + at(seq, { type: 'command/done', data: { + commandId, + kind, + ...text === undefined ? {} : { text }, + ...sourceEventSeq === undefined ? {} : { sourceEventSeq }, + } }), /** A compaction's log-only `compact/summary` provenance record. */ compactSummary: (seq: number, summary: string, start: number, end: number): SessionEvent => at(seq, { type: 'compact/summary', data: { diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index b6f2884837..2f4299ce6c 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -198,6 +198,7 @@ export class FakeApiClient implements IApiClient { onSkillList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ skills: [] })) + readonly commands: IApiClient['commands'] = { list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)), execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)), diff --git a/packages/client/runtime/tests/subagent-lineage.spec.ts b/packages/client/runtime/tests/subagent-lineage.spec.ts new file mode 100644 index 0000000000..05881576bf --- /dev/null +++ b/packages/client/runtime/tests/subagent-lineage.spec.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client' +import { indexSubagentDescendants } from '@deepseek-ai/dsh-client-runtime/client' + +const sid = (id: string) => id as SessionId + +function summary( + id: string, + parentId?: SessionId, + origin?: 'subagent', + running = false, +): SessionSummary { + return { + id: sid(id), displayTitle: id, running, blank: false, updatedAt: 0, + ...(parentId === undefined ? {} : { parentId }), + ...(origin === undefined ? {} : { origin }), + } +} + +function index(...summaries: SessionSummary[]) { + return indexSubagentDescendants(Object.fromEntries( + summaries.map(item => [item.id, item]), + )) +} + +describe('indexSubagentDescendants', () => { + it('counts every nested descendant and its exact running state', () => { + const owner = summary('owner') + const child = summary('child', owner.id, 'subagent') + const grandchild = summary('grandchild', child.id, 'subagent', true) + + const result = index(owner, child, grandchild) + expect(result.get(owner.id)).toEqual({ count: 2, runningCount: 1 }) + expect(result.get(child.id)).toEqual({ count: 1, runningCount: 1 }) + }) + + it('stops at ordinary forks and fails soft on cycles and missing parents', () => { + const owner = summary('owner') + const child = summary('child', owner.id, 'subagent', true) + const fork = summary('fork', child.id) + const forkChild = summary('fork-child', fork.id, 'subagent', true) + const orphan = summary('orphan', sid('missing'), 'subagent', true) + const cycleA = summary('cycle-a', sid('cycle-b'), 'subagent') + const cycleB = summary('cycle-b', sid('cycle-a'), 'subagent') + + const result = index(owner, child, fork, forkChild, orphan, cycleA, cycleB) + expect(result.get(owner.id)).toEqual({ count: 1, runningCount: 1 }) + expect(result.get(fork.id)).toEqual({ count: 1, runningCount: 1 }) + expect(result.get(sid('missing'))).toEqual({ count: 1, runningCount: 1 }) + expect(result.get(cycleA.id)).toEqual({ count: 2, runningCount: 0 }) + expect(result.get(cycleB.id)).toEqual({ count: 2, runningCount: 0 }) + }) +}) diff --git a/packages/client/runtime/tests/transcript-adapter.spec.ts b/packages/client/runtime/tests/transcript-adapter.spec.ts index e4ef3b0e1a..05dc2855ca 100644 --- a/packages/client/runtime/tests/transcript-adapter.spec.ts +++ b/packages/client/runtime/tests/transcript-adapter.spec.ts @@ -164,6 +164,28 @@ describe('TranscriptAdapter', () => { expect(adapter.nodes().map(node => node.kind)).toEqual(['user', 'user', 'context']) }) + it('materializes a skill-invocation injection as a named instructions context', () => { + const adapter = new TranscriptAdapter() + adapter.reset([ + at(0, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({ + content: [{ type: 'text', text: '/hidden-demo check the fixture' }], + source: { kind: 'user' }, + }) }), + at(1, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({ + content: [{ type: 'text', text: 'body' }], + source: { kind: 'skill-invocation', name: 'hidden-demo', form: 'instructions' } as never, + }) }), + ]) + const nodes = adapter.nodes() + // The gesture stays a user bubble; the injected body folds to a context + // row named after the skill, presented as instructions. + expect(nodes.map(node => node.kind)).toEqual(['user', 'context']) + expect(nodes[1]).toMatchObject({ + provenance: { role: 'inject', label: 'hidden-demo' }, + form: 'instructions', + }) + }) + it('skips events core does not call surface-eligible, marker or not', () => { // The transcript is the append-origin surface, so log-only events (a chunk, // a turn boundary, a compact/* provenance record) and a future type core @@ -223,8 +245,14 @@ describe('TranscriptAdapter', () => { checkpoint(5, 4, { start: 2, end: 3, sourceEventSeqs: [4, 2, 3] }), ]) expect(adapter.nodes().filter(n => n.kind === 'compaction')).toEqual([ - { kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: 'first' }, - { kind: 'compaction', seq: 5, time: 1_700_000_000_005, summary: 'second' }, + { + kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: 'first', + summaryEventSeq: 1, shadowedItemCount: 2, shadowedTokenCount: 100, + }, + { + kind: 'compaction', seq: 5, time: 1_700_000_000_005, summary: 'second', + summaryEventSeq: 4, shadowedItemCount: 2, shadowedTokenCount: 100, + }, ]) }) @@ -296,7 +324,7 @@ describe('TranscriptAdapter', () => { ...(summary === undefined ? [] : [summary]), checkpoint(2, 1, { start: 0, end: 0, sourceEventSeqs: [1, 0] }), ]) - expect(adapter.nodes()).toEqual([ + expect(adapter.nodes()).toMatchObject([ { kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: null }, ]) }) @@ -310,7 +338,10 @@ describe('TranscriptAdapter', () => { checkpoint(2, 1, { start: 0, end: 0, sourceEventSeqs: [1, 0] }), ]) expect(adapter.nodes()).toEqual([ - { kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: '可用摘要' }, + { + kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: '可用摘要', + summaryEventSeq: 1, shadowedItemCount: 2, shadowedTokenCount: 100, + }, ]) }) @@ -324,7 +355,10 @@ describe('TranscriptAdapter', () => { source: { kind: 'plugin', plugin: 'compact' }, }), })]) - expect(adapter.nodes()).toEqual([{ kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: null }]) + expect(adapter.nodes()).toEqual([{ + kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: null, + summaryEventSeq: null, shadowedItemCount: null, shadowedTokenCount: null, + }]) }) it('skips a non-summary provenance seq before reaching the real one', () => { @@ -468,20 +502,22 @@ describe('TranscriptAdapter', () => { expect(adapter.nodes().map(n => n.kind)).toEqual(['user', 'command']) }) - it('renders the /compact row alongside the marker its own command produced', () => { - // The row that reports the compaction is a command node; dropping command - // folding would delete it together with every other slash-command row. + it('preserves the domain-event link for the UI to fold a /compact row into its marker', () => { const adapter = new TranscriptAdapter() adapter.reset([ ev.user(0, '压缩前的问题'), ev.commandRun(1, 'cmd-compact', 'compact'), compactSummary(2, [{ type: 'text', text: '手动压缩摘要' }]), checkpoint(3, 2, { start: 0, end: 0, sourceEventSeqs: [2, 0] }), - ev.commandDone(4, 'cmd-compact', 'success', '已压缩'), + ev.commandDone(4, 'cmd-compact', 'success', '已压缩', 2), ]) const nodes = adapter.nodes() expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 0], ['command', 1], ['compaction', 3]]) - expect(nodes[1]).toMatchObject({ name: 'compact', outcome: { kind: 'success', text: '已压缩' } }) + expect(nodes[1]).toMatchObject({ + name: 'compact', + outcome: { kind: 'success', text: '已压缩', sourceEventSeq: 2 }, + }) + expect(nodes[2]).toMatchObject({ kind: 'compaction', summaryEventSeq: 2 }) }) }) diff --git a/packages/client/runtime/tsconfig.json b/packages/client/runtime/tsconfig.json index 2a1ea33b29..47920af0bc 100644 --- a/packages/client/runtime/tsconfig.json +++ b/packages/client/runtime/tsconfig.json @@ -23,9 +23,6 @@ { "path": "../schema-form" }, - { - "path": "../../api/remotes" - }, { "path": "../../host/apiproxy" }, diff --git a/packages/client/schema-form/tsdown.config.ts b/packages/client/schema-form/tsdown.config.ts new file mode 100644 index 0000000000..b03542c74e --- /dev/null +++ b/packages/client/schema-form/tsdown.config.ts @@ -0,0 +1,6 @@ +import { clientLibrary } from '../tsdown.client.ts' + +export default clientLibrary( + '@deepseek-ai/dsh-client-schema-form', + ['lib/types/index.js', 'lib/types/invariant.js'], +) diff --git a/packages/client/test-runtime/tsdown.config.ts b/packages/client/test-runtime/tsdown.config.ts new file mode 100644 index 0000000000..e2cb484ffb --- /dev/null +++ b/packages/client/test-runtime/tsdown.config.ts @@ -0,0 +1,6 @@ +import { clientLibrary } from '../tsdown.client.ts' + +export default clientLibrary( + '@deepseek-ai/dsh-client-test-runtime', + ['lib/types/index.js', 'lib/types/invariant.js'], +) diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index 74facbd69b..29b5a5f3b6 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -9,6 +9,7 @@ * The virtual loader registers each real stylesheet as a watch dependency. */ import { readFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' import { basename, dirname, relative, resolve as resolvePath, sep } from 'node:path' import { fileURLToPath } from 'node:url' import type { UserConfig } from 'tsdown' @@ -34,6 +35,12 @@ export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools| /** Generated descriptor/codec contribution with no shared runtime identity. */ const GENERATED_REMOTE = /^@deepseek-ai\/dsh-[a-z0-9]+(?:-[a-z0-9]+)*\/remote$/ +/** + * Workspace mode replaces an empty config array with the root defaults. A + * falsey entry instead removes this package before entry resolution. + */ +const SKIP_WORKSPACE_BUILD: UserConfig = { entry: '' } + /** * Documented TEMPORARY exemption, not a platform module (hence not in * platform.ts): the snapshot-store engine (createSnapshotStore/defineStore/ @@ -61,19 +68,85 @@ function browserSourcePath(source: string, sourcemapPath: string): string { /** * Build the tsdown config for one UI plugin package: the node-half lib build - * plus the browser client bundle. A package-level tsdown.config.ts REPLACES - * the root workspace shape, so the lib half must be restated here — dropping - * it leaves the package without lib/index.js and the host Loader cannot - * import its node half. + * plus the browser client bundle. Client packages emit both halves during the + * Client pass by default; packages needed for Host reflection may opt into the + * earlier Host pass. A package-level tsdown.config.ts REPLACES the root + * workspace shape, so the lib half must be restated here — dropping it leaves + * the package without lib/index.js and the host Loader cannot import its node + * half. * @param id - plugin id (package name), stamped into the __ModuleLoader__.load * handoff and onto the injected style tags. * @param libEntry - node-half entries, spelled at the call site so the * package-invariants gate can see `lib/types/invariant.js` in each package's * own tsdown.config.ts (a preset-side glob hides it from the mechanical check). - * @returns tsdown user configs emitting lib/*.js and lib/client.js. + * @param options - phase placement, lib overrides, and companion Node configs. + * @returns ENV-selected tsdown config for the current build face. */ -export function clientBundle(id: string, libEntry: readonly string[]): [UserConfig, UserConfig] { - return [{ +export function clientBundle( + id: string, + libEntry: readonly string[], + options: ClientBundleOptions = {}, +): BuildFaceConfig { + const lib = clientLibraryConfig(id, libEntry, options.lib) + return ({ env }) => { + const face = buildFace(env?.DSH_BUILD_FACE) + const client = clientConfig(id, face === undefined + ? 'src/client/index.ts' + : 'lib/types/client/index.js') + const node = [lib, ...(options.companions ?? [])] + if (face === 'host') return options.hostPhase === true ? node : [SKIP_WORKSPACE_BUILD] + if (face === 'client') return options.hostPhase === true ? [client] : [...node, client] + return [...node, client] + } +} + +/** + * Build a Client-only Node library during the Client pass. + * @param id - Package name used in tsdown diagnostics. + * @param libEntry - Emitted JavaScript entries consumed from `lib/types`. + * @returns ENV-selected tsdown config for the Client build face. + */ +export function clientLibrary(id: string, libEntry: readonly string[]): BuildFaceConfig { + const lib = clientLibraryConfig(id, libEntry) + return clientOnly([lib]) +} + +/** + * Select arbitrary package-local configs only during the Client pass. + * @param configs - Node-side configs emitted after Client tsc. + * @returns ENV-selected tsdown config for the Client build face. + */ +export function clientOnly(configs: readonly UserConfig[]): BuildFaceConfig { + return ({ env }) => buildFace(env?.DSH_BUILD_FACE) === 'host' + ? [SKIP_WORKSPACE_BUILD] + : [...configs] +} + +interface ClientBundleOptions { + /** Emit the Node-side artifacts during the Host pass instead of the Client pass. */ + readonly hostPhase?: boolean + /** Additional Node-side configs emitted alongside the package library. */ + readonly companions?: readonly UserConfig[] + /** Overrides for the package's primary Node-side library config. */ + readonly lib?: UserConfig +} + +type BuildFace = 'host' | 'client' | undefined + +type BuildFaceConfig = (inlineConfig: Pick) => UserConfig[] + +function buildFace(value: unknown): BuildFace { + if (value === undefined || value === 'host' || value === 'client') return value + throw new Error(`tsdown: --env.DSH_BUILD_FACE must be host or client, received ${String(value)}`) +} + +function clientLibraryConfig( + id: string, + libEntry: readonly string[], + overrides: UserConfig = {}, +): UserConfig { + return { + name: id, entry: [...libEntry], outDir: 'lib', format: ['esm'], @@ -82,8 +155,14 @@ export function clientBundle(id: string, libEntry: readonly string[]): [UserConf fixedExtension: false, dts: false, clean: false, - }, { - entry: { client: 'src/client/index.ts' }, + ...overrides, + } +} + +function clientConfig(id: string, entry: string): UserConfig { + return { + name: `${id}/client`, + entry: { client: entry }, // Browser bundle lands next to the node half (single lib/ artifact dir; // the entryFileNames pin keeps it exactly lib/client.js). clean must stay // off — a default clean would wipe the node-half output emitted above. @@ -139,7 +218,7 @@ export function clientBundle(id: string, libEntry: readonly string[]): [UserConf name: 'dsh-css-modules-inline', resolveId(source: string, importer: string | undefined) { if (!source.endsWith('.module.css')) return null - const abs = importer !== undefined ? resolvePath(dirname(importer), source) : source + const abs = importer !== undefined ? sourceAssetPath(source, importer) : source return CSS_VIRTUAL_PREFIX + abs + CSS_VIRTUAL_SUFFIX }, async load(virtualId: string) { @@ -151,7 +230,7 @@ export function clientBundle(id: string, libEntry: readonly string[]): [UserConf const { code, exports: cssExports } = transform({ filename: fileId, code: source, - cssModules: { pattern: `[hash]_[local]` }, + cssModules: { pattern: '[hash]_[local]' }, minify: true, }) const classMap: Record = {} @@ -160,13 +239,13 @@ export function clientBundle(id: string, libEntry: readonly string[]): [UserConf return [ `const css = ${JSON.stringify(code.toString())};`, `const tagId = ${JSON.stringify(`${id}/${basename(fileId)}`)};`, - `if (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css=' + JSON.stringify(tagId) + ']') === null) {`, - ` const tag = document.createElement('style');`, + 'if (typeof document !== \'undefined\' && document.querySelector(\'style[data-plugin-css=\' + JSON.stringify(tagId) + \']\') === null) {', + ' const tag = document.createElement(\'style\');', ` tag.dataset.plugin = ${JSON.stringify(id)};`, - ` tag.dataset.pluginCss = tagId;`, - ` tag.textContent = css;`, - ` document.head.appendChild(tag);`, - `}`, + ' tag.dataset.pluginCss = tagId;', + ' tag.textContent = css;', + ' document.head.appendChild(tag);', + '}', `export default ${JSON.stringify(classMap)};`, ].join('\n') }, @@ -179,8 +258,18 @@ export function clientBundle(id: string, libEntry: readonly string[]): [UserConf // without exposing that tree as an HTTP route. sourcemapPathTransform: browserSourcePath, banner: `window.__ModuleLoader__.load({ id: ${JSON.stringify(id)}, factory: (require) => {`, - footer: `return module.exports; } });`, + footer: 'return module.exports; } });', intro: 'var module = { exports: {} }; var exports = module.exports;', }, - }] + } +} + +/** Resolve an emitted JS asset import against its source-tree counterpart. */ +function sourceAssetPath(source: string, importer: string): string { + const emitted = resolvePath(dirname(importer), source) + if (existsSync(emitted)) return emitted + const marker = `${sep}lib${sep}types${sep}` + const boundary = emitted.indexOf(marker) + if (boundary < 0) return emitted + return resolvePath(emitted.slice(0, boundary), 'src', emitted.slice(boundary + marker.length)) } diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index a00f3eaebb..dcc6938f6b 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: bf4cc11fa8e5d512f6cd802321dd835135af03f4 -README.zh.md: 2dc09eb4a9408dde615bd2fc982e01291de3840b +README.md: 4e6ecfdb2d3085b196f08f440a09cdfc9f4863bb +README.zh.md: 8dc474aad64dafbabd0db0a300a63e72588e75f2 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index bf4cc11fa8..4e6ecfdb2d 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, an animated left-to-right gradient `Deep diving...` turn status, per-tool row slot with a bash sample registrant and the todo row), composer dock (session stats sticky with the input), input dock (hairline-separated queue rows plus the todo plan strip), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares). -Compaction renders as one collapsed row at the checkpoint's flow position without replacing the transcript above it. The disclosure renders the checkpoint's `compact/summary` provenance; when that event is outside the loaded window, the row remains visible but non-expandable. The framed checkpoint payload is model-facing and never renders. +Compaction renders as one collapsed row at the checkpoint's flow position without replacing the transcript above it. Automatic compaction uses the context-compacted title. Every completed marker with structured summary provenance shows the replaced-item and estimated-token counts and discloses the summary on click. Manual `/compact` starts as a running `compact` row; on successful settlement its explicit summary-event reference folds that command into the checkpoint row under the same React key. A completed checkpoint keeps the context-compaction icon at rest and replaces it with the collapsed or expanded disclosure only on hover or keyboard focus. Input rejection, no compactable history, cancellation, and failure retain the generic command row and its handler-authored text. Pairing never depends on adjacency because durable context may be injected while compaction is running. The framed checkpoint payload is model-facing and never renders; when summary provenance is outside the loaded window, the checkpoint remains visible but non-expandable. The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. The root always owns the same scrollport and Hero/composer subtree; separate strict-session header and body outlets fill their regions when the first Session arrives, so the Workspace picker, scroll body, composer seat, and textarea retain their React and DOM identity. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it the scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). That scrollport reserves its scrollbar gutter unconditionally, and a view opting into a composer overlay leaves it a scroll container, so the input card keeps one horizontal position whether or not the transcript scrolls and whichever view tab is shown ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host. @@ -64,7 +64,6 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **Compaction markers show no scale** — the row does not yet report how many messages or which range the checkpoint replaced. - **Stats-line durations and speeds cover the in-window flow only** — LLM and tool wall times plus the TTFT and throughput averages fold the snapshot's assistant `timing` and tool call/result pairs, so nodes outside the loaded event window (older history) are not counted. - **The details panel has no entry point** — `ChatViewInjected.openDetails` is implemented but uncalled, so the raw selected-call display is unreachable in the assembled application. There is no Input/Output/Metadata switch, Prev/Next stepping, or trajectory deep link. - **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / clock / branch) ships under the last content-text assistant of each turn that has ended; mid-turn narration, Think-only nodes, and every node of a turn still producing steps stay chrome-free. Branch stays disabled unless that message is also the last transcript node of a completed turn; when enabled, it forks through that turn, increments the inherited title on the client, and opens the child. A fork or rename failure leaves the source selected ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md)). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 2dc09eb4a9..8dc474aad6 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -4,7 +4,7 @@ 会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、带从左到右动态渐变的 `Deep diving...` 轮次状态、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、编辑器 dock(与输入区一同 sticky 的会话统计行)、输入区 dock(带发丝分界线的队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约:api-contracts v3 §7 加 slot 终端设计(store seat/props share)。 -压缩(compaction)在检查点自身的消息流位置渲染为一行折叠标记,不替换其上方的 transcript(文本记录)。展开内容来自检查点溯源的 `compact/summary`;该事件位于已加载窗口之外时,标记仍然可见但不可展开。面向模型的带框检查点载荷绝不渲染。 +压缩(compaction)在检查点自身的消息流位置渲染为一行折叠标记,不替换其上方的 transcript(文本记录)。自动压缩使用「上下文已压缩」标题。每个具备结构化摘要溯源的完成标记都会显示被替换条目数量和估算 token 数量,并可点击展开摘要。手动 `/compact` 开始时显示为运行中的 `compact` 行;成功结算后,其显式摘要事件引用会在保持同一 React key 的前提下把该命令折叠进检查点行。完成的检查点静止时保留上下文压缩图标,仅在悬停或键盘聚焦时将其替换为收起/展开指示图标。输入被拒绝、没有可压缩历史、取消和失败时仍使用通用命令行及处理器撰写的文本。配对绝不依赖相邻关系,因为压缩运行期间可能注入持久上下文。面向模型的带框检查点载荷绝不渲染;摘要溯源位于已加载窗口之外时,检查点仍然可见但不可展开。 常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。根组件始终拥有同一个滚动容器与 Hero/编辑器子树;首个会话到达时,彼此独立的严格会话页头和主体 outlet 只填入各自区域,因此 Workspace 选择器、滚动主体、编辑器 seat 与 textarea 都保留原有 React 和 DOM identity。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段,会话标题栏作为普通列 chrome,仅显示当前会话标题和视图标签;fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。该滚动容器无条件预留自己的滚动条槽,选用编辑器 overlay 的视图也仍把它保留为滚动容器,因此无论对话记录是否滚动、无论展示哪个视图标签,输入卡片都保持同一个横向位置([决策](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md))。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。 @@ -64,7 +64,6 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu ## 已知限制与暂缓事项 -- **压缩标记不显示规模**:该行尚不报告检查点替换了多少条消息或哪段范围。 - **统计行的耗时与速率只覆盖窗口内消息流**:LLM 与工具墙钟时间以及 TTFT 与吞吐平均值由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。 - **详情面板没有入口**:`ChatViewInjected.openDetails` 虽已实现却无人调用,因此以原始形式显示已选择调用的那部分在组装后的应用中不可达。没有 Input/Output/Metadata 切换、Prev/Next 步进,也没有 trajectory 深链接。 - **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/时钟/分支)只挂在每个已结束轮次中最后一条带 text 内容的 assistant 下;轮次中间的叙述、纯 Think 节点,以及仍在产出步骤的轮次里的所有节点都不带 chrome。除非该消息同时也是已完成轮次的最后一个 transcript 节点,否则分支保持禁用;启用后,它会 fork 到该轮次末尾,在 client 端递增继承标题并打开子会话。fork 或改名失败时源会话保持选中([决策](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md))。 diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index b0907f5a80..18fba234de 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -32,6 +32,7 @@ import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' import { assistantActionsSeqs, assistantBranchSeqs, deriveChatFlow, runningTurnStartTime, type ChatFlowItem } from './chat-flow.ts' import { AssistantMarkdown } from './AssistantMarkdown.tsx' +import { CompactionCommandCard } from './CompactionCommandCard.tsx' import { GenericCommandCard } from './GenericCommandCard.tsx' import { GenericToolCard } from './GenericToolCard.tsx' import { MessageItem, PendingSteeringBubble } from './MessageItem.tsx' @@ -267,17 +268,21 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selec /** One command lifecycle row: keyed dispatch on the command name with the * generic card as the render-site fallback (zero registration required). A * run-less cross-window node has no name and always lands on the fallback. */ -const CommandRow = memo(function CommandRow({ renderSlot, node, t }: { +const CommandRow = memo(function CommandRow({ renderSlot, node, compaction, t }: { renderSlot: RenderToolRow node: CommandNode + compaction?: Extract t: ChatViewSlotProps['t'] }) { - const owner = useMemo(() => ({ node }), [node]) + const owner = useMemo(() => ({ node, ...compaction === undefined ? {} : { compaction } }), [compaction, node]) + const fallback = node.name === 'compact' + ? + : return (
{renderSlot('conversation.chat.commandview', owner, { entryKey: node.name ?? '', - fallback: , + fallback, })}
) @@ -580,6 +585,16 @@ export function ChatView({ /> ) } + if (item.kind === 'command-compaction') { + return ( + + ) + } const node: ConversationNode = item.node if (node.kind === 'assistant') { const timing = actionSeqs.has(node.seq) ? turnTimings.get(node.turn) : undefined @@ -642,9 +657,17 @@ export function ChatView({
{renderItem(item)}
diff --git a/packages/client/ui-conversation/src/client/chat/CompactionCommandCard.tsx b/packages/client/ui-conversation/src/client/chat/CompactionCommandCard.tsx new file mode 100644 index 0000000000..8012834541 --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/CompactionCommandCard.tsx @@ -0,0 +1,40 @@ +// CompactionCommandCard: the `/compact` command's running row and its +// successful checkpoint disclosure. Outcomes without a checkpoint keep the +// generic command card so no-history, cancellation, and failures retain their +// complete handler-authored text. + +import { IconApiOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ChatViewSlotProps, CommandRowOwnerProps } from '../contract/slots.ts' +import { CompactionItem } from './CompactionItem.tsx' +import { GenericCommandCard } from './GenericCommandCard.tsx' +import { ToolRow } from './ToolRow.tsx' + +interface CompactionCommandCardProps extends CommandRowOwnerProps { + t: ChatViewSlotProps['t'] +} + +/** Render one manual compaction lifecycle without duplicating its checkpoint marker. */ +export function CompactionCommandCard({ node, compaction, t }: CompactionCommandCardProps) { + if (compaction !== undefined) { + return ( + + ) + } + if (node.outcome !== null) return + return ( + } + title="compact" + summary={t('message.compaction.running')} + body={null} + state="running" + /> + ) +} diff --git a/packages/client/ui-conversation/src/client/chat/CompactionItem.tsx b/packages/client/ui-conversation/src/client/chat/CompactionItem.tsx index 82922dd97e..5bd5a081cf 100644 --- a/packages/client/ui-conversation/src/client/chat/CompactionItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/CompactionItem.tsx @@ -9,6 +9,7 @@ import { memo, useState } from 'react' import type { CompactionSummaryNode } from '@deepseek-ai/dsh-client-runtime/client' import { + IconApiOutline14, IconChevronDownOutline14, IconChevronRightOutline14, MarkdownText, @@ -18,6 +19,10 @@ import css from './MessageItem.module.css' interface CompactionItemProps { node: CompactionSummaryNode + /** Optional command title for a manual compaction folded into this marker. */ + title?: string + /** Command settlement text used when structured compaction counts are unavailable. */ + fallbackSummary?: string | null /** The owning view's locale seat. */ t: ChatViewSlotProps['t'] } @@ -27,10 +32,22 @@ interface CompactionItemProps { * @param props - the marker node off the snapshot cache. * @returns the marker row, with the summary disclosure when one is available. */ -export const CompactionItem = memo(function CompactionItem({ node, t }: CompactionItemProps) { +export const CompactionItem = memo(function CompactionItem({ + node, + title, + fallbackSummary, + t, +}: CompactionItemProps) { const [expanded, setExpanded] = useState(false) const expandable = node.summary !== null const open = expandable && expanded + const summary = node.shadowedItemCount !== null && node.shadowedTokenCount !== null + ? t('message.compaction.completed', { + items: node.shadowedItemCount, + tokens: node.shadowedTokenCount, + }) + : fallbackSummary + ?? (expandable ? t('message.compaction.expand') : t('message.compaction.unavailable')) return (
{open && node.summary !== null &&
} diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css index 5c07ace71e..c6ca35bb2c 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css @@ -33,9 +33,9 @@ padding: 2px 0; } -/* Compaction marker: one dim 24px row with a chevron disclosure for the - summary body. Dimmed title (not label-primary) — the row is a boundary - notice, not conversation content. */ +/* Compaction marker: one dim 24px row with a context icon at rest and a + hover/focus disclosure for the summary body. Dimmed title (not + label-primary) — the row is a boundary notice, not conversation content. */ .compactionRow { padding: 2px 0; } @@ -65,15 +65,36 @@ .compactionLeading { flex: none; - display: inline-flex; - align-items: center; - justify-content: center; + display: inline-grid; + place-items: center; width: 16px; height: 16px; margin-right: 6px; color: var(--dsw-alias-label-secondary); } +.compactionContextIcon, +.compactionDisclosureIcon { + display: inline-flex; + grid-area: 1 / 1; + align-items: center; + justify-content: center; +} + +.compactionDisclosureIcon { + opacity: 0; +} + +.compactionButton:not(:disabled):hover .compactionContextIcon, +.compactionButton:not(:disabled):focus-visible .compactionContextIcon { + opacity: 0; +} + +.compactionButton:not(:disabled):hover .compactionDisclosureIcon, +.compactionButton:not(:disabled):focus-visible .compactionDisclosureIcon { + opacity: 1; +} + .compactionTitle { flex: none; font-size: 14px; diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 5473c9f8a2..30b51b3870 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -137,29 +137,27 @@ function TurnErrorItem({ node, t }: { /** * Display projection of reference forms in a user bubble (free geometry — no * textarea alignment constraint here); everything else stays plain text. The - * logged model text remains the single truth; this is presentation only. Two - * shapes decorate: legacy `name` spans (pre-decision-21 - * history) and plain-text `/name` / `@name` word-boundary tokens (decision - * 21: the sent text IS the reference — the bubble uses the same plainest - * token scan as the composer, minus the lexicon: sent tokens were validated - * at compose time, so shape alone decorates). + * logged model text remains the single truth; this is presentation only. + * Plain-text `/name` / `@name` word-boundary tokens decorate (decision 21: + * the sent text IS the reference — the bubble uses the same plainest token + * scan as the composer, minus the lexicon: sent tokens were validated at + * compose time, so shape alone decorates). */ function projectUserText(text: string): ReactNode { - const re = /([^<]+)<\/skill>|(^|\s)([/@][\w-]+)(?=\s|$)/g + const re = /(^|\s)([/@][\w-]+)(?=\s|$)/g const parts: ReactNode[] = [] let cursor = 0 let m: RegExpExecArray | null while ((m = re.exec(text)) !== null) { - const legacy = m[1] !== undefined - const tokenStart = legacy ? m.index : m.index + (m[2]?.length ?? 0) - const label = legacy ? `/${m[1]}` : m[3] ?? '' + const tokenStart = m.index + (m[1]?.length ?? 0) + const label = m[2] ?? '' if (tokenStart > cursor) parts.push() parts.push( {label} , ) - cursor = legacy ? m.index + m[0].length : tokenStart + label.length + cursor = tokenStart + label.length } if (parts.length === 0) return if (cursor < text.length) parts.push() diff --git a/packages/client/ui-conversation/src/client/chat/chat-flow.ts b/packages/client/ui-conversation/src/client/chat/chat-flow.ts index e3b8e5ba2c..a9b12cbe2d 100644 --- a/packages/client/ui-conversation/src/client/chat/chat-flow.ts +++ b/packages/client/ui-conversation/src/client/chat/chat-flow.ts @@ -9,13 +9,48 @@ * flow share their gates. */ import type { - AssistantBlock, ConversationNode, ConversationSnapshot, ToolResultNode, + AssistantBlock, CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot, ToolResultNode, } from '@deepseek-ai/dsh-client-runtime/client' /** One renderable flow item; key is the React key and the parent's identity unit. */ export type ChatFlowItem = | { kind: 'node'; key: string; node: ConversationNode } | { kind: 'tool-group'; key: string; results: readonly ToolResultNode[] } + | { + kind: 'command-compaction' + key: string + command: CommandNode + compaction: CompactionSummaryNode + } + +/** Match explicit command outcome references to exactly one compaction checkpoint. */ +function commandCompactionPairs(nodes: readonly ConversationNode[]): { + readonly byCommandId: ReadonlyMap + readonly byCompactionSeq: ReadonlyMap +} { + const commandsBySource = new Map() + for (const node of nodes) { + if (node.kind !== 'command' || node.name !== 'compact' || node.outcome?.kind !== 'success') continue + const source = node.outcome.sourceEventSeq + if (source === undefined) continue + commandsBySource.set(source, commandsBySource.has(source) ? null : node) + } + const compactionsBySummary = new Map() + for (const node of nodes) { + if (node.kind !== 'compaction' || node.summaryEventSeq === null) continue + const summary = node.summaryEventSeq + compactionsBySummary.set(summary, compactionsBySummary.has(summary) ? null : node) + } + const byCommandId = new Map() + const byCompactionSeq = new Map() + for (const [source, command] of commandsBySource) { + const compaction = compactionsBySummary.get(source) + if (command === null || compaction === undefined || compaction === null) continue + byCommandId.set(command.commandId, compaction) + byCompactionSeq.set(compaction.seq, command) + } + return { byCommandId, byCompactionSeq } +} /** * True when the node has model-visible text content worth IconActions chrome. @@ -115,9 +150,28 @@ export function assistantBranchSeqs( */ export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem[] { const items: ChatFlowItem[] = [] + const pairs = commandCompactionPairs(nodes) let group: ToolResultNode[] | null = null for (const node of nodes) { if (rendersNothing(node)) continue + if (node.kind === 'command' && pairs.byCommandId.has(node.commandId)) { + continue + } + if (node.kind === 'compaction') { + group = null + const command = pairs.byCompactionSeq.get(node.seq) + if (command !== undefined) { + items.push({ + kind: 'command-compaction', + key: `c${command.commandId}`, + command, + compaction: node, + }) + } else { + items.push({ kind: 'node', key: `n${node.seq}`, node }) + } + continue + } if (node.kind === 'tool-result') { if (group === null) { group = [node] @@ -138,7 +192,13 @@ export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem } } else { group = null - items.push({ kind: 'node', key: `n${node.seq}`, node }) + items.push({ + kind: 'node', + key: node.kind === 'command' && node.name === 'compact' + ? `c${node.commandId}` + : `n${node.seq}`, + node, + }) } } return items diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 0284784e6e..be57f08523 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -3,7 +3,7 @@ import type { ReactNode, RefObject } from 'react' import type { InjectFace, MaybeSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook, } from '@deepseek-ai/dsh-client-ui-slots' -import type { CommandNode, ConversationNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' +import type { CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' import type { ComposerBlock } from '../input/blocks.ts' import type { ComposerKeyboard, EditSelection, InputActions, InputNotice, InputState } from '../input/contract.ts' @@ -217,14 +217,16 @@ export type ToolRowProps = PropsRuntime<'conversation.chat.toolview'> /** * Owner share of the per-command row slot: the frozen {@link CommandNode} * slice off the snapshot (cache-stable reference — memo premise). The node - * carries the whole lifecycle (structured name/args, pairing id, - * outcome-or-executing), so a - * registrant needs no second data channel; domain state arrives through its - * own projection cell. + * carries the whole lifecycle (structured name/args, pairing id, and + * outcome-or-executing). A successful domain command may also carry the + * explicitly linked projection node needed to fold two log records into one + * presentation row. */ export interface CommandRowOwnerProps { /** Folded command lifecycle node (run + optional done). */ node: CommandNode + /** Explicitly linked compaction checkpoint for the settled `/compact` presentation. */ + compaction?: CompactionSummaryNode } /** Full props of a registered command-row component (same shape rule as {@link ToolRowProps}). */ diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index df107d2cd2..11e852a8b6 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -80,6 +80,8 @@ export const zh = { 'message.context.recall.truncated': '已截断', 'message.steering': '插话', 'message.compaction': '上下文已压缩', + 'message.compaction.running': '正在压缩…', + 'message.compaction.completed': '已压缩 {items} 条历史记录(约 {tokens} tokens)', 'message.compaction.expand': '点击查看压缩摘要', 'message.compaction.unavailable': '压缩摘要不可用', 'message.unknownSurface': '未知 surface 事件:{type}', @@ -220,6 +222,8 @@ export const en = { 'message.context.recall.truncated': 'truncated', 'message.steering': 'Interjection', 'message.compaction': 'Context compacted', + 'message.compaction.running': 'Compacting context…', + 'message.compaction.completed': 'Compacted {items} history items (~{tokens} tokens)', 'message.compaction.expand': 'View compaction summary', 'message.compaction.unavailable': 'Compaction summary unavailable', 'message.unknownSurface': 'Unknown surface event: {type}', diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index 3122b0fdc7..41cbd61afc 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -691,11 +691,15 @@ describe('MessageItem arms', () => { , ) const row = view.getByRole('button', { name: /上下文已压缩/ }) expect(row.getAttribute('aria-expanded')).toBe('false') + expect(view.getByText('已压缩 16 条历史记录(约 11309 tokens)')).toBeTruthy() expect(view.queryByText(/保留的事实/)).toBeNull() fireEvent.click(row) expect(row.getAttribute('aria-expanded')).toBe('true') @@ -705,7 +709,10 @@ describe('MessageItem arms', () => { }) it('a marker whose provenance fell outside the window is not expandable', () => { - const view = render() + const view = render() const row = view.getByRole('button', { name: /上下文已压缩/ }) expect(row).toHaveProperty('disabled', true) expect(row.getAttribute('aria-expanded')).toBeNull() @@ -864,6 +871,7 @@ describe('MessageItem arms', () => { view.rerender() expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s') }) + }) describe('formatMessageClock', () => { diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index b8cd94de52..f42aed6731 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Profiler } from 'react' import { act, cleanup, fireEvent, render, within } from '@testing-library/react' import type { - AssistantMessageNode, CommandNode, ConversationNode, ConversationSnapshot, + AssistantMessageNode, CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot, ModelRetryNode, RunningToolCall, SessionId, SessionListState, ToolResultNode, TurnErrorNode, UserMessageNode, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' @@ -93,6 +93,19 @@ const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode const runningCall = (callId: string, name = 'bash'): RunningToolCall => ({ callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, time: 1_000, callView: null, }) +const command = (over: Partial = {}): CommandNode => ({ + kind: 'command', seq: 5, time: 5_000, commandId: 'cmd-1' as CommandNode['commandId'], + name: 'plan', args: '', outcome: { kind: 'success', text: '已进入 plan mode' }, + ...over, +}) +const compaction = (over: Partial = {}): CompactionSummaryNode => ({ + kind: 'compaction', seq: 8, time: 8_000, + summary: '## 压缩摘要\n\n保留的事实。', + summaryEventSeq: 7, + shadowedItemCount: 16, + shadowedTokenCount: 11_309, + ...over, +}) /** Empty sessions-list hook for the global standard-kit seat. */ function emptySessions() { @@ -212,6 +225,79 @@ describe('chat-flow derivation', () => { expect(updated[1]?.kind === 'node' && updated[1].node).toBe(second) }) + it('folds a successful /compact lifecycle into its explicitly linked checkpoint', () => { + const running = command({ + seq: 1, + commandId: 'cmd-compact' as CommandNode['commandId'], + name: 'compact', + outcome: null, + }) + expect(flowKeys(deriveChatFlow([user(0, 'before'), running]))).toBe('n0|ccmd-compact') + + const settled = { + ...running, + outcome: { kind: 'success' as const, text: 'Compacted 16 history items.', sourceEventSeq: 3 }, + } + const checkpoint = compaction({ seq: 4, summaryEventSeq: 3 }) + const items = deriveChatFlow([user(0, 'before'), settled, user(2, 'injected while compacting'), checkpoint]) + expect(flowKeys(items)).toBe('n0|n2|ccmd-compact') + expect(items.at(-1)).toEqual({ + kind: 'command-compaction', + key: 'ccmd-compact', + command: settled, + compaction: checkpoint, + }) + }) + + it('does not split adjacent tool results around a folded /compact command', () => { + const folded = command({ + seq: 2, + commandId: 'cmd-compact' as CommandNode['commandId'], + name: 'compact', + outcome: { kind: 'success', sourceEventSeq: 4 }, + }) + const items = deriveChatFlow([ + toolResult(1, 'a'), + folded, + toolResult(3, 'b'), + compaction({ seq: 5, summaryEventSeq: 4 }), + ]) + expect(flowKeys(items)).toBe('g1|ccmd-compact') + expect( + items[0]?.kind === 'tool-group' && items[0].results.map(result => result.callId), + ).toEqual(['a', 'b']) + }) + + it('keeps automatic, unlinked, and ambiguously linked compactions as separate rows', () => { + const automatic = compaction({ seq: 2, summaryEventSeq: 1 }) + expect(flowKeys(deriveChatFlow([automatic]))).toBe('n2') + + const first = command({ + seq: 3, + commandId: 'cmd-a' as CommandNode['commandId'], + name: 'compact', + outcome: { kind: 'success', sourceEventSeq: 9 }, + }) + const second = command({ + seq: 4, + commandId: 'cmd-b' as CommandNode['commandId'], + name: 'compact', + outcome: { kind: 'success', sourceEventSeq: 9 }, + }) + const ambiguous = compaction({ seq: 10, summaryEventSeq: 9 }) + expect(flowKeys(deriveChatFlow([first, second, ambiguous]))).toBe('ccmd-a|ccmd-b|n10') + + const sole = command({ + seq: 11, + commandId: 'cmd-sole' as CommandNode['commandId'], + name: 'compact', + outcome: { kind: 'success', sourceEventSeq: 12 }, + }) + const duplicateA = compaction({ seq: 13, summaryEventSeq: 12 }) + const duplicateB = compaction({ seq: 14, summaryEventSeq: 12 }) + expect(flowKeys(deriveChatFlow([sole, duplicateA, duplicateB]))).toBe('ccmd-sole|n13|n14') + }) + it('skips render-nothing assistant nodes so tool runs stay one group', () => { // A tool-call-only step message (and blank text/reasoning) renders nothing: // it must not split the run into two groups with an empty line between. @@ -1172,11 +1258,6 @@ describe('ChatView', () => { }) it('renders command nodes as durable rows: settled text, error state, executing spinner, run-less soft-fall', () => { - const command = (over: Partial): CommandNode => ({ - kind: 'command', seq: 5, time: 5_000, commandId: 'cmd-1' as CommandNode['commandId'], - name: 'plan', args: '', outcome: { kind: 'success', text: '已进入 plan mode' }, - ...over, - }) // Settled success: the bare command name is the title, the outcome text // the summary — neither the dispatched `/` nor its arguments reach the row // (the settlement text already says what the command did). @@ -1211,4 +1292,65 @@ describe('ChatView', () => { expect(ov.getByText('命令')).toBeTruthy() expect(ov.getByText('已完成')).toBeTruthy() }) + + it('renders /compact as one stateful disclosure from running through completion', () => { + const running = command({ + commandId: 'cmd-compact' as CommandNode['commandId'], + name: 'compact', + outcome: null, + }) + const h = makeHarness({ nodes: [running] }) + const view = render() + expect(view.getByText('正在压缩…')).toBeTruthy() + expect(view.container.querySelector('[data-state="running"]')).not.toBeNull() + + act(() => { + h.set({ + nodes: [{ + ...running, + outcome: { + kind: 'success', + text: 'Compacted 16 history items (~11309 tokens).', + sourceEventSeq: 7, + }, + }, compaction()], + }) + }) + + expect(view.queryByText('正在压缩…')).toBeNull() + expect(view.queryByText('上下文已压缩')).toBeNull() + expect(view.getByText('已压缩 16 条历史记录(约 11309 tokens)')).toBeTruthy() + const row = view.getByRole('button', { name: /compact/ }) + expect(row.getAttribute('aria-expanded')).toBe('false') + expect(row.querySelector('[data-compaction-icon="context"]')).not.toBeNull() + expect(row.querySelector('[data-compaction-disclosure="collapsed"]')).not.toBeNull() + expect(view.queryByText('保留的事实。')).toBeNull() + fireEvent.click(row) + expect(row.getAttribute('aria-expanded')).toBe('true') + expect(row.querySelector('[data-compaction-disclosure="expanded"]')).not.toBeNull() + expect(view.getByRole('heading', { name: '压缩摘要' })).toBeTruthy() + }) + + it('keeps /compact no-history and error settlements on the generic command row', () => { + const noHistory = makeHarness({ + nodes: [command({ + name: 'compact', + outcome: { kind: 'success', text: 'No compactable history yet.' }, + })], + }) + const noHistoryView = render() + expect(noHistoryView.getByText('No compactable history yet.')).toBeTruthy() + expect(noHistoryView.queryByRole('button')).toBeNull() + + const failed = makeHarness({ + nodes: [command({ + commandId: 'cmd-compact-failed' as CommandNode['commandId'], + name: 'compact', + outcome: { kind: 'error', text: 'Compaction cancelled.' }, + })], + }) + const failedView = render() + expect(failedView.getByText('Compaction cancelled.')).toBeTruthy() + expect(failedView.container.querySelector('[data-state="error"]')).not.toBeNull() + }) }) diff --git a/packages/client/ui-deliverables/tests/produced-files.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.spec.tsx index e6e59bf272..ff742be771 100644 --- a/packages/client/ui-deliverables/tests/produced-files.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.spec.tsx @@ -73,6 +73,7 @@ describe('producedForClosing derivation', () => { expect(producedForClosing(nodes, 999)).toEqual([]) }) + it('counts a generic edit and never spills across the turn boundary', () => { const inserted = (seq: number, callId: string, path: string): ToolResultNode => ({ ...toolResult(seq, callId, 'str_replace_editor'), diff --git a/packages/client/ui-goal/tsconfig.json b/packages/client/ui-goal/tsconfig.json index 263dfceb26..1c89771abf 100644 --- a/packages/client/ui-goal/tsconfig.json +++ b/packages/client/ui-goal/tsconfig.json @@ -15,7 +15,7 @@ "path": "../locale" }, { - "path": "../../api/remotes" + "path": "../../api/remotes/tsconfig.client.json" }, { "path": "../runtime" diff --git a/packages/client/ui-models/README.i18n.yaml b/packages/client/ui-models/README.i18n.yaml index 5d579d3b51..5e7b29d244 100644 --- a/packages/client/ui-models/README.i18n.yaml +++ b/packages/client/ui-models/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-models/README.md -README.md: cf4e50630339c4055e9ae2df37246b814af06966 -README.zh.md: 2b9158fa4419bf07f496fce47c938744f2a4233f +README.md: 66f23d3f77adae23fcebd3adbb6c5c47ce16ae2f +README.zh.md: dcb35c624aca73b558532210750f684e5cc54e85 diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md index cf4e506303..66f23d3f77 100644 --- a/packages/client/ui-models/README.md +++ b/packages/client/ui-models/README.md @@ -4,11 +4,11 @@ English | [中文](README.zh.md) Models settings plugin: the provider configuration page and official-DeepSeek conditional onboarding step. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time, without presenting route liveness as provider status. -Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. A row labels API-key state with a green solid dot only when a literal key or referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing; reference-free provider-native authentication and unavailable credential enrichment remain unmarked. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. A successful Apply emits a local accessible status message without echoing secret material. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint) and each adapter's model catalog. Reasoning effort is deliberately NOT among them: it is a per-model capability and the models under one provider disagree about which levels they accept, so a provider-scoped control could only be set to a value some of them reject — which took the whole provider out of the model picker. The composer's model picker offers each model its own levels, and a switch there records provider, model, and effort together as the default for the next session. The profile field stays in `settings.yaml` for a deployment that knows its route. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its localized confirmation dialog names the provider in the title, description, and final action. A row is tagged **Custom** when the directory entry says the owning adapter ships nothing under that key. The tag follows that answer alone: having a stored profile does not make a route custom — narrowing a shipped provider's models stores one too — and an adapter that reports nothing leaves its rows untagged rather than being read as shipped. +Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. A row labels API-key state with a green solid dot only when a referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing; reference-free provider-native authentication and unavailable credential enrichment remain unmarked. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. A successful Apply emits a local accessible status message without echoing secret material. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint) and each adapter's model catalog. Reasoning effort is deliberately NOT among them: it is a per-model capability and the models under one provider disagree about which levels they accept, so a provider-scoped control could only be set to a value some of them reject — which took the whole provider out of the model picker. The composer's model picker offers each model its own levels, and a switch there records provider, model, and effort together as the default for the next session. The profile field stays in `settings.yaml` for a deployment that knows its route. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its localized confirmation dialog names the provider in the title, description, and final action. A row is tagged **Custom** when the directory entry says the owning adapter ships nothing under that key. The tag follows that answer alone: having a stored profile does not make a route custom — narrowing a shipped provider's models stores one too — and an adapter that reports nothing leaves its rows untagged rather than being read as shipped. -The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured literal `apiKey` secret sidecar or configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface. +The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface. -Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, the same shape the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. A typed API key is judged on its own field the same way: after trimming, it must be non-empty and every character must be printable ASCII (`[\x21-\x7E]`), which is exactly what an HTTP header value can carry — the twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`, mirrored here because the source-plane split forbids importing it. A value shaped like a pasted `NAME=value` environment line or wrapped in matching quotes is refused as the same format failure; that paste-shape heuristic runs only in the browser, since a false positive in a resolver would leave the environment refusing the key as well. A field holding only whitespace fails rather than being silently dropped, while an empty field is not a failure at all: it means keep the stored key on an editor card, and authenticate some other way on a create card. A refused key blocks both the write and the endpoint interrogation, so the page never spends a round trip to be told what the field already says. Each settings write carries the card's current `revision`, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict`; after settings commit, the card adopts the returned redacted user subtree and revision before storing the credential, which makes a failed credential stage retry only that stage. Deletion removes a configured, writable credential only when the profile names the page's derived `_API_KEY` target, then unsets the profile; both operations are idempotent, and a partial failure remains in the identified confirmation dialog for retry. Environment credentials, custom references, and credentials whose target cannot be identified remain untouched. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. +Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it mutates the fields it can see rather than rebuilding a section. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, the same shape the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. A typed API key is judged on its own field the same way: after trimming, it must be non-empty and every character must be printable ASCII (`[\x21-\x7E]`), which is exactly what an HTTP header value can carry — the twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`, mirrored here because the source-plane split forbids importing it. A value shaped like a pasted `NAME=value` environment line or wrapped in matching quotes is refused as the same format failure; that paste-shape heuristic runs only in the browser, since a false positive in a resolver would leave the environment refusing the key as well. A field holding only whitespace fails rather than being silently dropped, while an empty field is not a failure at all: it means keep the stored key on an editor card, and authenticate some other way on a create card. A refused key blocks both the write and the endpoint interrogation, so the page never spends a round trip to be told what the field already says. Each settings write carries the card's current `revision`, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict`; after settings commit, the card adopts the returned redacted user subtree and revision before storing the credential, which makes a failed credential stage retry only that stage. Deletion removes a configured, writable credential only when the profile names the page's derived `_API_KEY` target, then unsets the profile; both operations are idempotent, and a partial failure remains in the identified confirmation dialog for retry. Environment credentials, custom references, and credentials whose target cannot be identified remain untouched. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. ## Model list and endpoint interrogation diff --git a/packages/client/ui-models/README.zh.md b/packages/client/ui-models/README.zh.md index 2b9158fa44..dcb35c624a 100644 --- a/packages/client/ui-models/README.zh.md +++ b/packages/client/ui-models/README.zh.md @@ -4,11 +4,11 @@ 模型设置插件:提供方配置页和按条件显示的 DeepSeek 官方首次使用引导步骤。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。 -行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可以询问提供方它服务什么。只有确认字面密钥或引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。无引用的提供方原生认证以及无法取得凭据补充信息时都不显示状态点。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,因此能保留提供方原生认证,例如 Bedrock 凭据链或 Vertex ADC。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),以及各适配器自己的模型目录。推理等级刻意**不在**其中:它是按模型的能力,而同一提供方下各模型接受的档位并不一致,因此提供方级的控件只可能被设成其中一些模型会拒绝的值——那会让整个提供方从模型选择器里消失。输入框的模型选择器为每个模型提供它自己的档位,在那里切换会把提供方、模型、推理等级一并记为下一个会话的默认值。profile 字段仍留在 `settings.yaml`,供清楚自己路由的部署使用。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。当目录条目表明拥有该路由的适配器在这个键下什么都没有时,该行会带上 **自定义** 标签。标签只跟随这个答案:存了 profile 并不使一条路由成为自定义——收窄一个内置提供方的模型同样会存下 profile——而什么都不回答的适配器,其路由保持无标签,不会被当成内置。 +行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可以询问提供方它服务什么。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。无引用的提供方原生认证以及无法取得凭据补充信息时都不显示状态点。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,因此能保留提供方原生认证,例如 Bedrock 凭据链或 Vertex ADC。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),以及各适配器自己的模型目录。推理等级刻意**不在**其中:它是按模型的能力,而同一提供方下各模型接受的档位并不一致,因此提供方级的控件只可能被设成其中一些模型会拒绝的值——那会让整个提供方从模型选择器里消失。输入框的模型选择器为每个模型提供它自己的档位,在那里切换会把提供方、模型、推理等级一并记为下一个会话的默认值。profile 字段仍留在 `settings.yaml`,供清楚自己路由的部署使用。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。当目录条目表明拥有该路由的适配器在这个键下什么都没有时,该行会带上 **自定义** 标签。标签只跟随这个答案:存了 profile 并不使一条路由成为自定义——收窄一个内置提供方的模型同样会存下 profile——而什么都不回答的适配器,其路由保持无标签,不会被当成内置。 -前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。 +前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。凭据引用已配置时,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。 -每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,与 pi-ai 提供方表单采用的形态相同。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。键入的 API 密钥同样在它自己的字段上被判定:trim 之后必须非空,且每个字符都是可打印 ASCII(`[\x21-\x7E]`)——这正是 HTTP 标头值所能承载的范围,是 `@deepseek-ai/dsh-llm` 中 `normalizeApiKey` 的孪生体,因源码平面分割禁止直接引入而在此镜像。形如整行粘贴的 `NAME=value` 环境变量或首尾成对引号包裹的值,会以同一条格式失败被拒绝;该粘贴形状启发式只在浏览器中运行,因为 resolver 中的一次误判会连带让环境变量这条路也拒绝该密钥。只含空白的输入框会失败而不是被静默丢弃;留空则完全不是失败:在编辑卡片上意味着保持已存储的密钥,在新建卡片上则意味着以其他方式鉴权。被拒绝的密钥会同时拦截写入与端点探测,因此页面不会白花一次往返去换取字段上已经写明的答案。每次 settings 写入都携带卡片当前的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝;settings 提交成功后,卡片会在存储凭据前采用响应返回的脱敏用户子树与 revision,因此凭据阶段失败时,重试只会重复该阶段。删除操作只会在 profile 指向页面派生的 `_API_KEY` 目标时清除已配置且可写的凭据,随后取消设置 profile;两项操作都具备幂等性,部分失败会停留在点名目标的确认对话框中供重试。环境凭据、自定义引用和无法识别目标的凭据保持不变。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 +每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它只修改自己看得见的字段,而不重建分节。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,与 pi-ai 提供方表单采用的形态相同。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。键入的 API 密钥同样在它自己的字段上被判定:trim 之后必须非空,且每个字符都是可打印 ASCII(`[\x21-\x7E]`)——这正是 HTTP 标头值所能承载的范围,是 `@deepseek-ai/dsh-llm` 中 `normalizeApiKey` 的孪生体,因源码平面分割禁止直接引入而在此镜像。形如整行粘贴的 `NAME=value` 环境变量或首尾成对引号包裹的值,会以同一条格式失败被拒绝;该粘贴形状启发式只在浏览器中运行,因为 resolver 中的一次误判会连带让环境变量这条路也拒绝该密钥。只含空白的输入框会失败而不是被静默丢弃;留空则完全不是失败:在编辑卡片上意味着保持已存储的密钥,在新建卡片上则意味着以其他方式鉴权。被拒绝的密钥会同时拦截写入与端点探测,因此页面不会白花一次往返去换取字段上已经写明的答案。每次 settings 写入都携带卡片当前的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝;settings 提交成功后,卡片会在存储凭据前采用响应返回的脱敏用户子树与 revision,因此凭据阶段失败时,重试只会重复该阶段。删除操作只会在 profile 指向页面派生的 `_API_KEY` 目标时清除已配置且可写的凭据,随后取消设置 profile;两项操作都具备幂等性,部分失败会停留在点名目标的确认对话框中供重试。环境凭据、自定义引用和无法识别目标的凭据保持不变。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 ## 模型列表与端点询问 diff --git a/packages/client/ui-models/src/client/ModelsSection.tsx b/packages/client/ui-models/src/client/ModelsSection.tsx index 9cbfba9a5a..5551b847fd 100644 --- a/packages/client/ui-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-models/src/client/ModelsSection.tsx @@ -80,8 +80,8 @@ function renderProviderEditor({ target, ...props }: ProviderEditorRenderProps): * Remove one user-added provider and its page-managed credential. Credential * removal comes first so a second-step failure leaves the provider row visible * and the whole operation safely retryable; both unsets are idempotent. - * The settings removal names the profile rather than rebuilding its redacted - * namespace, which would drop literal secrets stored elsewhere. + * The settings removal names the profile rather than rebuilding its whole + * namespace from a partial view. * @param api - settings and credential wire faces. * @param controller - the page store to refresh. * @param target - the provider's settings address and optional managed credential. @@ -112,16 +112,14 @@ export async function removeProviderProfile( } /** - * Whether a whole-section provider still needs its first key: nothing marks - * the credential configured and no literal `apiKey` is stored, so the page - * opens the setup card instead of showing a row. + * Whether a whole-section provider still needs its first key: an unconfigured + * credential opens the setup card instead of showing a row. * @param row - the joined provider row. * @returns whether to render the setup card. */ export function needsSetup(row: ProviderRow): boolean { if (row.entry.settingsPath.length > 0) return false - if (row.credential?.configured === true) return false - return !row.literalApiKeyConfigured + return row.credential?.configured !== true } function targetOf(row: ProviderRow): EditorTarget { @@ -264,7 +262,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { ) } const open = !adding && editing?.provider === row.entry.provider - const credentialConfigured = row.literalApiKeyConfigured || row.credential?.configured === true + const credentialConfigured = row.credential?.configured === true const credentialMissing = !credentialConfigured && row.apiKeyEnv !== undefined && row.credential?.configured === false diff --git a/packages/client/ui-models/src/client/ProviderEditor.tsx b/packages/client/ui-models/src/client/ProviderEditor.tsx index ff23e35b63..01978273ae 100644 --- a/packages/client/ui-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-models/src/client/ProviderEditor.tsx @@ -14,9 +14,8 @@ * model picker offers each model its own levels; `settings.yaml` keeps the * profile field for a deployment that knows its route. Everything else stays * owned by `settings.yaml`. Profile edits land as minimal `settings.mutate` - * path ops against the stored section — the card reads the redacted - * descriptor, so it names only the fields it can see and a stored literal - * secret is never collaterally removed. + * path ops against the stored section — the card names only the fields it can + * see instead of rebuilding the whole subtree from a partial descriptor. */ import { useEffect, useMemo, useState } from 'react' @@ -72,10 +71,9 @@ function draftAt(namespace: SettingsNamespaceView, path: readonly string[]): Rec /** * The minimal path ops carrying `after` over `before`, both as the card sees - * them (that is, redacted). Only keys the card observed are named: a stored - * `role('secret')` field appears in neither side, so it produces no op and - * survives the write — the whole reason edits are path-addressed rather than - * a rebuilt section. + * them. Only keys the card observed are named; fields absent from both sides + * produce no op, which is why edits are path-addressed rather than a rebuilt + * section. * @param base - path of the edited subtree inside the user section. * @param before - the subtree as loaded, or undefined when it is new. * @param after - the subtree as edited. @@ -197,9 +195,8 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { /** * The write for this card, or a failure message. Every edit travels as * path ops against the STORED section: the draft comes from the redacted - * descriptor, so a wholesale replace rebuilt from it would delete the - * literal secrets the wire never returned. Ops name only the fields this - * card can see, so a stored secret is untouched by construction. + * descriptor, so a wholesale replace rebuilt from it could delete fields + * outside the card. Ops name only the fields this card can see. */ const applyOnce = async (): Promise => { const ns = namespace.ns diff --git a/packages/client/ui-models/src/client/store.ts b/packages/client/ui-models/src/client/store.ts index 938283b903..95db7e6787 100644 --- a/packages/client/ui-models/src/client/store.ts +++ b/packages/client/ui-models/src/client/store.ts @@ -31,8 +31,6 @@ export interface ProviderRow { apiKeyEnv: string | undefined /** Credential state for {@link apiKeyEnv}, once described. */ credential: CredentialView | undefined - /** Whether the redacted secret sidecar reports an effective literal `apiKey`. */ - literalApiKeyConfigured: boolean } /** Page snapshot. */ @@ -97,19 +95,6 @@ function apiKeyEnvOf(namespace: SettingsNamespaceView | undefined, path: readonl return typeof ref === 'string' && ref.length > 0 ? ref : undefined } -/** Whether one namespace's redacted sidecar reports a set literal API key. */ -function literalApiKeyConfigured( - namespace: SettingsNamespaceView | undefined, - path: readonly string[], -): boolean { - if (namespace === undefined) return false - const secretPath = [...path, 'apiKey'] - return namespace.secrets.some(secret => - secret.set - && secret.path.length === secretPath.length - && secret.path.every((key, index) => key === secretPath[index])) -} - /** The models settings page controller (one per settings surface). */ export class ModelsSettingsStore { /** The snapshot the section renders from (uSES-safe store). */ @@ -170,7 +155,6 @@ export class ModelsSettingsStore { removable, apiKeyEnv: apiKeyEnvOf(namespace, entry.settingsPath), credential: undefined, - literalApiKeyConfigured: literalApiKeyConfigured(namespace, entry.settingsPath), } }) const refs = [...new Set(rows.flatMap(row => row.apiKeyEnv === undefined ? [] : [row.apiKeyEnv]))] @@ -257,7 +241,6 @@ export function deepSeekReadiness(state: ModelsSettingsState): DeepSeekReadiness reason: 'settings-unavailable', } } - if (row.literalApiKeyConfigured) return { kind: 'configured' } if (row.apiKeyEnv === undefined) { return { kind: 'unavailable', diff --git a/packages/client/ui-models/tests/components.spec.tsx b/packages/client/ui-models/tests/components.spec.tsx index 88eab4b998..da4c8972ae 100644 --- a/packages/client/ui-models/tests/components.spec.tsx +++ b/packages/client/ui-models/tests/components.spec.tsx @@ -35,9 +35,7 @@ function capacityInputs(label: string): HTMLInputElement[] { } const PiAiConfig = Schema.object({ - token: Schema.string().role('secret'), providers: Schema.dict(Schema.object({ - apiKey: Schema.string().role('secret'), apiKeyEnv: Schema.string().role('credential-ref'), baseURL: Schema.string(), reasoning: Schema.union(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']), @@ -46,7 +44,6 @@ const PiAiConfig = Schema.object({ }) const DeepSeekConfig = Schema.object({ - apiKey: Schema.string().role('secret'), apiKeyEnv: Schema.string().role('credential-ref'), baseURL: Schema.string().pattern(/^https:\/\//), reasoningEffort: Schema.union(['off', 'high', 'max']), @@ -99,7 +96,7 @@ function wireNamespaces(): SettingsNamespaceView[] { base: { defaultContextWindow: 1_000_000, maxTokens: 256_000, models: DEFAULT_DEEPSEEK_MODELS }, user: { baseURL: 'https://base' }, applies: 'live', - secrets: [{ path: ['apiKey'], set: false }], + secrets: [], revision: 0, }, { @@ -118,7 +115,7 @@ function wireNamespaces(): SettingsNamespaceView[] { value: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://proxy', headers: { 'X-Team': 'a' } }, zombie: {} } }, user: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://proxy', headers: { 'X-Team': 'a' } }, zombie: {} } }, applies: 'live', - secrets: [{ path: ['token'], set: false }, { path: ['providers', 'openai', 'apiKey'], set: false }], + secrets: [], revision: 0, }, ] @@ -262,22 +259,17 @@ describe('ModelsSection', () => { expect(screen.queryByLabelText(en.keyInput)).toBeNull() }) - it('decides setup need from the joined credential state and literal-key sidecar', () => { + it('decides setup need from the joined credential state', () => { const entry = { provider: 'p', displayName: 'p', settingsNs: 'llm-deepseek', settingsPath: [], active: true } - const row = ( - credential: ProviderRow['credential'], - literalApiKeyConfigured = false, - ): ProviderRow => ({ + const row = (credential: ProviderRow['credential']): ProviderRow => ({ entry, configured: true, removable: false, apiKeyEnv: 'X', credential, - literalApiKeyConfigured, }) expect(needsSetup(row(undefined))).toBe(true) expect(needsSetup(row({ configured: true, writable: true }))).toBe(false) - expect(needsSetup(row(undefined, true))).toBe(false) const nested = { ...row(undefined), entry: { ...entry, settingsPath: ['providers', 'x'] } } expect(needsSetup(nested)).toBe(false) }) @@ -294,9 +286,7 @@ describe('ModelsSection', () => { expect(providerTargetLabel(OPENAI_TARGET)).toBe('openai') }) - it('names only the fields the card can see, so an unseen secret survives', () => { - // `before` is the REDACTED subtree: a stored literal apiKey is in neither - // side, so no op mentions it and the seam leaves it alone. + it('names only changed fields instead of rebuilding the section', () => { expect(pathOps(['providers', 'openai'], { baseURL: 'https://old', reasoning: 'high' }, { reasoning: 'high' })) .toEqual([{ op: 'unset', path: ['providers', 'openai', 'baseURL'] }]) expect(pathOps([], { b: 1 }, { b: 2, d: 3 })) @@ -724,8 +714,7 @@ describe('ModelsSection', () => { }) it('clears an inherited override with an unset op, never a whole-section replace', async () => { - // The data-loss shape: the old path rebuilt the section from the REDACTED - // user layer and replaced it wholesale, deleting any stored literal key. + // The old path rebuilt the whole user section to clear one inherited field. const { replace, update, mutate } = await mountSection() fireEvent.click(screen.getByText(en.customized)) const url = screen.getByLabelText(en.baseUrl) @@ -798,9 +787,7 @@ describe('ModelsSection', () => { fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement) await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) // Only the edited field travels: apiKeyEnv and headers were already stored - // with these values, so no op restates them — and the profile's stored - // literal apiKey, absent from the redacted view the card read, is named by - // nothing at all. + // with these values, so no op restates them. expect(mutate.mock.calls[0]?.[0]).toEqual({ ns: 'llm-pi-ai', ops: [{ op: 'set', path: ['providers', 'openai', 'baseURL'], value: 'https://proxy/v2' }], @@ -1132,8 +1119,8 @@ describe('ModelsSection', () => { }) it('removes by unsetting the profile path, never by rebuilding the section', async () => { - // The section rebuild is what dropped stored literal secrets: this page - // only ever holds the redacted descriptor, so the removal names the path. + // The page only needs to name the profile path; rebuilding the section + // would widen the write for no benefit. const { face, mutate, replace, controller } = await mountSection() await removeProviderProfile( face as unknown as Parameters[0], diff --git a/packages/client/ui-models/tests/onboarding-dialog.spec.tsx b/packages/client/ui-models/tests/onboarding-dialog.spec.tsx index 07acc89fe4..772e14aaba 100644 --- a/packages/client/ui-models/tests/onboarding-dialog.spec.tsx +++ b/packages/client/ui-models/tests/onboarding-dialog.spec.tsx @@ -28,7 +28,6 @@ function harness(options: { providerActive?: boolean settingsNamespace?: boolean apiKeyEnv?: string | null - literal?: boolean configured?: () => boolean credential?: { source?: string; writable: boolean } describeFailure?: string @@ -66,7 +65,7 @@ function harness(options: { ? {} : { apiKeyEnv: options.apiKeyEnv ?? 'DEEPSEEK_API_KEY' }, applies: 'live' as const, - secrets: [{ path: ['apiKey'], set: options.literal === true }], + secrets: [], revision: 0, }], })), @@ -153,11 +152,10 @@ describe('DeepSeekOnboardingDialog', () => { } }) - it('skips an absent adapter and already-configured literal or environment credentials', async () => { + it('skips an absent adapter and an already-configured environment credential', async () => { for (const h of [ harness({ provider: false }), harness({ providerSettingsNs: '' }), - harness({ literal: true, describeFailure: 'credential seam absent' }), harness({ configured: () => true, credential: { source: 'env', writable: false } }), ]) { const view = render() diff --git a/packages/client/ui-models/tests/readiness.spec.ts b/packages/client/ui-models/tests/readiness.spec.ts index d03cd130f4..f01ab75930 100644 --- a/packages/client/ui-models/tests/readiness.spec.ts +++ b/packages/client/ui-models/tests/readiness.spec.ts @@ -19,7 +19,6 @@ function row(overrides: Partial = {}): ProviderRow { removable: false, apiKeyEnv: 'DEEPSEEK_API_KEY', credential: missingCredential, - literalApiKeyConfigured: false, ...overrides, } } @@ -64,13 +63,6 @@ describe('deepSeekReadiness', () => { }))).toEqual({ kind: 'configured' }) }) - it('accepts the redacted literal-key sidecar before judging the credential domain', () => { - expect(deepSeekReadiness(state({ - credentialError: 'credentials service absent', - rows: [row({ literalApiKeyConfigured: true, credential: undefined })], - }))).toEqual({ kind: 'configured' }) - }) - it('turns missing capabilities and inconsistent descriptors into diagnostics', () => { expect(deepSeekReadiness(state({ status: 'error', error: 'settings down' }))).toEqual({ kind: 'unavailable', diff --git a/packages/client/ui-models/tests/store.spec.ts b/packages/client/ui-models/tests/store.spec.ts index ee9aa2ddaf..5a1f340a0d 100644 --- a/packages/client/ui-models/tests/store.spec.ts +++ b/packages/client/ui-models/tests/store.spec.ts @@ -25,7 +25,7 @@ const NAMESPACES = [ value: { apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://base' }, base: { baseURL: 'https://base' }, applies: 'live' as const, - secrets: [{ path: ['apiKey'], set: false }], + secrets: [], revision: 0, }, { @@ -85,7 +85,6 @@ describe('ModelsSettingsStore', () => { removable: false, apiKeyEnv: 'DEEPSEEK_API_KEY', credential: { configured: false, writable: true }, - literalApiKeyConfigured: false, }) expect(byProvider.get('openai')).toMatchObject({ configured: true, @@ -131,30 +130,6 @@ describe('ModelsSettingsStore', () => { expect(store.store.getSnapshot().credentialError).toBe('credential transport refusal') }) - it('joins a configured literal key from the redacted secret sidecar', async () => { - const { face } = api({ - describeSettings: () => Promise.resolve(ok({ - writable: true, - hasDocument: false, - namespaces: [{ - ...NAMESPACES[0], - secrets: [ - { path: ['apiKey', 'nested'], set: true }, - { path: ['different'], set: true }, - { path: ['apiKey'], set: true }, - ], - }] as never, - })), - providers: () => Promise.resolve(ok({ providers: [DIRECTORY[0]] as never })), - }) - const store = new ModelsSettingsStore(face) - await store.load() - expect(store.store.getSnapshot().rows[0]).toMatchObject({ - literalApiKeyConfigured: true, - apiKeyEnv: 'DEEPSEEK_API_KEY', - }) - }) - it('surfaces a directory failure and keeps the last good rows', async () => { const { face } = api() const store = new ModelsSettingsStore(face) diff --git a/packages/client/ui-primitives/tsdown.config.ts b/packages/client/ui-primitives/tsdown.config.ts index 1532f5e5f6..cbabf4f2a1 100644 --- a/packages/client/ui-primitives/tsdown.config.ts +++ b/packages/client/ui-primitives/tsdown.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from 'tsdown' +import { clientOnly } from '../tsdown.client.ts' /** * ui-primitives is browser-only, but its lib bundle IS imported under plain @@ -8,7 +8,7 @@ import { defineConfig } from 'tsdown' * (loader module table / vite source paths), which compile src directly and * never read lib. */ -export default defineConfig({ +export default clientOnly([{ entry: ['lib/types/index.js', 'lib/types/invariant.js'], outDir: 'lib', format: ['esm'], @@ -28,4 +28,4 @@ export default defineConfig({ return 'export default {};' }, }], -}) +}]) diff --git a/packages/client/ui-skill/README.i18n.yaml b/packages/client/ui-skill/README.i18n.yaml index ca4bc68ebf..a1d1a2c9d5 100644 --- a/packages/client/ui-skill/README.i18n.yaml +++ b/packages/client/ui-skill/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-skill/README.md -README.md: f70bd2780f255cd8e0c64acb3da3863e10c4fa9d -README.zh.md: 6eb6cbd3ae196a540e161a3a23f9df2136824f2e +README.md: bdd772662acda1f8cf1b7d8a7c5532f9b37123dd +README.zh.md: 959ff0ede6d545150fb22710c8af75859966caa9 diff --git a/packages/client/ui-skill/README.md b/packages/client/ui-skill/README.md index f70bd2780f..bdd772662a 100644 --- a/packages/client/ui-skill/README.md +++ b/packages/client/ui-skill/README.md @@ -2,7 +2,9 @@ English | [中文](README.zh.md) -Skill reference source, browser half: registers the `/`-trigger `skill` source into `ctx.slash`. Ordinary-session candidates come from the `skill.list` RPC addressed by the per-call `ClientSessionContext` projection's `{sessionId}`, with the host resolving `cwd` from the session header. The host returns the intersection of model-invocable and user-invocable skills because this browser path inserts a model reference rather than loading the body directly. Catalog-addressed continuable children resolve no skill candidates locally because the existing skill RPC requires an attached session; viewing their persisted history must not activate them. Catalogs cache per ordinary session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry and `connection/reset` clears everything. Results filter by `startsWith(query)`; picking a candidate lands the literal `/name ` text through the slash pipeline (decision 21 plain-text reference), and the source `codec` owns the reference's two projections: `clipboardText` → `/name`, `serialize` → the model form `name` invoked at submit time. The RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument. The source implements no `matchSpace`/`matchEnter` hooks — skill references never enter command adjudication and ride ordinary prompts into the default sink. +Skill invocation source, browser half: registers the `/`-trigger `skill` source into `ctx.slash`. Ordinary-session candidates come from the `skill.list` RPC addressed by the per-call `ClientSessionContext` projection's `{sessionId}`, with the host resolving `cwd` from the session header. The host serves every user-invocable skill; a `modelInvocable: false` entry (a `disable-model-invocation` skill, whose only entry point is this path) wears the user-only marker as a description prefix in the active language. Catalog-addressed continuable children resolve no skill candidates locally because the existing skill RPC requires an attached session; viewing their persisted history must not activate them. Catalogs cache per ordinary session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry and `connection/reset` clears everything. Results filter by `startsWith(query)`. + +A pick lands the literal `/name ` text and the prompt ships the same literal (decision 21) — this source implements no adjudication hooks and no reference codec (the legacy `name` form is gone with the removal cut). Determinism lives host-side: the pre-step gesture boundary (`dsh-tool-skill`) recognizes whitespace-bounded `/name` tokens naming user-invocable skills anywhere in a user message and injects the rendered `` for every front end, so a menu pick, a hand-typed token, and a TUI/ACP prompt all load the skill the same way. A name shared with a host command still resolves to the command: adjudication claims the line client-side before it ever becomes a prompt — deliberate precedence, matching peer products. The list RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument; draft chip visuals derive from the `lexicon` scan. A failed `skill.list` throws from `candidates`, which the slash shell logs and folds into a silent menu-group drop — the menu shows only pending/ready states. @@ -14,23 +16,22 @@ The browser plugin also registers a keyed `skill` toolview in `conversation.chat ## Model Experience -### Skill reference text in the user prompt +### User-explicit skill invocation #### What the model sees -A picked candidate lands the literal `/name ` in the draft (decision 21: plain text, no `` tag); the text reaches the model verbatim inside the ordinary user message (`session.prompt`), with no dedicated content block, prompt section, or host-side expansion. The association with the actual skill is model-side and non-deterministic: the session prefix already carries the skill catalog (rendered by `dsh-tool-skill`), and the reference's name matching a catalog entry is what invites the model to load it. +The user's message reaches the model verbatim, `/name` literal included. The host's pre-step boundary (`dsh-tool-skill`) then appends the canonical `` block — the same `renderSkillContent` output the `skill` tool returns — as injected instructions context at the end of that step's injections, closest to the model's answer. Loading is deterministic: the model receives the full body without being asked to call the `skill` tool, and the catalog tells it not to re-load an inline-injected skill. #### Token effect -Conditional and tiny: only a pick (or hand-typing the same text) adds the reference's characters to that one user message. Menu browsing and the candidate fetch add zero model tokens. +One invocation adds the rendered skill body to that turn as injected context — the same cost as the model loading the skill through the tool, paid unconditionally instead of at the model's discretion. Menu browsing and the candidate fetch add zero model tokens. #### KV Cache effect -Append-only: the reference is part of a new user message appended after the reusable history prefix. This package never edits earlier request tokens. +Append-only: the injected message lands after the reusable history prefix. This package never edits earlier request tokens. ## Known Limitations and Deferred Work - **Result-only history pages use the generic row** — keyed dispatch needs the paired call in the runtime window; pagination that leaves the call outside has no tool identity. This client presentation feature does not extend the history wire contract to recover it. -- **Non-deterministic skill loading** — the reference is a collaboration cue, not a guarantee; the model may ignore it. The rework path when hit rate proves insufficient (a host-side `context/skill-reference` guidance package, or full-text injection) sits in the design ledger; the wire text shape would not change. -- **First keystroke may race the prewarm** — the scope-birth warm launches the catalog fetch, but a menu opened before it settles shows no skill candidates for that keystroke. Accepted by design: skill references do not participate in enter adjudication, so nothing correctness-bearing waits on the catalog. -- **Text is the truth** — the reference is plain draft text; a hand-typed identical token is the same reference. Chip visuals derive from the lexicon scan; no occurrence identity or position tracking (componentized chips are a ledger item). +- **Text is the truth** — the reference is plain draft text; a hand-typed identical token is the same reference, and the host gesture boundary judges the sent text, not the menu interaction. Chip visuals derive from the lexicon scan; no occurrence identity, position tracking, or structured reference payload on the prompt wire (both are ledger items). +- **A menu opened before the prewarm settles** shows no skill candidates for that keystroke; the next keystroke re-polls the settled cache. diff --git a/packages/client/ui-skill/README.zh.md b/packages/client/ui-skill/README.zh.md index 6eb6cbd3ae..959ff0ede6 100644 --- a/packages/client/ui-skill/README.zh.md +++ b/packages/client/ui-skill/README.zh.md @@ -2,7 +2,9 @@ [English](README.md) | 中文 -skill(技能)引用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。普通会话的候选来自 `skill.list` RPC,以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址,host 从会话 header 解析 `cwd`。宿主返回模型可调用与用户可调用 skill 的交集,因为该浏览器路径插入的是模型引用,而不是直接加载正文。由目录寻址的可继续 subagent 在客户端解析为没有 skill 候选,因为现有 skill RPC 要求会话已挂载;查看其持久化历史不得激活它。目录按普通会话缓存,拉取走 single-flight;scope 创建时的 `warm` 钩子预热该会话的缓存项,`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤;pick 一个候选会把字面文本 `/name ` 经 slash 流水线落进草稿(决策 21 的纯文本引用),source 的 `codec` 拥有该引用的两种投影:`clipboardText` → `/name`,`serialize` → 提交时生成的模型形式 `name`。RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务。source 不实现 `matchSpace`/`matchEnter` 钩子——skill 引用永不进入命令裁决,随普通提示词落入 default sink。 +skill(技能)调用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。普通会话的候选来自 `skill.list` RPC,以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址,host 从会话 header 解析 `cwd`。宿主提供每一个用户可调用的 skill;`modelInvocable: false` 的条目(即 `disable-model-invocation` skill,此路径是其唯一入口)会以当前语言把仅限用户标记作为描述前缀带上。由目录寻址的可继续 subagent 在客户端解析为没有 skill 候选,因为现有 skill RPC 要求会话已挂载;查看其持久化历史不得激活它。目录按普通会话缓存,拉取走 single-flight;scope 创建时的 `warm` 钩子预热该会话的缓存项,`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤。 + +pick 会落下字面文本 `/name `,提示词发出的就是同一段字面文本(决策 21)——本 source 不实现任何裁决钩子,也没有引用 codec(旧的 `name` 形式已随移除裁定消失)。确定性在宿主侧:pre-step 手势边界(`dsh-tool-skill`)识别用户消息中任意位置、以空白为界、指名用户可调用 skill 的 `/name` token,并为每一种前端注入渲染后的 ``,因此菜单 pick、手动键入的 token 与 TUI/ACP 提示词都以同一种方式加载 skill。与宿主命令同名的名称仍解析为命令:裁决在客户端把该行认领走,它根本不会成为提示词——这是有意的优先级,与同行产品一致。列表 RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务;草稿 chip 视觉由 `lexicon` 扫描派生。 `skill.list` 失败时 `candidates` 抛出异常,slash 壳层记录日志并折叠为静默的菜单组丢弃——菜单只显示 pending/ready 状态。 @@ -14,23 +16,22 @@ skill(技能)引用 source 的浏览器端:把 `/` 触发的 `skill` sourc ## 模型体验 -### 用户提示词中的 skill 引用文本 +### 用户显式 skill 调用 #### 模型看到的内容 -被 pick 的候选会把字面文本 `/name ` 落进草稿(决策 21:纯文本,无 `` 标签);该文本原样进入普通用户消息(`session.prompt`)到达模型,没有专用内容块、提示词 section 或 host 侧展开。与实际 skill 的关联在模型侧建立且具有非确定性:会话前缀已携带 skill 目录(由 `dsh-tool-skill` 渲染),引用名称与目录条目匹配,正是这一点引导模型去加载它。 +用户消息原样到达模型,字面文本 `/name` 也包含在内。随后宿主的 pre-step 边界(`dsh-tool-skill`)把规范的 `` 块——与 `skill` 工具返回的 `renderSkillContent` 输出相同——作为注入的指令上下文追加在该步骤各项注入的末尾,最贴近模型的回答。加载是确定性的:模型无需被要求调用 `skill` 工具就能收到完整正文,目录也会告诉它不要重新加载已内联注入的 skill。 #### Token 影响 -有条件且极小:只有 pick(或手动键入相同文本)会把引用的字符加进那一条用户消息。浏览菜单和拉取候选不会增加任何模型 token。 +一次调用会把渲染后的 skill 正文作为注入上下文加进该轮次——成本与模型经由工具加载该 skill 相同,只是无条件支付,而非由模型自行裁量。浏览菜单和拉取候选不会增加任何模型 token。 #### KV Cache 影响 -仅追加:引用是追加在可复用历史前缀之后的新用户消息的一部分。该包绝不改写较早的请求 token。 +仅追加:注入的消息落在可复用历史前缀之后。该包绝不改写较早的请求 token。 ## 已知限制与暂缓事项 - **仅含结果的 history 页使用通用行**:键控分派要求配对调用位于 runtime 窗口内;分页将调用留在窗口外时,结果没有工具身份。这项客户端呈现功能不会为了恢复该身份而扩展 history 协议契约。 -- **skill 加载具有非确定性**:引用是协作线索,不是保证;模型可能忽略它。针对命中率不足情况的返工路径(host 侧 `context/skill-reference` 引导包,或全文注入)记录在设计台账中;协议中的文本形态不会改变。 -- **首次击键可能与预热竞速**:scope 创建时的预热会启动目录拉取,但目录落定之前打开的菜单,在那次击键下不会显示 skill 候选。这是设计上接受的取舍:skill 引用不参与回车裁决,因此没有任何攸关正确性的环节等待目录。 -- **文本是唯一依据**:引用是普通的草稿文本;手动键入的相同 token 就是同一个引用。chip 视觉由 lexicon 扫描派生;没有 occurrence 身份或位置跟踪(组件化 chip 是台账事项)。 +- **文本是唯一依据**:引用是普通的草稿文本;手动键入的相同 token 就是同一个引用,宿主手势边界评判的是发出的文本,而不是菜单交互。chip 视觉由 lexicon 扫描派生;没有 occurrence 身份、位置跟踪,也没有提示词协议上的结构化引用载荷(两者都是台账事项)。 +- **预热落定之前打开的菜单**:在那次击键下不显示 skill 候选;下一次击键会重新轮询已落定的缓存。 diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts index 9631125801..4e23be06be 100644 --- a/packages/client/ui-skill/src/client/index.ts +++ b/packages/client/ui-skill/src/client/index.ts @@ -2,13 +2,16 @@ * Skill reference plugin, browser half: registers the '/' skill source — * candidates from the skill.list RPC addressed by the per-call session * projection's sessionId (sessions are always agent-backed; the host - * resolves cwd from the session header), pick inserts the literal `/name ` - * text (decision 21: the draft carries plain text, chip visuals are derived - * by scanning against the source lexicon, and the prompt ships the same - * literal — no `` tag). The RPC rides the plugin's root-context - * connection captured at registration — the source never reads services off - * a per-call argument. No adjudication hooks: skill references ride - * ordinary prompts and never enter command adjudication. + * resolves cwd from the session header). A pick lands the literal `/name ` + * text and the prompt ships the same literal (decision 21); determinism + * lives host-side — the pre-step boundary (`dsh-tool-skill`) recognizes a + * leading `/name` naming a user-invocable skill and injects the rendered + * body for every front end, including `disable-model-invocation` skills the + * model-side catalog never lists (issue #1470). The RPC rides the plugin's + * root-context connection captured at registration — the source never reads + * services off a per-call argument. Draft chip visuals still derive from + * the lexicon scan; the legacy `` reference codec is gone (decision + * 21 removal cut). * * Catalog fetches are cached per session (the small twin of the ui-command * directory): the per-keystroke candidates re-poll filters a settled @@ -119,6 +122,10 @@ export function apply(ctx: ClientContext): void { for (const key of [...fetches.keys()]) invalidate(key) } + // The bound translate resolves against the registered dictionaries with the + // locale service's own fallback ladder; candidate-time reads stay plain text. + const t = ctx.locale.bind(NS) + const source: SlashSource = { trigger: '/', name: 'skill', @@ -129,7 +136,12 @@ export function apply(ctx: ClientContext): void { if (signal.aborted) return [] return skills .filter(skill => skill.name.startsWith(query)) - .map(skill => ({ name: skill.name, description: skill.description })) + .map(skill => ({ + name: skill.name, + // The user-only marker rides the description (the menu's only + // secondary text); `hint` is the claim-state ghost text, not a badge. + description: skill.modelInvocable ? skill.description : `${t('menu.userOnly')} · ${skill.description}`, + })) }, warm(session) { // Fire-and-forget scope-birth prewarm; the shared fetch reports @@ -150,16 +162,14 @@ export function apply(ctx: ClientContext): void { } }, onPick({ candidate }) { - // Decision 21: plain-text reference — the literal lands in the draft - // and ships to the model verbatim (trailing space closes the token). - // Legacy path (decision 21), retained for the removal cut, no longer reached: - // return { insert: { source: 'skill', ref: candidate.name, label: candidate.name, clipboardText: `/${candidate.name}` } } + // Decision 21: the pick lands plain text and the prompt ships the same + // literal. Determinism no longer rides the client — the host's + // pre-step boundary (dsh-tool-skill) recognizes the leading /name and + // injects the rendered body for every front end. A name shared with a + // host command still resolves to the command: adjudication claims the + // line client-side before it ever becomes a prompt. return { text: `/${candidate.name} ` } }, - codec: { - clipboardText: ref => `/${ref}`, - serialize: ref => Promise.resolve(`${ref}`), - }, } const slash = ctx.get('slash') as SlashServiceContract ctx.on('connection/reset', clearAll) diff --git a/packages/client/ui-skill/src/client/locales.ts b/packages/client/ui-skill/src/client/locales.ts index 53746397bc..40ef78dea5 100644 --- a/packages/client/ui-skill/src/client/locales.ts +++ b/packages/client/ui-skill/src/client/locales.ts @@ -9,6 +9,7 @@ export const zh = { 'row.failed': 'skill 加载失败', 'row.stopped': 'skill 加载已中止', 'row.instructions': '说明', + 'menu.userOnly': '仅用户', } satisfies Record /** The skill namespace key union. */ @@ -20,4 +21,5 @@ export const en = { 'row.failed': 'Skill load failed', 'row.stopped': 'Skill load stopped', 'row.instructions': 'Instructions', + 'menu.userOnly': 'user-only', } satisfies Record diff --git a/packages/client/ui-skill/tests/browser-plugin.spec.ts b/packages/client/ui-skill/tests/browser-plugin.spec.ts index 9b047a3713..f73a8d8bda 100644 --- a/packages/client/ui-skill/tests/browser-plugin.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.spec.ts @@ -20,11 +20,15 @@ import type { ClientSessionContext, SlashSource } from '@deepseek-ai/dsh-client- import { apply, inject } from '../src/client/index.ts' import { SkillRow as SkillToolRow } from '../src/client/SkillRow.tsx' -type SkillRow = { name: string; description: string; whenToUse?: string } +type SkillRow = { name: string; description: string; whenToUse?: string; modelInvocable?: boolean } type ListResult = | { ok: true; value: { skills: SkillRow[] } } | { ok: false; error: { code: string; message: string; details: object } } type ListFn = (payload: object, signal?: AbortSignal) => Promise<{ result: ListResult }> +type InvokeResult = + | { ok: true; value: { accepted: true } } + | { ok: false; error: { code: string; message: string; details: object } } +type InvokeFn = (payload: object) => Promise<{ result: InvokeResult }> interface PresentationCapture { slots: SlotsService @@ -49,16 +53,19 @@ function providePresentation(ctx: Context): PresentationCapture { capture.dictionaries.push({ namespace, dictionaries }) return () => { capture.localeDisposed = true } }, + // Minimal bound-translate fake: zh dictionary lookup, key passthrough on miss. + bind: () => (key: string) => key === 'menu.userOnly' ? '仅用户' : key, }) return capture } /** Boot the plugin over fake slash/connection faces; returns the captured source and its ctx. */ -async function bench(list: ListFn, addressed?: SessionId) { +async function bench(list: ListFn, addressed?: SessionId, invoke?: InvokeFn) { const ctx = new Context() let captured: SlashSource | undefined ctx.provide('slash', { registerSource: (src: SlashSource) => { captured = src; return () => {} } }) - ctx.provide('connection', { api: { skills: { list } } }) + const defaultInvoke: InvokeFn = () => Promise.resolve({ result: { ok: true as const, value: { accepted: true as const } } }) + ctx.provide('connection', { api: { skills: { list, invoke: invoke ?? defaultInvoke } } }) ctx.provide('sessions', { subagentAddress: (id: SessionId) => id === addressed ? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const } @@ -70,9 +77,9 @@ async function bench(list: ListFn, addressed?: SessionId) { } const CATALOG: SkillRow[] = [ - { name: 'commit-helper', description: 'commit flow' }, - { name: 'code-review', description: 'review flow', whenToUse: 'reviews' }, - { name: 'deploy', description: 'deploy flow' }, + { name: 'commit-helper', description: 'commit flow', modelInvocable: true }, + { name: 'code-review', description: 'review flow', whenToUse: 'reviews', modelInvocable: true }, + { name: 'deploy', description: 'deploy flow', modelInvocable: true }, ] const listOk = (skills: SkillRow[]): ListFn => () => Promise.resolve({ result: { ok: true as const, value: { skills } } }) @@ -117,12 +124,14 @@ describe('apply', () => { 'row.failed': 'skill 加载失败', 'row.stopped': 'skill 加载已中止', 'row.instructions': '说明', + 'menu.userOnly': '仅用户', }, en: { 'row.running': 'Loading skill', 'row.failed': 'Skill load failed', 'row.stopped': 'Skill load stopped', 'row.instructions': 'Instructions', + 'menu.userOnly': 'user-only', }, }, }]) @@ -313,8 +322,8 @@ describe('lexicon', () => { }) }) -describe('pick and codec', () => { - it('onPick returns the literal /name text with a closing space (decision 21)', async () => { +describe('pick lands plain text (decision 21)', () => { + it('onPick returns the literal /name text with a closing space', async () => { const { source } = await bench(listOk(CATALOG)) const outcome = source.onPick({ candidate: { name: 'commit-helper', description: 'commit flow' }, @@ -326,18 +335,27 @@ describe('pick and codec', () => { expect(outcome).toEqual({ text: '/commit-helper ' }) }) - it('codec projects clipboard `/name` and serializes the model form name', async () => { - const { source } = await bench(listOk(CATALOG)) - expect(source.codec!.clipboardText('deploy')).toBe('/deploy') - await expect(source.codec!.serialize('deploy', new AbortController().signal)) - .resolves.toBe('deploy') - }) -}) - -describe('adjudication', () => { - it('never participates: no matchSpace/matchEnter hooks on the skill source', async () => { + it('keeps the legacy reference codec removed and stays out of adjudication', async () => { const { source } = await bench(listOk(CATALOG)) + // Determinism lives host-side (the pre-step gesture boundary), so the + // source neither claims lines nor serializes reference markup. + expect(source.codec).toBeUndefined() expect(typeof source.matchSpace).toBe('undefined') expect(typeof source.matchEnter).toBe('undefined') }) }) + +describe('user-only marking', () => { + it('prefixes the description of candidates the model cannot invoke', async () => { + const rows: SkillRow[] = [ + { name: 'shared-skill', description: 'both surfaces', modelInvocable: true }, + { name: 'user-only-skill', description: 'user surface only', modelInvocable: false }, + ] + const { source } = await bench(listOk(rows)) + const candidates = await source.candidates(proj('s1'), req('')) + expect(candidates).toEqual([ + { name: 'shared-skill', description: 'both surfaces' }, + { name: 'user-only-skill', description: '仅用户 · user surface only' }, + ]) + }) +}) diff --git a/packages/client/ui-slots/tsdown.config.ts b/packages/client/ui-slots/tsdown.config.ts new file mode 100644 index 0000000000..b199e31976 --- /dev/null +++ b/packages/client/ui-slots/tsdown.config.ts @@ -0,0 +1,6 @@ +import { clientLibrary } from '../tsdown.client.ts' + +export default clientLibrary( + '@deepseek-ai/dsh-client-ui-slots', + ['lib/types/index.js', 'lib/types/invariant.js'], +) diff --git a/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx b/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx index 76a216ebd8..3a6ace14a5 100644 --- a/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx +++ b/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx @@ -1,9 +1,9 @@ import { - useEffect, useRef, useState, type KeyboardEvent, type MouseEvent, + useEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent, } from 'react' -import type { - SessionId, SessionListState, SessionProjectionMap, SessionSummary, SubagentAddress, - SubagentCatalogSnapshot, +import { + indexSubagentDescendants, type SessionId, type SessionListState, type SessionProjectionMap, + type SessionSummary, type SubagentAddress, type SubagentCatalogSnapshot, } from '@deepseek-ai/dsh-client-runtime/client' import { IconChevronDownOutline14, IconChevronRightOutline14, IconRefreshOutline14, StateDot, @@ -171,30 +171,7 @@ function formatExactDuration(ms: number, t: TranslateNS): string { }) } -/** Aggregate the complete subagent-only descendant subtree from flat summaries. */ -function summarizeDescendants( - sessionId: SessionId, - summaries: Readonly>, -): { count: number; running: boolean } { - let count = 0 - let running = false - for (const summary of Object.values(summaries)) { - if (summary.origin !== 'subagent') continue - const seen = new Set() - let current: SessionSummary | undefined = summary - while (current?.origin === 'subagent' && current.parentId !== undefined - && !seen.has(current.id)) { - seen.add(current.id) - if (current.parentId === sessionId) { - count += 1 - running ||= summary.running - break - } - current = summaries[current.parentId] - } - } - return { count, running } -} +const NO_DESCENDANTS = { count: 0, runningCount: 0 } as const /** Render the known direct-child shape while its authoritative catalog hydrates. */ function CatalogLoadingRows({ @@ -448,12 +425,15 @@ export function SubagentCatalogAction({ const setCatalogOpenRef = useRef(setCatalogOpen) setCatalogOpenRef.current = setCatalogOpen const healthy = catalog?.entries.filter(entry => entry.kind === 'child') ?? [] - const descendants = summarizeDescendants(sessionId, summaries) + const descendants = useMemo( + () => indexSubagentDescendants(summaries).get(sessionId) ?? NO_DESCENDANTS, + [sessionId, summaries], + ) // The catalog can arrive before the session-list baseline; never undercount // the already-visible direct rows during that short bootstrap window. const descendantCount = Math.max(healthy.length, descendants.count) const totalCountKey = descendantCount === 1 ? 'count.total.one' : 'count.total.other' - const runningCountKey = descendantCount === 1 ? 'count.running.one' : 'count.running.other' + const runningCountKey = descendants.runningCount === 1 ? 'count.running.one' : 'count.running.other' // Session summaries can announce membership before the descriptor-backed catalog catches up. // Keep that entry point visible through disabled loading rows; only catalog rows are navigable. const summaryBackedLoading = descendants.count > 0 @@ -527,10 +507,10 @@ export function SubagentCatalogAction({ }, [open]) useEffect(() => { - if (!open || !descendants.running) return + if (!open || descendants.runningCount === 0) return const timer = setInterval(() => { setNow(Date.now()) }, 1_000) return () => { clearInterval(timer) } - }, [open, descendants.running]) + }, [open, descendants.runningCount]) useEffect(() => () => { for (const parentSessionId of observedCatalogs.current) { @@ -584,7 +564,10 @@ export function SubagentCatalogAction({ className={css.trigger} aria-haspopup="tree" aria-expanded={open} - aria-label={t(descendants.running ? runningCountKey : totalCountKey, { count: descendantCount })} + aria-label={t( + descendants.runningCount > 0 ? runningCountKey : totalCountKey, + { count: descendants.runningCount > 0 ? descendants.runningCount : descendantCount }, + )} onClick={() => { changeOpen(!open) }} onKeyDown={(event) => { if (event.key !== 'ArrowDown') return @@ -594,7 +577,7 @@ export function SubagentCatalogAction({ }} > - {descendants.running && } + {descendants.runningCount > 0 && } {t(totalCountKey, { count: descendantCount })} diff --git a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx index e0610d3379..c300e423e6 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx @@ -108,7 +108,7 @@ describe('SubagentCatalogAction', () => { } const view = render() - const trigger = screen.getByRole('button', { name: '3 个子代理,正在运行' }) + const trigger = screen.getByRole('button', { name: '1 个子代理,正在运行' }) expect(trigger.querySelector('[data-state="ongoing"]')).not.toBeNull() view.rerender( { })) as Record const input = props(catalog({ entries }), {}, summaries) render() - fireEvent.click(screen.getByRole('button', { name: /9 个子代理/ })) + const trigger = screen.getByRole('button', { name: '1 个子代理,正在运行' }) + expect(within(trigger).getByText('9 个子代理')).toBeTruthy() + fireEvent.click(trigger) const runningRow = screen.getByRole('treeitem', { name: /running.*4\.6K tok · 1分10秒/ }) const runningMetrics = within(runningRow) @@ -506,7 +508,8 @@ describe('SubagentCatalogAction', () => { const absent = props(undefined, {}, summaries) const view = render() - const trigger = screen.getByRole('button', { name: '2 个子代理,正在运行' }) + const trigger = screen.getByRole('button', { name: '1 个子代理,正在运行' }) + expect(within(trigger).getByText('2 个子代理')).toBeTruthy() fireEvent.click(trigger) expect(absent.setCatalogOpen).toHaveBeenCalledWith(PARENT, true) expect(screen.getAllByRole('treeitem', { name: '正在加载子代理' })).toHaveLength(2) @@ -514,7 +517,7 @@ describe('SubagentCatalogAction', () => { const staleEmpty = props(catalog({ entries: [] }), {}, summaries) view.rerender() - expect(screen.getByRole('button', { name: '2 个子代理,正在运行' })).toBeTruthy() + expect(screen.getByRole('button', { name: '1 个子代理,正在运行' })).toBeTruthy() expect(screen.getAllByRole('treeitem', { name: '正在加载子代理' })).toHaveLength(2) expect(staleEmpty.openChild).not.toHaveBeenCalled() }) diff --git a/packages/client/ui-theme/tsdown.config.ts b/packages/client/ui-theme/tsdown.config.ts index 08616753ce..25b80eef68 100644 --- a/packages/client/ui-theme/tsdown.config.ts +++ b/packages/client/ui-theme/tsdown.config.ts @@ -1,11 +1,11 @@ import { clientBundle } from '../tsdown.client.ts' -const [lib, client] = clientBundle( +export default clientBundle( '@deepseek-ai/dsh-client-ui-theme', ['lib/types/index.js', 'lib/types/invariant.js'], + { + lib: { + copy: [{ from: 'src/styles/*', to: 'lib/styles' }], + }, + }, ) - -export default [{ - ...lib, - copy: [{ from: 'src/styles/*', to: 'lib/styles' }], -}, client] diff --git a/packages/client/ui-trajectory/tests/layout.spec.tsx b/packages/client/ui-trajectory/tests/layout.spec.tsx index bd25cc4d51..544199e345 100644 --- a/packages/client/ui-trajectory/tests/layout.spec.tsx +++ b/packages/client/ui-trajectory/tests/layout.spec.tsx @@ -324,7 +324,10 @@ describe('deriveTrajectoryLayout', () => { }, // A landed compaction renders no cell, but is still a real log position, // so it moves the cursor after the visible context row. - { kind: 'compaction', seq: 5, time: 9_500, summary: 'checkpoint facts' }, + { + kind: 'compaction', seq: 5, time: 9_500, summary: 'checkpoint facts', + summaryEventSeq: 4, shadowedItemCount: 2, shadowedTokenCount: 100, + }, { kind: 'assistant', seq: 6, time: 10_000, turn: 1, step: 0, blocks: [{ kind: 'text', text: 'done' }], diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index fee956b683..047513c346 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md -README.md: bd7313b560e76378e4fff274c99bb976819aebae -README.zh.md: 734a897b9cb9c3469d8f402b13bff4b62753f9b2 +README.md: 1ec07bd41e72bb5a26b2cfc3bf90e57e7d92db08 +README.zh.md: 18d9e356a9e5deb38624f377d41b2fc8bc677294 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index bd7313b560..1ec07bd41e 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -16,7 +16,7 @@ Session rows render the runtime's live `pendingInteraction` classification: appr Both target slots are declared by other plugins, so `apply` uses `slots.inject()` to register for each declaration lifetime and re-register after a declaring slot is restored. -The shared sidebar projection hides rows whose durable Session summary has `origin: 'subagent'`; users enter those conversations through the selected parent's subagent header catalog. Ordinary forks remain visible because lineage alone does not set that origin. The runtime keeps hidden rows available for conversation, title, and addressed transport state. +The shared sidebar projection hides rows whose durable Session summary has `origin: 'subagent'`; users enter those conversations through the selected parent's subagent header catalog. Each visible ordinary row inherits the blue activity indicator while any descendant reached through uninterrupted subagent-origin lineage is running, and its hover and assistive text report the exact running-descendant count without describing an idle parent as running. Ordinary forks remain visible and terminate this aggregation because lineage alone does not set their origin. Pending user interaction outranks the session's own running state, and either remains the primary row status while descendant activity stays available as a separate hover and assistive status. With neither present, descendant activity outranks the green unviewed-completion reminder; the reminder returns once no descendant is running. The runtime keeps hidden rows available for conversation, title, and addressed transport state. ## Model Experience diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 734a897b9c..18d9e356a9 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -16,7 +16,7 @@ Session 行渲染运行时的实时 `pendingInteraction` 分类:审批显示** 两个目标 slot 都由其他插件声明,因此 `apply` 使用 `slots.inject()` 在各自的声明生命周期内完成注册,并在目标 slot 的声明恢复后重新注册。 -共享侧边栏投影会隐藏持久化 Session 摘要中带有 `origin: 'subagent'` 的行;用户从所选 parent 的 subagent 页头目录进入这些对话。普通 fork 仍然可见,因为仅有谱系不会设置该 origin。运行时仍保留隐藏行,供对话、标题与已寻址传输状态使用。 +共享侧边栏投影会隐藏持久化 Session 摘要中带有 `origin: 'subagent'` 的行;用户从所选 parent 的 subagent 页头目录进入这些对话。每个可见的普通行都会在经不间断的 subagent 谱系可达的任一后代运行时继承蓝色活动指示器;其悬停与无障碍文本会报告确切的运行中后代数量,同时不会把空闲 parent 描述为正在运行。普通 fork 仍然可见,并会终止此聚合,因为仅有谱系不会设置该 origin。待处理的用户交互优先于会话自身的运行中状态,二者无论哪一项存在都会保持为行的主要状态,而后代活动仍作为独立的悬停与无障碍状态保留。两者均不存在时,后代活动优先于绿色的未查看完成提醒;最后一个运行中的后代停止后,该提醒会重新出现。运行时仍保留隐藏行,供对话、标题与已寻址传输状态使用。 ## 模型体验 diff --git a/packages/client/ui-workspace/src/client/locales.ts b/packages/client/ui-workspace/src/client/locales.ts index d9c70de729..30fe6bfcc0 100644 --- a/packages/client/ui-workspace/src/client/locales.ts +++ b/packages/client/ui-workspace/src/client/locales.ts @@ -45,6 +45,8 @@ export const zh = { 'actions.session.aria': '会话“{name}”的操作', 'actions.newSession.aria': '在“{name}”中新建会话', 'status.running': '进行中', + 'status.subagentsRunning.one': '{n} 个子代理运行中', + 'status.subagentsRunning.other': '{n} 个子代理运行中', 'status.idle': '空闲', 'status.waitingApproval': '等待审批', 'status.planReview': '计划待审', @@ -106,6 +108,8 @@ export const en = { 'actions.session.aria': 'Session actions for {name}', 'actions.newSession.aria': 'New session in {name}', 'status.running': 'Running', + 'status.subagentsRunning.one': '{n} subagent running', + 'status.subagentsRunning.other': '{n} subagents running', 'status.idle': 'Idle', 'status.waitingApproval': 'Waiting for approval', 'status.planReview': 'Plan awaiting review', diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index fb64a0be42..40ef1e8883 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -171,37 +171,70 @@ function assertNever(value: never): never { throw new Error(`unknown pending interaction: ${String(value)}`) } -/** Session status presentation; pending user interaction outranks the running state. */ -function sessionStatus( - node: Pick, +interface SessionStatus { + state: StateDotState + label: string +} + +/** + * Session status presentation; pending interaction is primary and live activity + * outranks completion reminders. + */ +function sessionStatuses( + node: Pick, t: RowTranslate, -): { state: StateDotState; label: string } { +): readonly [SessionStatus, ...SessionStatus[]] { + const subagents: SessionStatus | undefined = node.runningSubagentCount === 0 + ? undefined + : { + state: 'ongoing', + label: t( + node.runningSubagentCount === 1 + ? 'status.subagentsRunning.one' + : 'status.subagentsRunning.other', + { n: node.runningSubagentCount }, + ), + } + let pending: SessionStatus | undefined switch (node.pendingInteraction) { - case 'approval': return { state: 'warning', label: t('status.waitingApproval') } - case 'plan-review': return { state: 'warning', label: t('status.planReview') } - case 'question': return { state: 'warning', label: t('status.waitingAnswer') } + case 'approval': + pending = { state: 'warning', label: t('status.waitingApproval') } + break + case 'plan-review': + pending = { state: 'warning', label: t('status.planReview') } + break + case 'question': + pending = { state: 'warning', label: t('status.waitingAnswer') } + break case undefined: break /* v8 ignore next -- closed PendingInteractionStatus union */ default: return assertNever(node.pendingInteraction) } - if (node.running) return { state: 'ongoing', label: t('status.running') } - if (node.completed) return { state: 'done', label: t('status.completed') } - return { state: 'done', label: t('status.idle') } + if (pending !== undefined) return subagents === undefined ? [pending] : [pending, subagents] + if (node.running) { + const primary: SessionStatus = { state: 'ongoing', label: t('status.running') } + return subagents === undefined ? [primary] : [primary, subagents] + } + if (subagents !== undefined) return [subagents] + if (node.completed) return [{ state: 'done', label: t('status.completed') }] + return [{ state: 'done', label: t('status.idle') }] } -/** Hover-card body: full title, relative time, and interaction/running/completed/idle status. */ +/** Hover-card body: full title, relative time, and every relevant live status. */ function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number; t: RowTranslate }) { - const status = sessionStatus(node, t) + const statuses = sessionStatuses(node, t) return (
{displayTitle(node, t)}
{/* Same placeholder rule as the row's trailing cell: no timestamp before the first prompt. */} {!node.blank &&
{hoverTimeLabel(node.updatedAt, now, t)}
} -
- - {status.label} -
+ {statuses.map(status => ( +
+ + {status.label} +
+ ))}
) } @@ -241,7 +274,8 @@ export function SearchResultItem({ result, currentId, onOpen, t }: { t: RowTranslate }) { const selected = result.id === currentId - const status = sessionStatus(result, t) + const statuses = sessionStatuses(result, t) + const primaryStatus = statuses[0] return (