diff --git a/.agents/notes/README.i18n.yaml b/.agents/notes/README.i18n.yaml
index f92ea2f66d..faf4d69b5b 100644
--- a/.agents/notes/README.i18n.yaml
+++ b/.agents/notes/README.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/README.md
README.md: ae8e4724d610c97d74910d7dec5c95af69e93281
-README.zh.md: a16403ae87cd16d758b8303f42f60ad34b9c1eb3
+README.zh.md: 8cecc7068d9efc5e2ebb78d2301d03633f70f137
diff --git a/.agents/notes/README.zh.md b/.agents/notes/README.zh.md
index a16403ae87..8cecc7068d 100644
--- a/.agents/notes/README.zh.md
+++ b/.agents/notes/README.zh.md
@@ -16,13 +16,13 @@
文件名中的日期是该主题**首次提出**的时间(以 git 历史为准)。Agent Note 之间的交叉引用使用相对 Markdown 链接(`[topic](../../implemented/architecture/2026-…-….md)`),从不使用纯文字或编号,这样既可机械检查,也能在文件夹间移动时保持有效。
-活跃生命周期目录树就是工作清单:浏览其生命周期/类别文件夹,或搜索仓库即可。请勿添加集中式 `INDEX.md`;设计理由见[不设索引的 Agent Note](implemented/process/2026-07-19-remove-generated-agent-note-index.md)。未来指导价值较低的已实施记录会移至下文所述、单独冻结的 [`archived/`](archived/AGENTS.md) 目录树。
+活跃生命周期目录树就是工作清单:浏览其生命周期/类别文件夹,或搜索仓库即可。请勿添加集中式 `INDEX.md`;设计理由见[不设索引的 Agent Note](implemented/process/2026-07-19-remove-generated-agent-note-index.zh.md)。未来指导价值较低的已实施记录会移至下文所述、单独冻结的 [`archived/`](archived/AGENTS.md) 目录树。
## 分类
-每份 Agent Note 属于 `scripts/agent-note-tree.ts` 中封闭集合里的一个路径编码类别;分类门禁拒绝其他文件夹。新增类别需要同时更新规范集合与本节。见[分类 Agent Note](implemented/process/2026-06-20-agent-note-classification.md)。
+每份 Agent Note 属于 `scripts/agent-note-tree.ts` 中封闭集合里的一个路径编码类别;分类门禁拒绝其他文件夹。新增类别需要同时更新规范集合与本节。见[分类 Agent Note](implemented/process/2026-06-20-agent-note-classification.zh.md)。
| 类别 | 覆盖范围 |
|---|---|
@@ -41,7 +41,9 @@
归档路径编码为 `archived/{class}/yyyy-mm-dd-topic-title.md`;其中有意省略 `implemented`,因为只有 implemented Agent Note 可以进入归档。归档变更会移动完整的英文、中文和伴随记录三个文件,保留 `Status: implemented`,在两种语言的文件中紧接该状态行插入相同的 `Archived: YYYY-MM-DD` 行,重新记录伴随记录,并修复或删除入站链接。归档时只允许对内容做这些更改。
-封存后,每组归档文件都永久冻结。禁止编辑、翻译、重新格式化、更新、移动或删除,也不得将其视为当前行为的权威依据。文档门禁会跳过归档源文件,包括其中的出站链接;当活跃文档有意引用历史时,仍可链接到归档 Agent Note。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 强制执行封闭的类别目录树、完整的三文件配对、归档元数据、伴随记录 hash,以及仅追加的冻结内容 manifest(元数据清单)。[归档政策 Agent Note](implemented/process/2026-07-26-frozen-agent-note-archive.md) 记录了设计依据。
+封存后,每组归档文件都永久冻结。禁止编辑、翻译、重新格式化、更新、移动或删除,也不得将其视为当前行为的权威依据。文档门禁会跳过归档源文件,包括其中的出站链接;当活跃文档有意引用历史时,仍可链接到归档 Agent Note。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 强制执行封闭的类别目录树、完整的三文件配对、归档元数据、伴随记录 hash,以及仅追加的冻结内容 manifest(元数据清单)。[归档政策 Agent Note](implemented/process/2026-07-26-frozen-agent-note-archive.zh.md) 记录了设计依据。
+
+
## 何时需要写一份
@@ -57,7 +59,7 @@
## 文件格式
-每份活跃 Agent Note 遵循统一的文件内格式,由 `pnpm run verify-agent-note-format`([scripts/verify-agent-note-format.ts](../../scripts/verify-agent-note-format.ts),`doc-sync`(文档同步门禁)的一环)强制执行;该格式的设计动机及其否决的替代方案见[统一格式 Agent Note](implemented/process/2026-07-05-uniform-agent-note-format.md)。归档记录保留封存时的格式,并增加上述归档日期行。
+每份活跃 Agent Note 遵循统一的文件内格式,由 `pnpm run verify-agent-note-format`([scripts/verify-agent-note-format.ts](../../scripts/verify-agent-note-format.ts),`doc-sync`(文档同步门禁)的一环)强制执行;该格式的设计动机及其否决的替代方案见[统一格式 Agent Note](implemented/process/2026-07-05-uniform-agent-note-format.zh.md)。归档记录保留封存时的格式,并增加上述归档日期行。
### 头部块
@@ -126,4 +128,4 @@ Status:
### 中文对侧文件
-`.zh.md` 对侧文件按 [i18n 约定](../../docs/i18n/README.md)逐章节与其英文对侧文件保持相同结构;机器检查的头部标记(`# Agent Note: ` 和 `Status:` 行)保持英文原样不翻译。格式门禁跳过 `.zh.md` 文件;配对门禁检查它们的一致性。
+`.zh.md` 对侧文件按 [i18n 约定](../../docs/i18n/README.zh.md)逐章节与其英文对侧文件保持相同结构;机器检查的头部标记(`# Agent Note: ` 和 `Status:` 行)保持英文原样不翻译。格式门禁跳过 `.zh.md` 文件;配对门禁检查它们的一致性。
diff --git a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml
index 0c0e629d26..dac9ce6b5c 100644
--- a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md
2026-06-11-content-block-vocabulary.md: a31df6a7d16ea7cba649702fdb474dab34533c1b
-2026-06-11-content-block-vocabulary.zh.md: 5ac882e9de7dea02cc6534aee99c46869cc9363f
+2026-06-11-content-block-vocabulary.zh.md: da387b179816cda64791e71ca7affa1fbdfd195b
diff --git a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md
index 5ac882e9de..da387b1798 100644
--- a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md
+++ b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md
@@ -12,7 +12,7 @@ harness 需要一套统一的内部消息语言,供 agent loop(智能体循
自主拥有词汇:消息是类型化内容块的数组(`text`、`reasoning`、`tool-call`、`tool-result`),其联合类型派生自可合并扩展的 `ContentBlockMap`,插件通过声明合并添加新的块类型。同一可合并扩展映射模式为所有「字符串化」字段提供类型(`MessageSource`、`FinishReason`、`TurnTrigger`、`TurnEndReason`)。流式输出采用原始分片协议;`BlockAssembler` 是唯一的共享组装实现。适配器负责转换为提供方的协议格式(wire format)——映射成本留在适配器中,正是它该在的地方。
-会话内上下文注入(`context/message`)和轮次中途 steering(中途引导)最初渲染为带标签的 user-role 信封(system-reminder 模式),而非引入新角色,因此适配器无需承担额外负担。如今两者都投影为无包装的普通用户内容;见[注入内容信封 Agent Note](../simplification/2026-07-20-unwrap-injected-content-envelopes.md)。实际适配器验证已确认此渲染方式符合当前 DeepSeek 的行为;如果未来某提供方出现不兼容,应在该适配器内处理,而非引入新的规范角色。
+会话内上下文注入(`context/message`)和轮次中途 steering(中途引导)最初渲染为带标签的 user-role 信封(system-reminder 模式),而非引入新角色,因此适配器无需承担额外负担。如今两者都投影为无包装的普通用户内容;见[注入内容信封 Agent Note](../simplification/2026-07-20-unwrap-injected-content-envelopes.zh.md)。实际适配器验证已确认此渲染方式符合当前 DeepSeek 的行为;如果未来某提供方出现不兼容,应在该适配器内处理,而非引入新的规范角色。
## 曾考虑的替代方案
diff --git a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml
index e5c589dbf9..44272f7ca1 100644
--- a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md
2026-06-11-dev-invariants-over-deep-readonly.md: 66980f1ee09c6112f72786d6c3a147aadbc57f6c
-2026-06-11-dev-invariants-over-deep-readonly.zh.md: 0d694ea84e1b7c265491e9adb64757378b4185f1
+2026-06-11-dev-invariants-over-deep-readonly.zh.md: 576e53e0b27e65f6fa071ff649509223a7bc30ff
diff --git a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md
index 0d694ea84e..576e53e0b2 100644
--- a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md
+++ b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md
@@ -32,7 +32,7 @@ TypeScript readonly 类型不是充分的运行时边界。它们在程序运行
### 包拥有的不变式配套插件检查关系
-`dsh-invariants` 注册可配置的 `ctx.invariants` 服务,本身不包含产品检查。每个包发布一个 `./invariant` 所有权配套插件;`dsh-session`、`dsh-agent`、`dsh-scope` 和 `dsh-agent-loop` 目前添加需要跟踪状态或观察另一个 seam 的规则:单调递增的序列号、轮次与步骤嵌套、工具调用/结果配对、合法的 agent(智能体)状态转换、主体正确的作用域分发,以及循环构建的请求与从其会话日志前缀重建的请求之间的相等性。全局启用和包名 regex 过滤器归该服务所有(见[包拥有的不变式服务](2026-07-19-package-owned-invariant-service.md))。
+`dsh-invariants` 注册可配置的 `ctx.invariants` 服务,本身不包含产品检查。每个包发布一个 `./invariant` 所有权配套插件;`dsh-session`、`dsh-agent`、`dsh-scope` 和 `dsh-agent-loop` 目前添加需要跟踪状态或观察另一个 seam 的规则:单调递增的序列号、轮次与步骤嵌套、工具调用/结果配对、合法的 agent(智能体)状态转换、主体正确的作用域分发,以及循环构建的请求与从其会话日志前缀重建的请求之间的相等性。全局启用和包名 regex 过滤器归该服务所有(见[包拥有的不变式服务](2026-07-19-package-owned-invariant-service.zh.md))。
当会话配套插件附加到已有会话或以种子记录初始化的会话时,它回放不可变日志以重建跟踪状态。服务为每项贡献提供一个可 dispose(资源释放)的子 fiber,因此轮次中途热重载是安全的,同时不赋予诊断逻辑对会话存储的所有权。
diff --git a/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.i18n.yaml
index 760c3fd099..868d215253 100644
--- a/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.md
2026-06-11-event-sourced-sessions.md: b6d17d2db1d9b489f3d4224683b61e62be485014
-2026-06-11-event-sourced-sessions.zh.md: a78349ea2385b3c9d32870757bb607f15996196d
+2026-06-11-event-sourced-sessions.zh.md: 11e5740dc2636fc002ed7158167b1dfbb39a971d
diff --git a/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.zh.md b/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.zh.md
index a78349ea23..11e5740dc2 100644
--- a/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.zh.md
+++ b/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.zh.md
@@ -24,5 +24,5 @@ MVP 要求严格的基于事件的追踪,以及完全可回放的会话(严
- 回放、追踪与遥测在结构上得到保证,而非事后附加。
- 持久化仍是插件关注点;内存存储随 dsh-session 一起提供。
-- 事件词汇可通过合并扩展(插件可添加如压缩(compaction)事件);[会话持久化](2026-06-14-session-persistence.md)在日志具备持久性后固定了其结构。
+- 事件词汇可通过合并扩展(插件可添加如压缩(compaction)事件);[会话持久化](2026-06-14-session-persistence.zh.md)在日志具备持久性后固定了其结构。
- 派生成本随日志长度增长,压缩(dsh-compaction)是预期的缓解手段,而不是改写日志。
diff --git a/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.i18n.yaml
index 8e97d86ccf..57843018c6 100644
--- a/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md
2026-06-11-microkernel-event-taxonomy.md: 6cacbc30f29c863de0f8ddfa4df7251a64bc80ca
-2026-06-11-microkernel-event-taxonomy.zh.md: 2e43b57d62d8fca692ffab379d1841e6a3e61fad
+2026-06-11-microkernel-event-taxonomy.zh.md: 1c7919392b835c491b9dc606d46f638a708d2948
diff --git a/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.zh.md b/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.zh.md
index 2e43b57d62..1c7919392b 100644
--- a/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.zh.md
+++ b/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.zh.md
@@ -25,7 +25,7 @@ Status: implemented
## 后果
-- 每个 MVP 功能都映射到一个监听器([功能→机制映射](../../../../docs/cookbook/extension-cookbook.md#the-feature--mechanism-map)是证明义务,保持更新)。
+- 每个 MVP 功能都映射到一个监听器([功能→机制映射](../../../../docs/cookbook/extension-cookbook.zh.md#the-feature--mechanism-map)是证明义务,保持更新)。
- HMR 与 dispose 无需额外工作:监听器和注册均为 Cordis effect。
- waterfall 语义(调用 `next()` 或短路)不直观,需要教学——在 AGENTS.md 中记录,并由组合测试覆盖。
- 循环必须具备防御性:插件异常在轮次级别被隔离,来自任何扩展点的 steering(中途引导)永远不会被搁置(有回归测试保障)。
diff --git a/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.i18n.yaml
index 2e6f1ad2df..821a220d9a 100644
--- a/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.md
2026-06-11-runtime-arg-validation.md: 520823c76f147205805d306e97b5461f38b84c35
-2026-06-11-runtime-arg-validation.zh.md: 0a6ef40c93ec3550d6e991836950be849e86ede7
+2026-06-11-runtime-arg-validation.zh.md: bc2f743300be78cc2a01f027d5aca81fcb18b468
diff --git a/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.zh.md b/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.zh.md
index 0a6ef40c93..bc2f743300 100644
--- a/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.zh.md
+++ b/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.zh.md
@@ -6,7 +6,7 @@ Status: implemented
## 问题
-`defineTool`([统一 schema DSL](2026-07-20-unified-json-value-schema-dsl.md))为工具作者的 `execute(args)` 提供了经 `InferArgs` 映射的类型化参数。但该类型只是对运行时值的编译期声明,而这个值实际上是模型生成的 JSON:没有任何机制强制模型遵守 schema,因此畸形调用(缺少必需键、声明为数字的位置传入字符串,或字面量超出声明的集合)会以「仅在名义上类型化」的状态到达 `execute`。工具函数体随后要么在处理结构错误的数据时崩溃,要么在不报错的情况下行为异常。
+`defineTool`([统一 schema DSL](2026-07-20-unified-json-value-schema-dsl.zh.md))为工具作者的 `execute(args)` 提供了经 `InferArgs` 映射的类型化参数。但该类型只是对运行时值的编译期声明,而这个值实际上是模型生成的 JSON:没有任何机制强制模型遵守 schema,因此畸形调用(缺少必需键、声明为数字的位置传入字符串,或字面量超出声明的集合)会以「仅在名义上类型化」的状态到达 `execute`。工具函数体随后要么在处理结构错误的数据时崩溃,要么在不报错的情况下行为异常。
## 决策
@@ -17,8 +17,8 @@ Status: implemented
## 后果
- 模型会收到有关自身畸形调用的可操作反馈,而不是遭遇不透明的崩溃,弥合了 `InferArgs` 的承诺与运行时现实之间的鸿沟。
-- 校验器与 `InferArgs` 必须保持一致;一项[属性测试](../testing/2026-06-11-property-based-testing.md)生成满足 spec 的参数并断言它们通过 `validateArgs`(同时通过针对性改坏参数来断言其会被拒绝),通过自动化检查消除这种漂移风险。
-- `ToolArgsError` 是[结构化错误分类体系](2026-06-11-structured-error-taxonomy.md)中 `HarnessError` 的子类,保留其 `code` 字段;读取 `.message` 的调用方不受该层级结构影响。
+- 校验器与 `InferArgs` 必须保持一致;一项[属性测试](../testing/2026-06-11-property-based-testing.zh.md)生成满足 spec 的参数并断言它们通过 `validateArgs`(同时通过针对性改坏参数来断言其会被拒绝),通过自动化检查消除这种漂移风险。
+- `ToolArgsError` 是[结构化错误分类体系](2026-06-11-structured-error-taxonomy.zh.md)中 `HarnessError` 的子类,保留其 `code` 字段;读取 `.message` 的调用方不受该层级结构影响。
- 校验开销相对于一次模型调用可忽略不计。
diff --git a/.agents/notes/implemented/architecture/2026-06-13-capability-seams.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-13-capability-seams.i18n.yaml
index 106164a71c..b7a0ea7192 100644
--- a/.agents/notes/implemented/architecture/2026-06-13-capability-seams.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-06-13-capability-seams.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-13-capability-seams.md
2026-06-13-capability-seams.md: 2a166278ea454895177fa12b58f5493276f19cd1
-2026-06-13-capability-seams.zh.md: 0874af5826960ab9e718eb07b00c12b446edfd78
+2026-06-13-capability-seams.zh.md: 28b45cbbc7f65a0b783db3d91a2e559132b5779f
diff --git a/.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md b/.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md
index 0874af5826..28b45cbbc7 100644
--- a/.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md
+++ b/.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md
@@ -26,7 +26,7 @@ Service Provider 与 Consumer 由此独立演进:沙箱化执行器替换 `dsh
## 术语:seam 指三者组合,而非接口
-一个 **seam** 是完整的能力——三个角色合在一起:**Service Definition**(拥有 `ctx.` 和词汇的 Cordis `Service`)、一个或多个 **Service Provider**,以及一个或多个 **Consumer**。`packages/shell` 是规范范例——`dsh-shell` / `dsh-bash-local`+`dsh-bash-sandbox` / `dsh-tool-bash`。一个包可以承担多个角色,但单个角色本身不是 seam。「seam」一词严格保留给这种完整能力;命名其中一个组成部分时,应使用其角色、类、服务、约定或扩展点。[术语表](../../../../docs/glossary.md#capability-seam)是规范条目。
+一个 **seam** 是完整的能力——三个角色合在一起:**Service Definition**(拥有 `ctx.` 和词汇的 Cordis `Service`)、一个或多个 **Service Provider**,以及一个或多个 **Consumer**。`packages/shell` 是规范范例——`dsh-shell` / `dsh-bash-local`+`dsh-bash-sandbox` / `dsh-tool-bash`。一个包可以承担多个角色,但单个角色本身不是 seam。「seam」一词严格保留给这种完整能力;命名其中一个组成部分时,应使用其角色、类、服务、约定或扩展点。[术语表](../../../../docs/glossary.zh.md#capability-seam)是规范条目。
## 曾考虑的替代方案
@@ -35,4 +35,4 @@ Service Provider 与 Consumer 由此独立演进:沙箱化执行器替换 `dsh
## 后果
-分离角色会增加包和样板代码(`package.json`、`tsconfig`、README 和注入接线)。换来的是:Service Provider 与 Consumer 独立发布和版本管理,新后端永远不会波及面向模型的约定。[AGENTS.md](../../../../AGENTS.md) 和 [architecture.md](../../../../docs/architecture.md) 载有这项规则;bash 三件套是参考模板。本 Agent Note 记录为什么独立变化的角色通常需要拆分,而确实共享的关注点可以保持合并。
+分离角色会增加包和样板代码(`package.json`、`tsconfig`、README 和注入接线)。换来的是:Service Provider 与 Consumer 独立发布和版本管理,新后端永远不会波及面向模型的约定。[AGENTS.md](../../../../AGENTS.md) 和 [architecture.md](../../../../docs/architecture.zh.md) 载有这项规则;bash 三件套是参考模板。本 Agent Note 记录为什么独立变化的角色通常需要拆分,而确实共享的关注点可以保持合并。
diff --git a/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.i18n.yaml
index d44279eb42..3af4789414 100644
--- a/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md
2026-06-13-twin-llm-adapters.md: a4c87325a0b0d1ebe6cf8f95672e5de74ef37d57
-2026-06-13-twin-llm-adapters.zh.md: 2af7e7cca4d4c40e18cece1a4dd5c9cdb11901c8
+2026-06-13-twin-llm-adapters.zh.md: 36996750a16cc95393d727cffee3bcc53573eaf2
diff --git a/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md b/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md
index 2af7e7cca4..36996750a1 100644
--- a/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md
+++ b/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md
@@ -6,7 +6,7 @@ Status: implemented
## 问题
-`dsh-llm` 拥有一套提供方无关的流式词汇:`StreamChunk` 协议(`block-start`、`text-delta`、`reasoning-delta`、`tool-call-delta`、`block-end`、`usage`、`finish`)以及内容块类型([内容块词汇](2026-06-11-content-block-vocabulary.md))。如果词汇仅针对单个适配器定义,就有可能将该适配器的特异行为固化到「中立」约定中:唯一实现碰巧做了什么,什么就成为事实上的规范;在第二个提供方到来之前,抽象层未经验证——而届时修复这种泄漏的代价已经很高。
+`dsh-llm` 拥有一套提供方无关的流式词汇:`StreamChunk` 协议(`block-start`、`text-delta`、`reasoning-delta`、`tool-call-delta`、`block-end`、`usage`、`finish`)以及内容块类型([内容块词汇](2026-06-11-content-block-vocabulary.zh.md))。如果词汇仅针对单个适配器定义,就有可能将该适配器的特异行为固化到「中立」约定中:唯一实现碰巧做了什么,什么就成为事实上的规范;在第二个提供方到来之前,抽象层未经验证——而届时修复这种泄漏的代价已经很高。
## 决策
diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml
index 61f9754431..8f9aa62e49 100644
--- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-14-session-persistence.md
2026-06-14-session-persistence.md: 62228bd2f5b25b13880a563818d08f3a2d52d956
-2026-06-14-session-persistence.zh.md: b10ceebd95d4d05ae3f7ea620183ad8fffd074ee
+2026-06-14-session-persistence.zh.md: ebf004333c383336cd025aa8a4aabc9d1e07f0e5
diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md
index b10ceebd95..ebf004333c 100644
--- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md
+++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md
@@ -8,28 +8,28 @@ Status: implemented
会话此前仅存在于内存中。示例插件 `session-jsonl.ts`(在两个示例中逐字节重复)是只写的遥测:它缓冲 `session/event` 并追加 JSON 行,没有读取/回放路径,没有崩溃安全性(无 fsync、无原子写入、dispose(资源释放)时采用 fire-and-forget 方式排空),没有列表功能,也没有格式版本控制。没有任何机制能将磁盘上的历史会话重新注入到活跃的 agent(智能体)中,因此持久恢复、持久 fork 以及宿主侧的会话浏览都无法实现。
-[事件溯源模型](2026-06-11-event-sourced-sessions.md)将仅追加日志作为唯一真源,并从中派生 LLM(大语言模型)历史。持久化必须忠实于这一设计:直接持久化现有的 `SessionEvent`,不引入需要来回转换的并行「持久化消息」类型。后端也必须可替换——当前用文件存储,以后用数据库存储——并由同一接口封装。
+[事件溯源模型](2026-06-11-event-sourced-sessions.zh.md)将仅追加日志作为唯一真源,并从中派生 LLM(大语言模型)历史。持久化必须忠实于这一设计:直接持久化现有的 `SessionEvent`,不引入需要来回转换的并行「持久化消息」类型。后端也必须可替换——当前用文件存储,以后用数据库存储——并由同一接口封装。
## 决策
-持久化是一个具有抽象 Service Definition 的**能力 seam**([能力 seam](2026-06-13-capability-seams.md),`dsh-shell` 模板),而非循环或核心逻辑:
+持久化是一个具有抽象 Service Definition 的**能力 seam**([能力 seam](2026-06-13-capability-seams.zh.md),`dsh-shell` 模板),而非循环或核心逻辑:
1. **接口**(`dsh-session-persistence`,`ctx.sessionPersistence`):一个抽象的 `SessionPersistence` 服务,提供 `locate`/`create`/`append`/`prepare`/`load`/`inspect`/`readFrom`/`list`/`listSnapshots`。其持久化单元就是现有的 `SessionEvent`(`{ type, seq, time, data }`),原样复用,无转换类型。
-2. **实现**(`dsh-session-persistence-jsonl`):每个会话一个仅追加的逻辑 JSONL 日志:先是一行 `SessionHeader`,随后是无损表示连续 `SessionEvent` 流的存储记录。符合条件的 `assistant/chunk` 增量连续段默认使用打包行;[带校验和的 Zstandard 帧](2026-07-19-zstandard-jsonl-session-logs.md)是默认物理编码,也可通过配置使用原始行。
+2. **实现**(`dsh-session-persistence-jsonl`):每个会话一个仅追加的逻辑 JSONL 日志:先是一行 `SessionHeader`,随后是无损表示连续 `SessionEvent` 流的存储记录。符合条件的 `assistant/chunk` 增量连续段默认使用打包行;[带校验和的 Zstandard 帧](2026-07-19-zstandard-jsonl-session-logs.zh.md)是默认物理编码,也可通过配置使用原始行。
长期有效、存在争议的关键选择:
- **规范的持久日志无损保留每个 `SessionEvent`,包括 `assistant/chunk`。** JSONL 存储可以将一段连续的增量事件编码为一条打包行,但逻辑读取方会重建精确的事件边界、序号与时间戳。`deriveMessages()` 跳过分片,而过滤分片的方案(Codex 的 `policy.rs`)很有吸引力,但 `seq = log.length` 以及 `events[i].seq === i` 验证要求*连续*的逻辑日志;过滤掉分片会留下空洞,同时破坏约定和恢复功能。基于分片过滤的投影可以作为派生视图在后续实现(带有自己的重新编号),但它不是规范日志。
-- **仅追加;崩溃的轮次被关闭,而非截断。** 已刷写的事件永不被重写。[语义检查点策略](../bug-fix/2026-07-21-semantic-session-checkpoints.md)会在调用模型前排空请求、在调用工具前排空已记录的顶层调用,并在步骤结束后排空完整的响应/结果批次;循环则排空最终轮次边界。由于一个被中断的轮次可能包含大量有效工作,冷检查会保留其连续、可解析的事件,并在内存逻辑视图中为未应答的 assistant 调用添加按风险分类的错误结果、补一个缺失的 `step/end`,以及带 `{ kind: 'interrupted' }` 的 `turn/end`。`prepare` 或 `load` 在返回可恢复视图前提交这些收尾事件;合成结果保证恢复后的提供方 transcript(文本记录)仍然有效。只有不完整的最后一条记录会在提交修复时被丢弃;在最后一个真实 `turn/end` 处或之前出现解析错误或序号间隙,属于数据损坏,会使该会话不可加载。
+- **仅追加;崩溃的轮次被关闭,而非截断。** 已刷写的事件永不被重写。[语义检查点策略](../bug-fix/2026-07-21-semantic-session-checkpoints.zh.md)会在调用模型前排空请求、在调用工具前排空已记录的顶层调用,并在步骤结束后排空完整的响应/结果批次;循环则排空最终轮次边界。由于一个被中断的轮次可能包含大量有效工作,冷检查会保留其连续、可解析的事件,并在内存逻辑视图中为未应答的 assistant 调用添加按风险分类的错误结果、补一个缺失的 `step/end`,以及带 `{ kind: 'interrupted' }` 的 `turn/end`。`prepare` 或 `load` 在返回可恢复视图前提交这些收尾事件;合成结果保证恢复后的提供方 transcript(文本记录)仍然有效。只有不完整的最后一条记录会在提交修复时被丢弃;在最后一个真实 `turn/end` 处或之前出现解析错误或序号间隙,属于数据损坏,会使该会话不可加载。
- **文件后端为规范实现,数据库后端为经过验证的直接替换。** `SessionEvent` 1:1 映射到一行 `(session_id, seq, type, time, data)`:`append` 是 INSERT(在一个断言连续 seq 约定的事务中),读取使用 SELECT … ORDER BY seq。`dsh-session-persistence-sqlite` 正是如此:一个 `SessionPersistence` 子类,接口无变化(opencode 在 SQLite/WAL 上采用的正是这种接口形态),且通过与 JSONL 后端相同的 `runPersistenceContract` 测试套件。该约定以相同的语义约束两个后端(惰性物化、逻辑关闭中断轮次、修复只提交一次、连续 seq),一次表达在文件字节上,一次表达在数据库行上。其数据库拥有专用的 application id 与单调递增的 schema 版本。系统会在一个事务中为全新文件创建所有表并写入这两个 header 值;未版本化文件若带有任何用户定义的 schema 对象或应用标识、当前版本文件若带有外部应用标识,以及任何非当前版本文件,都会在修改日志模式之前被拒绝。
-- **元数据在日志之外。** 格式版本、cwd 和谱系是存储关注点,不是可回放的对话状态,因此它们存放在 `dsh-session` 拥有的 `SessionHeader` 中,并通过新的只读属性 `session.header` 附加到 `Session` 上——永远不进入 `SessionEventMap`,永远不到达 `deriveMessages()`。`createdAt` 是以 Unix epoch 毫秒表示的非负安全整数:运行时创建和持久化注册会拒绝小数值,JSONL 会验证解码后的 header,SQLite 则将其存入严格的 `INTEGER` 列。替代方案(一个可合并扩展的 `session/meta` 事件作为日志第 0 行)被否决:日志内事件会自然随 seed/fork 的会话携带,但元数据不是可回放状态,因此显式的日志外 header 边界是更清晰的取舍。(header 最初被拆分为不可变的 `SessionHeader` 加可变的 `SessionSummary`,二者的联合类型为 `SessionMeta`;可变 summary 后来因属于死状态而被移除——见 [移除可变会话摘要](../simplification/2026-06-19-drop-mutable-session-summary.md)。)
-- **`ctx.agents.create()` 和 `ctx.agents.resume()` 是异步工厂;恢复还跨越持久化边界。** `ctx.agents.resume({ resumeSessionId })` 通过 `ctx.sessionPersistence.prepare()` 取得精确的未发布 Session,以持久化 id 发布它,并继续其投影。[Session 准备阶段决策](2026-08-05-session-preparation.md)定义历史检查与恢复之间的复用。agent loop(智能体循环)不会硬注入 `sessionPersistence`(那样会让非持久化的演示永远挂起);当它不存在时,`resume` 会以明确的错误拒绝。
+- **元数据在日志之外。** 格式版本、cwd 和谱系是存储关注点,不是可回放的对话状态,因此它们存放在 `dsh-session` 拥有的 `SessionHeader` 中,并通过新的只读属性 `session.header` 附加到 `Session` 上——永远不进入 `SessionEventMap`,永远不到达 `deriveMessages()`。`createdAt` 是以 Unix epoch 毫秒表示的非负安全整数:运行时创建和持久化注册会拒绝小数值,JSONL 会验证解码后的 header,SQLite 则将其存入严格的 `INTEGER` 列。替代方案(一个可合并扩展的 `session/meta` 事件作为日志第 0 行)被否决:日志内事件会自然随 seed/fork 的会话携带,但元数据不是可回放状态,因此显式的日志外 header 边界是更清晰的取舍。(header 最初被拆分为不可变的 `SessionHeader` 加可变的 `SessionSummary`,二者的联合类型为 `SessionMeta`;可变 summary 后来因属于死状态而被移除——见 [移除可变会话摘要](../simplification/2026-06-19-drop-mutable-session-summary.zh.md)。)
+- **`ctx.agents.create()` 和 `ctx.agents.resume()` 是异步工厂;恢复还跨越持久化边界。** `ctx.agents.resume({ resumeSessionId })` 通过 `ctx.sessionPersistence.prepare()` 取得精确的未发布 Session,以持久化 id 发布它,并继续其投影。[Session 准备阶段决策](2026-08-05-session-preparation.zh.md)定义历史检查与恢复之间的复用。agent loop(智能体循环)不会硬注入 `sessionPersistence`(那样会让非持久化的演示永远挂起);当它不存在时,`resume` 会以明确的错误拒绝。
## 曾考虑的替代方案
上述每个关键选择都在陈述处记录了被否决的替代方案:**过滤分片的规范日志**(Codex 的 `policy.rs` 形式)破坏连续 seq 约定;**截断崩溃的轮次**会静默销毁长时间自主运行中的真实工作;**日志内 `session/meta` 事件作为第 0 行**——元数据不是可回放状态;**有限的非整数 `createdAt` 值**没有生产方,且与整数 Unix 毫秒存储及查询列不一致;**接受非全新的未版本化 SQLite 文件**可能覆盖无关对象或应用标识;**将 `sessionPersistence` 硬注入循环**会让非持久化的演示永远挂起。
-格式版本控制:header 携带一个 `version`;冷读取拒绝任何非当前版本。预发布阶段的会话格式仍固定为 `SESSION_FORMAT_VERSION = 0`,不承诺广泛兼容;当持久化用户数据确有需要时,协调器可以负责显式且范围受限的导入升级([消息标识机制引入前的消息恢复](../bug-fix/2026-07-28-load-pre-identity-session-messages.md))。仅追加 + 刷写对尾部的不完整写入具有健壮性(冷准备时可容忍),但无法抵御未使用 fsync 时在行写入中途断电;数据库/WAL 后端是该场景下更强的选项。
+格式版本控制:header 携带一个 `version`;冷读取拒绝任何非当前版本。预发布阶段的会话格式仍固定为 `SESSION_FORMAT_VERSION = 0`,不承诺广泛兼容;当持久化用户数据确有需要时,协调器可以负责显式且范围受限的导入升级([消息标识机制引入前的消息恢复](../bug-fix/2026-07-28-load-pre-identity-session-messages.zh.md))。仅追加 + 刷写对尾部的不完整写入具有健壮性(冷准备时可容忍),但无法抵御未使用 fsync 时在行写入中途断电;数据库/WAL 后端是该场景下更强的选项。
## 后果
diff --git a/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.i18n.yaml
index 9898684b9e..c3984a9fa9 100644
--- a/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md
2026-06-17-filesystem-capability-seam.md: 6265aebf5e7ffdd4ec4dc0083aa55adb34ee78a1
-2026-06-17-filesystem-capability-seam.zh.md: 024421ed59fa1e6c29bcc12ab51f69ac19a0c231
+2026-06-17-filesystem-capability-seam.zh.md: f0f1cb132fe06ebf669f0310f0066f4fdd36ad0b
diff --git a/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.zh.md
index 024421ed59..f0f1cb132f 100644
--- a/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.zh.md
+++ b/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.zh.md
@@ -20,7 +20,7 @@ harness 已有一个具体的 `bash` 能力 seam(`dsh-shell` / `dsh-bash-local
## 决策
-文件系统访问是一个一等的能力 seam,遵循[能力 seam Agent Note](2026-06-13-capability-seams.md):
+文件系统访问是一个一等的能力 seam,遵循[能力 seam Agent Note](2026-06-13-capability-seams.zh.md):
1. `@deepseek-ai/dsh-fs`(`packages/fs/fs`)拥有抽象的 `ctx.fs` 服务、文件系统词汇类型,以及 `fs/*` 策略事件词汇。
2. `@deepseek-ai/dsh-fs-local`(`packages/fs/fs-local`)提供第一个实现,以本地文件系统为后端。
@@ -28,7 +28,7 @@ harness 已有一个具体的 `bash` 能力 seam(`dsh-shell` / `dsh-bash-local
Consumer 包仅依赖 Service Definition 包,从不依赖 `dsh-fs-local`。需要不同后端的部署只需为 `ctx.fs` 加载不同的提供方,无需改动工具 schema 或面向模型的提示词引导。
-读后写/编辑与观测状态策略是第四个包 `@deepseek-ai/dsh-fs-observation-policy`(`packages/fs/fs-observation-policy`),通过 `fs/*` 事件门控贡献,而非挂在 `ctx.fs` 上;加载 `dsh-tool-fs` 的部署同时加载 `dsh-fs-observation-policy` 以获得读后写/编辑能力。本决策确立了由三个包构成的边界;策略从提供方基类拆出的决策由 [拆分文件系统 seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md) 做出,其以事件门控插件(而非方法服务)实现的方式由 [事件门控 Agent Note](2026-06-26-file-context-as-event-gate.md) 做出。
+读后写/编辑与观测状态策略是第四个包 `@deepseek-ai/dsh-fs-observation-policy`(`packages/fs/fs-observation-policy`),通过 `fs/*` 事件门控贡献,而非挂在 `ctx.fs` 上;加载 `dsh-tool-fs` 的部署同时加载 `dsh-fs-observation-policy` 以获得读后写/编辑能力。本决策确立了由三个包构成的边界;策略从提供方基类拆出的决策由 [拆分文件系统 seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.zh.md) 做出,其以事件门控插件(而非方法服务)实现的方式由 [事件门控 Agent Note](2026-06-26-file-context-as-event-gate.zh.md) 做出。
第一个后端有意仅限本地:`dsh-fs-local` 基于宿主文件系统实现 `ctx.fs`。未来的兄弟后端可在同一接口之后提供沙箱、远程、虚拟或项目作用域的文件系统。
@@ -36,7 +36,7 @@ Consumer 包仅依赖 Service Definition 包,从不依赖 `dsh-fs-local`。需
文件系统权限和沙箱并非此拆分所隐含。本地后端从其配置的基目录解析相对路径,但路径包含约束策略是独立的决策:要么由更严格的 `ctx.fs` 实现强制执行,要么由权限/沙箱插件包装 `tools/execute` 并在调用到达消费方之前否决。
-读后写/编辑与观测状态属于 `dsh-fs-observation-policy`,而非 `ctx.fs`。通过 `fs/*` 事件门控,策略按不透明 actor 记录版本,并提供可选的变更期望;提供方原子性地强制新鲜度。`dsh-tool-fs` 发出事件但不依赖策略。见[拆分文件系统 seam](../simplification/2026-06-26-fsspec-style-fs-seam.md)和[事件门控插件](2026-06-26-file-context-as-event-gate.md) Agent Note。
+读后写/编辑与观测状态属于 `dsh-fs-observation-policy`,而非 `ctx.fs`。通过 `fs/*` 事件门控,策略按不透明 actor 记录版本,并提供可选的变更期望;提供方原子性地强制新鲜度。`dsh-tool-fs` 发出事件但不依赖策略。见[拆分文件系统 seam](../simplification/2026-06-26-fsspec-style-fs-seam.zh.md)和[事件门控插件](2026-06-26-file-context-as-event-gate.zh.md) Agent Note。
## 包拓扑
@@ -84,7 +84,7 @@ Consumer 包仅依赖 Service Definition 包,从不依赖 `dsh-fs-local`。需
- 不透明的 `targetKey`,用于陈旧守护和文件状态查找。本地后端可能使用类似 realpath 的键;远程后端可能使用工作区 URI 或文件 id。消费方禁止解析或假设它是本地绝对路径。
- `displayPath`,用于面向模型/UI 的输出。根据后端不同,它可能是本地绝对路径、工作区相对路径或远程 URI。
-即使另一项能力共享提供方的执行环境,`targetKey` 仍保持不透明。这类消费方通过提供方的 `processPath(target)`、`fileUrl(target)` 或 `contains(parent, child)` 获取所需事实;[可移植执行环境决策](2026-07-28-portable-execution-world-consumers.md)说明这些事实为何属于文件系统 seam。
+即使另一项能力共享提供方的执行环境,`targetKey` 仍保持不透明。这类消费方通过提供方的 `processPath(target)`、`fileUrl(target)` 或 `contains(parent, child)` 获取所需事实;[可移植执行环境决策](2026-07-28-portable-execution-world-consumers.zh.md)说明这些事实为何属于文件系统 seam。
读取和变更结果必须包含不透明的文件 `version`。本地后端从 bigint stat 元数据(`dev`、`ino`、`size`、`mtimeNs` 和 `ctimeNs`)派生令牌,因此同大小重写和 inode 替换都会可靠地使消费方失效;远程后端可以使用 revision id 或类似 hash 的令牌。`dsh-fs-observation-policy` 插件记录版本用于陈旧检查;消费方可以展示相关元数据但禁止解释版本令牌。
@@ -140,7 +140,9 @@ Consumer 包仅依赖 Service Definition 包,从不依赖 `dsh-fs-local`。需
- **面向模型的工具直接基于 `node:fs`**:工具包将同时承担执行策略、路径解析、原子写入、文本解码和编辑语义,耦合问题部分所列的三个独立变化的关注点,且任何后端替换都会搅动 schema。
- **单一合并包 `dsh-fs-tools`**:seam 之前的形态;以与 bash 相同的 Service Definition / Service Provider / Consumer 拆分理由否决,且合并名称从未成为公开 API。
-- **观测状态放在 `ctx.fs` 上**:本 Agent Note 最初落地的形态;被 [拆分文件系统 seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md) 和 [事件门控 Agent Note](2026-06-26-file-context-as-event-gate.md) 取代:沙箱/远程后端不应继承面向模型的观测策略,因此提供方只保留版本令牌和可选的版本守护变更。
+- **观测状态放在 `ctx.fs` 上**:本 Agent Note 最初落地的形态;被 [拆分文件系统 seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.zh.md) 和 [事件门控 Agent Note](2026-06-26-file-context-as-event-gate.zh.md) 取代:沙箱/远程后端不应继承面向模型的观测策略,因此提供方只保留版本令牌和可选的版本守护变更。
+
+
## 后果
diff --git a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-contracts.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-contracts.i18n.yaml
index 0ecda2484f..03a0440ca1 100644
--- a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-contracts.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-contracts.i18n.yaml
@@ -3,4 +3,4 @@
# 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-agent-lifecycle-and-ownership-contracts.md
2026-06-18-agent-lifecycle-and-ownership-contracts.md: 9bc558bfce75892b0ebdb80e9f8735d440cabaf4
-2026-06-18-agent-lifecycle-and-ownership-contracts.zh.md: 65e0018c3f2ba56043898050c8e0b49929f8e7e7
+2026-06-18-agent-lifecycle-and-ownership-contracts.zh.md: bbd0a7219f902e5ead4d190c58e9a5d4da2131fd
diff --git a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-contracts.zh.md b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-contracts.zh.md
index 65e0018c3f..bbd0a7219f 100644
--- a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-contracts.zh.md
+++ b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-contracts.zh.md
@@ -14,7 +14,7 @@ ACP(Agent Client Protocol)与 tool-bash 的若干限制是同一个所有权
### 1. 队列感知的 `Agent.cancel(cause?)`
-`Agent` 接口新增 `cancel()` 动词——唯一的公开停止原语。(它最初与范围更窄、仅作用于步骤的 `abort()` 一同交付;后者后来因无人使用而移除,使 `cancel()` 成为唯一公开的停止工作方式。)它清空 inbox 的 queued + steering FIFO,在存在活跃轮次时中止它,并保留一个不带 cause 的 pre-run 标记,使在被领取前被取消的提示词永不运行,而后来的提示词仍保持独立。有效调用会在清空或中止前发出 `agent/cancel-requested`,携带类型化的 `user | parent` cause;空闲取消不发出任何事件,也不会使下一条提示词搁浅。`whenIdle()` 会在取消后达到完全停稳,ACP 的 `session/cancel` 映射到 `user`。[显式轮次取消决策](2026-07-16-explicit-turn-cancellation.md)规定了当前的 cause、signal 生命周期与协作式结算约定。
+`Agent` 接口新增 `cancel()` 动词——唯一的公开停止原语。(它最初与范围更窄、仅作用于步骤的 `abort()` 一同交付;后者后来因无人使用而移除,使 `cancel()` 成为唯一公开的停止工作方式。)它清空 inbox 的 queued + steering FIFO,在存在活跃轮次时中止它,并保留一个不带 cause 的 pre-run 标记,使在被领取前被取消的提示词永不运行,而后来的提示词仍保持独立。有效调用会在清空或中止前发出 `agent/cancel-requested`,携带类型化的 `user | parent` cause;空闲取消不发出任何事件,也不会使下一条提示词搁浅。`whenIdle()` 会在取消后达到完全停稳,ACP 的 `session/cancel` 映射到 `user`。[显式轮次取消决策](2026-07-16-explicit-turn-cancellation.zh.md)规定了当前的 cause、signal 生命周期与协作式结算约定。
### 2. `AgentHandle` 异步释放器
@@ -43,7 +43,7 @@ bash 所有者 token 比较依赖共享的 `Agent.id`/`SessionId` 在存活 agen
- **公开的 `BashTask.owner` 字段**而非 `ShellExecutor.ownerOf(id)` Service Definition 方法:否决。一条读取路径即可,无需冗余 API。
- **为 agent 的会话生命周期使用兄弟 Cordis effect**:否决。fiber 卸载时并发释放兄弟 effect(`Promise.all`),store 拥有的 append 发布钩子的移除与循环的关闭 `session/flush` 产生竞争;单一复合 effect 的有序 LIFO 链才能在两条释放路径上都捕获关闭的 `turn/end`。
-- **在 `cancel()` 之外另设一个仅中止步骤的 `abort()`**:最初发布过,后因无人使用而移除;`cancel()` 是唯一的公开停止原语(见[公开停止接口 Agent Note](../simplification/2026-06-20-public-agent-stop-api.md))。
+- **在 `cancel()` 之外另设一个仅中止步骤的 `abort()`**:最初发布过,后因无人使用而移除;`cancel()` 是唯一的公开停止原语(见[公开停止接口 Agent Note](../simplification/2026-06-20-public-agent-stop-api.zh.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 38a665a0fd..8097b8cbb0 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: 93b6cd1bd058499e71948d3909de8e4c076b445e
-2026-06-18-shared-persistence-write-coordinator.zh.md: 9e1bc736d4dba5e59b763710976425fa0ae76c30
+2026-06-18-shared-persistence-write-coordinator.md: 286bbb7d5cd3720109db0d0abc0bb72ddbfcbdcd
+2026-06-18-shared-persistence-write-coordinator.zh.md: 70db616b0a71826c648072228fff936ad423ad8f
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 93b6cd1bd0..286bbb7d5c 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
@@ -16,6 +16,10 @@ Composition, not inheritance. The coordinator is a concrete class the backend ho
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.
+Creation borrows the exact `Session.events` snapshot as its persistence seed. `Session` has already detached, validated, and deeply frozen every event, and the snapshot array remains stable when later appends replace the cached view. The coordinator and its backend hooks only read this typed in-process value, so cloning the complete log again would duplicate the ownership work described by the [agent-scope runtime decision](2026-07-12-agent-scope-runtime-design.md#session-append-materialize-validate-commit-notify). Public persistence `append()` still snapshots caller-owned input at its API boundary.
+
+Prepared-session suffixes and events admitted to the write-behind queue retain their existing copies. Those paths establish asynchronous queue ownership one suffix or event at a time and have no measured whole-log clone cost; removing their copies remains a separate ownership audit rather than part of creation-seed borrowing.
+
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.
### The hook interface (`PersistenceBackend`)
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 9e1bc736d4..70db616b0a 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,11 @@ Status: implemented
组合,而非继承。协调器是后端持有的具体类,不是后端继承的基类。协调器让非常规后端与继承层级作斗争的风险由此规避:后端只暴露钩子,无法触及协调器的私有编排状态。第三方后端仍然可以完全不使用协调器、直接实现抽象服务,包括不可变逻辑检查,以及通过 `load` 实现的默认准备回退。
-协调器为每个存活的 `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` 实例持有一个生命周期条目:初始化,加上一个包私有写入控制器,后者负责待处理事件、固定批处理截止时间、活跃写入、失败保留和共享 flush 屏障。每个 `session/event` 都进入这条有界写入路径,`session/flush` 则绕过等待以观察完全停稳。控制器归并由 [flush 控制器简化](../simplification/2026-07-23-collapse-persistence-flush-state.zh.md)定义;调度节奏由[有界批处理决策](2026-08-08-bounded-session-persistence-write-batching.zh.md)定义。
+
+创建流程将 `Session.events` 的原始快照借作持久化种子。`Session` 已经分离、验证并深度冻结每个事件,后续追加会替换缓存视图,因此该快照数组保持稳定。协调器及其后端钩子只读取这个有类型的进程内值;再次克隆完整日志会重复 [agent scope 运行时决策](2026-07-12-agent-scope-runtime-design.zh.md#session-append-materialize-validate-commit-notify)规定的所有权工作。持久化服务的公开 `append()` 仍在 API 边界为调用方拥有的输入创建快照。
+
+已准备 Session 的后缀,以及进入 write-behind 队列的事件,仍保留现有复制。这些路径会逐个后缀或事件建立异步队列所有权,且没有已测得的完整日志克隆成本;移除这些复制属于单独的所有权审计,不属于创建种子的借用决策。
协调器通过 `session/disposed` 退役会话:它等待控制器完成初始化和当前 flush,串行执行最后一次排空,且仅在成功后才移除控制器与其拥有的每 id 状态。失败时保持控制器可被找到,以供后端 teardown(拆除)重试。每个 id 的已结算链尾仅在其仍是当前链尾时才移除自身,因此旧操作完成后不会抹除同一 id 的新操作。后端 teardown 会注销写入路径监听器、flush 每个剩余的控制器、等待所有按 id 串行化的操作,最后关闭后端。
@@ -44,4 +48,4 @@ Status: implemented
## 后果
-协调器增加了一层间接、一个不透明的 torn marker、脱离会话生命周期的退役任务,以及有界的已准备 Session 状态,但将此前每个后端重复的、对正确性要求很高的编排逻辑集中到一处。会话 dispose 仍是仅观察事件,因此会话所有者不会等待持久化退役;协调器会收容失败、在存活控制器中保留待处理事件,并以后端 teardown 为完全停稳边界。其钩子面保持窄小:标识校验、接管、碰撞检查、准备与不可变检查共用 `loadStored`;物化保持在 `appendBatch` 内原子完成;列举绕过协调器。读模型使用 `inspect` 而非 `load`,因此观察已持久化但仍开放的轮次时不会提交中断收尾事件;复用、预留与发布由 [Session 准备阶段决策](2026-08-05-session-preparation.md)定义。新后端只需实现存储原语,而无需复制有界写入生命周期。
+协调器增加了一层间接、一个不透明的 torn marker、脱离会话生命周期的退役任务,以及有界的已准备 Session 状态,但将此前每个后端重复的、对正确性要求很高的编排逻辑集中到一处。会话 dispose 仍是仅观察事件,因此会话所有者不会等待持久化退役;协调器会收容失败、在存活控制器中保留待处理事件,并以后端 teardown 为完全停稳边界。其钩子面保持窄小:标识校验、接管、碰撞检查、准备与不可变检查共用 `loadStored`;物化保持在 `appendBatch` 内原子完成;列举绕过协调器。读模型使用 `inspect` 而非 `load`,因此观察已持久化但仍开放的轮次时不会提交中断收尾事件;复用、预留与发布由 [Session 准备阶段决策](2026-08-05-session-preparation.zh.md)定义。新后端只需实现存储原语,而无需复制有界写入生命周期。
diff --git a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.i18n.yaml
index b7e2678928..ed11425f4b 100644
--- a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-20-branded-ids.md
2026-06-20-branded-ids.md: 29b258b21240c92e74051339f0939a8e70933099
-2026-06-20-branded-ids.zh.md: 288125764d4b279c79b7469080bb0c9f2efdc709
+2026-06-20-branded-ids.zh.md: 2f960bac3172f1e83161f1af8f4e9d2cc0cda7bd
diff --git a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.zh.md b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.zh.md
index 288125764d..2f960bac31 100644
--- a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.zh.md
+++ b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.zh.md
@@ -6,11 +6,11 @@ Status: implemented
## 问题
-harness 使用 `Branded = string & { readonly [BRAND]: B }` 机制,为 `CallId`(`packages/llm/llm/src/brand.ts`)和 agent(智能体)/会话共享的 `SessionId`(`packages/core/session/src/types.ts`)做 brand 处理;该机制由纯类型包 `@deepseek-ai/dsh-brand` 拥有,位于 `packages/util/brand/`,见其 [README](../../../../packages/util/brand/README.md),并为每个类型提供零开销的 cast 工厂。`dsh-brand` 还声明了治理策略:*「Branding 用于跨包边界且可能被混淆的 id;不是每个 string 都需要 brand。」* 这条策略是正确的;问题在于它只落实了一半。两处缺口使得结构相同但语义错误的 string 今天仍能通过类型检查器。
+harness 使用 `Branded = string & { readonly [BRAND]: B }` 机制,为 `CallId`(`packages/llm/llm/src/brand.ts`)和 agent(智能体)/会话共享的 `SessionId`(`packages/core/session/src/types.ts`)做 brand 处理;该机制由纯类型包 `@deepseek-ai/dsh-brand` 拥有,位于 `packages/util/brand/`,见其 [README](../../../../packages/util/brand/README.zh.md),并为每个类型提供零开销的 cast 工厂。`dsh-brand` 还声明了治理策略:*「Branding 用于跨包边界且可能被混淆的 id;不是每个 string 都需要 brand。」* 这条策略是正确的;问题在于它只落实了一半。两处缺口使得结构相同但语义错误的 string 今天仍能通过类型检查器。
**缺口 1:bash seam 中未 brand 的跨边界 ID。** 后台 job id 是普通 `string`:`BashTask.id: string`(`packages/shell/shell/src/types.ts`),作为 `string` 贯穿整个执行器 seam(`packages/shell/shell/src/index.ts` 中的 `ShellExecutor.get`/`ownerOf`/`readOutput`/`kill(id: string)`),再由面向模型的工具以 `string` 校验并传递(`validateJobId`、`assertTaskAccess`、`packages/shell/tool-bash/src/index.ts` 中 `job_id` 的 schema 参数)。它由每执行器计数器生成——`packages/shell/bash-local/src/index.ts` 中的 `` `bash-${this.nextTaskId++}` ``——其形状与 `SessionId` 的默认值**完全相同,都是 `name-N`**(`packages/core/session/src/index.ts` 中的 `` `session-${++counter}` ``)。bash job id 和会话 id 在调用点轻易就能互换,而编译器毫无反应。它是面向模型的 id(模型会把 `job_id` 传回 `bash_output`/`bash_kill`),所以该混淆可由不受信任的输入触达。
-bash **owner token** 是相关的子情形:`ShellExecRequest.owner?: string` 和 `ShellExecSpec.owner: string | undefined`(`packages/shell/shell/src/types.ts`)被文档描述为刻意*不透明*的隔离键,但在所有实际调用方中,该值就是所属 agent 共享的 `Agent.id`/`SessionId`(`callerToken = (exec) => exec.agent?.id`,位于 `packages/shell/tool-bash/src/index.ts`),只是披着另一个 seam 本地名称。它被用于访问控制比较(`owner !== callerToken(exec)`),因此一个不匹配但类型正确的 string 在此处就是跨会话隔离 bug,而当前类型系统无法捕获。这正是[统一 agent/session 标识决策](../simplification/2026-06-20-unify-agent-and-session-id.md)覆盖的共享 id 别名。
+bash **owner token** 是相关的子情形:`ShellExecRequest.owner?: string` 和 `ShellExecSpec.owner: string | undefined`(`packages/shell/shell/src/types.ts`)被文档描述为刻意*不透明*的隔离键,但在所有实际调用方中,该值就是所属 agent 共享的 `Agent.id`/`SessionId`(`callerToken = (exec) => exec.agent?.id`,位于 `packages/shell/tool-bash/src/index.ts`),只是披着另一个 seam 本地名称。它被用于访问控制比较(`owner !== callerToken(exec)`),因此一个不匹配但类型正确的 string 在此处就是跨会话隔离 bug,而当前类型系统无法捕获。这正是[统一 agent/session 标识决策](../simplification/2026-06-20-unify-agent-and-session-id.zh.md)覆盖的共享 id 别名。
**缺口 2:*已经 brand* 的 ID 在边界处被侵蚀。** 就连 `CallId` 和 `SessionId` 也恰好在最容易混淆的地方退化为裸 `string`:注册表/store 键类型和公开方法参数。代表性位置包括会话存储、agent 注册表(二者都以共享的 `SessionId` 为键)、工具展示层的 call-id map、ACP(Agent Client Protocol)的会话记录,以及持久化协调器。在集合键处丢弃 brand,会让既有 brand 在查找时毫无价值;它们的价值只实现了一部分。
@@ -64,6 +64,6 @@ export function OwnerToken(id: string): OwnerToken {
## 后果
-- **两个接口面的机械性改动。** 传播 brand 涉及 bash seam(Service Definition + Service Provider + Consumer)以及 ACP 会话 id 接口和持久化协调器。改动面广但严重度低:遗漏的位置是编译错误而非静默 bug。从可观察行为看,这是一项纯类型变更——无快照或 e2e 行为差异。它与[统一 agent/会话标识决策](../simplification/2026-06-20-unify-agent-and-session-id.md)相邻,因为二者都触及会话 id / owner-token 边界;`OwnerToken` 出于上述解耦理由仍与统一后的 id 保持独立。
+- **两个接口面的机械性改动。** 传播 brand 涉及 bash seam(Service Definition + Service Provider + Consumer)以及 ACP 会话 id 接口和持久化协调器。改动面广但严重度低:遗漏的位置是编译错误而非静默 bug。从可观察行为看,这是一项纯类型变更——无快照或 e2e 行为差异。它与[统一 agent/会话标识决策](../simplification/2026-06-20-unify-agent-and-session-id.zh.md)相邻,因为二者都触及会话 id / owner-token 边界;`OwnerToken` 出于上述解耦理由仍与统一后的 id 保持独立。
- **Brand 不做校验。** Brand 是混淆防护,不是正确性证明:一个*错误的*会话 id 只要仍是格式正确的 string,就和以前一样能通过类型检查器。本决策不关闭这个缺口(见「不在范围内」)——它只阻止这类*类别*错误:传入错误*种类*的 id。
- **「在哪里停下」仍是判断题。** 为 `BashTaskId` 加 brand 但不为 `ToolName` 加,为 `OwnerToken` 加但不为 `ModelId` 加,是对哪些 string「可能被混淆」的品味判断。合理的评审者可能想要更多或更少;`brand.ts` 中的策略是裁决依据,本决策倾向于面向模型或用于访问控制的 id。
diff --git a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml
index 477c6df3ae..7d4ccbd4e1 100644
--- a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md
2026-06-20-generic-long-running-tool-runtime.md: 7db43323dd83f8e99698a7163412c9b33a607dfb
-2026-06-20-generic-long-running-tool-runtime.zh.md: efd306cc8d3b82749635dde302af45235c27b431
+2026-06-20-generic-long-running-tool-runtime.zh.md: 040ea6a01e97c9dc1d93e8f3a0fd9c1f4b0f31fc
diff --git a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md
index efd306cc8d..040ea6a01e 100644
--- a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md
+++ b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md
@@ -19,13 +19,13 @@ Status: implemented
长时间运行工具是生产方。`dsh-tool-bash` 将 `ShellProcess` 适配为增量输出与进程取消;`dsh-tool-subagent` 将子运行适配为最终输出与子运行释放。bash 与 subagent 能力 seam 保持独立,不依赖会话或任务注册表。
-`JobRegistry` 是 `@deepseek-ai/dsh-jobs` 中的 Service Definition;进程内 Service Provider 是 `@deepseek-ai/dsh-jobs-local` 中的 `LocalJobRegistry`(该拆分记录在[任务注册表约定 Agent Note](2026-07-26-job-registry-seam.md)中)。
+`JobRegistry` 是 `@deepseek-ai/dsh-jobs` 中的 Service Definition;进程内 Service Provider 是 `@deepseek-ai/dsh-jobs-local` 中的 `LocalJobRegistry`(该拆分记录在[任务注册表约定 Agent Note](2026-07-26-job-registry-seam.zh.md)中)。
## 运行时约定
-字面类型见[任务子系统页面](../../../../docs/subsystems/jobs.md)。生产方调用 `ctx.jobs.start()`,传入 kind、label、可选的所属 `Agent`、可选的正数 `outputLimitBytes` 与一个 `run()` 函数。运行时会在调用 `run()` 前完成所有可能失败的预检工作,并且只调用一次。`run()` 返回钩子后,注册过程不会再执行可能失败的步骤而直接提交;生产方无法启动没有可收集 job id 的工作。
+字面类型见[任务子系统页面](../../../../docs/subsystems/jobs.zh.md)。生产方调用 `ctx.jobs.start()`,传入 kind、label、可选的所属 `Agent`、可选的正数 `outputLimitBytes` 与一个 `run()` 函数。运行时会在调用 `run()` 前完成所有可能失败的预检工作,并且只调用一次。`run()` 返回钩子后,注册过程不会再执行可能失败的步骤而直接提交;生产方无法启动没有可收集 job id 的工作。
-进程内 Service Provider 还拥有有界准入,其理由记录在[有界后台任务准入决策](../bug-fix/2026-08-11-bounded-background-job-admission.md)中。它的 `maxConcurrentJobsPerOwner` 配置必须是正的安全整数,默认值为 `10`;`start()` 从 `running` 与 `stopping` 记录派生每个确切 `Agent` 对象的活动数量,而全部无 owner 任务共享一个服务级桶。容量拒绝发生在 `run()` 与 id 分配之前,处于 stopping 的任务只有在生产方 `done` 结算时才释放名额。Service Provider 不排队或抢占任务,也不保留第二份可变计数。
+进程内 Service Provider 还拥有有界准入,其理由记录在[有界后台任务准入决策](../bug-fix/2026-08-11-bounded-background-job-admission.zh.md)中。它的 `maxConcurrentJobsPerOwner` 配置必须是正的安全整数,默认值为 `10`;`start()` 从 `running` 与 `stopping` 记录派生每个确切 `Agent` 对象的活动数量,而全部无 owner 任务共享一个服务级桶。容量拒绝发生在 `run()` 与 id 分配之前,处于 stopping 的任务只有在生产方 `done` 结算时才释放名额。Service Provider 不排队或抢占任务,也不保留第二份可变计数。
`outputLimitBytes` 是生产方拥有的呈现策略,而非注册表缓冲区。注册表校验该值,并将其原样投影到 `JobSnapshot`;通用任务控制器添加自身的状态或通知元数据后,再将该上限应用于完整的面向模型输出。省略该值时保持现有控制器行为,因此运行时不会向无关的生产方类别施加隐式默认值。
@@ -79,7 +79,7 @@ job id 在运行时全局可见且可预测,因此注册表会授权每次访
流式读取共享一个任务作用域内的消费游标,因为所属模型是预期读取方。UI 或多个独立读取方需要单独的非消费式观察 API;共享该游标会让读取方彼此消费对方的输出。
-系统提示词要求模型保留 job id、在后台工作运行时继续处理独立工作而非忙轮询或重复启动同一任务、在给出最终答案前收集相关任务,并终止不再重要的工作。完成时,系统会向确切所有者的会话交付一条已记录的消息:繁忙的所有者走注入,空闲的所有者会被唤醒,其有界策略由[空闲所有者唤醒决策](../feature/2026-08-11-background-job-completion-wakes-an-idle-owner.md)负责。
+系统提示词要求模型保留 job id、在后台工作运行时继续处理独立工作而非忙轮询或重复启动同一任务、在给出最终答案前收集相关任务,并终止不再重要的工作。完成时,系统会向确切所有者的会话交付一条已记录的消息:繁忙的所有者走注入,空闲的所有者会被唤醒,其有界策略由[空闲所有者唤醒决策](../feature/2026-08-11-background-job-completion-wakes-an-idle-owner.zh.md)负责。
当读取或等待交付终止任务、尚在等待的等待方在结算时认领了投递,或模型显式终止任务时,运行时将终止任务标为 `reported`。已报告的任务不会注入冗余的完成通知。监听器失败会独立记录,不会阻止后续监听器,也不会被等待方或资源销毁过程等待。当快照携带 `outputLimitBytes` 时,`dsh-tool-jobs` 会保持 UTF-8 边界,并复用生产方已有的截断标记,而不会重复添加。读取会为状态后缀预留空间并保留输出尾部;完成通知会先为稳定的 `background job ` 前缀与 `job_output` 指令预留空间,再截断可变的 kind、label、status、detail,乃至截断标记本身,因此 PTY 的最小上限仍能标识需要收集的任务。任务控制器在策略有机会拒绝或短路分发之前,于最先执行的 pre-execute 监听器中解析调用方可见的生产方上限;随后通过任务定义最后一道的 `finalizeContent` 回调应用该上限,使规范化的工具错误、外层流水线失败与单文本策略结果都无法绕过该边界;经特意结构化的多块策略结果仍由策略拥有其形状与大小。
@@ -105,7 +105,7 @@ bash seam 暴露 `resolve`、`run` 和 `start`。`start(spec)` 返回一个 `She
### 立即抽象任务运行时后端
-当前 `JobStart.run()` 约定传入进程内回调与确切的 `Agent` 对象。持久化后端会改变身份、重启、所有权与观察语义,因此在引入之时注册表保持为单一具体服务,而非固化错误的边界。[任务注册表约定 Agent Note](2026-07-26-job-registry-seam.md)后来在不改变这些进程内语义的前提下,将约定与进程内实现分离。
+当前 `JobStart.run()` 约定传入进程内回调与确切的 `Agent` 对象。持久化后端会改变身份、重启、所有权与观察语义,因此在引入之时注册表保持为单一具体服务,而非固化错误的边界。[任务注册表约定 Agent Note](2026-07-26-job-registry-seam.zh.md)后来在不改变这些进程内语义的前提下,将约定与进程内实现分离。
### 由消费方负责授权或清理事件
@@ -131,7 +131,7 @@ bash seam 暴露 `resolve`、`run` 和 `start`。`start(spec)` 返回一个 `She
## 后果
-bash 命令与 subagent 共享一套 id 词汇、列表、通知格式、提示词习惯和控制工具。新的长时间运行生产方只需实现执行钩子,而不必再实现一套注册表与工具族。[工具实操手册](../../../../docs/cookbook/adding-a-tool.md)将生产方指向本约定。
+bash 命令与 subagent 共享一套 id 词汇、列表、通知格式、提示词习惯和控制工具。新的长时间运行生产方只需实现执行钩子,而不必再实现一套注册表与工具族。[工具实操手册](../../../../docs/cookbook/adding-a-tool.zh.md)将生产方指向本约定。
单个确切 owner 无法再无限增加进程内由 Task 承载的工作,另一个 owner 也不会消耗它的额度。取消请求会继续占用容量,直到生产方真正释放资源,因此用新工作替换缓慢停止的任务不会突破已配置的实时资源预算。
diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml
index f9b830c3b7..dcaeb8c34b 100644
--- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.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-bounded-llm-request-recovery.md
-2026-06-21-bounded-llm-request-recovery.md: 3a91d77fe9c08fbcc3afdce4dad9a38e288b4723
-2026-06-21-bounded-llm-request-recovery.zh.md: a6b342b19236251e638242c1e9df6b3d6ed557c0
+2026-06-21-bounded-llm-request-recovery.md: e725a025f2d8b0d5e8eaf4137f07d8eab4448bf4
+2026-06-21-bounded-llm-request-recovery.zh.md: 9e2263b05888797eaaaeb82859730d1c4a728cea
diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md
index 3a91d77fe9..e725a025f2 100644
--- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md
+++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md
@@ -4,13 +4,13 @@ Status: implemented
English | [中文](2026-06-21-bounded-llm-request-recovery.zh.md)
-The [per-provider request retry policy](../feature/2026-07-24-provider-retry-policies.md) extends this foundation with exact-provider configuration and an explicit unbounded mode. This note continues to own structured failure facts, the closed-step recovery boundary, normal mode's transient defaults, visible single attempts, and durable retry status. [Terminal LLM stream failures](2026-07-29-terminal-llm-stream-failures.md) supersedes its thrown-error identity and stream-sidecar mechanism.
+The [per-provider request retry policy](../feature/2026-07-24-provider-retry-policies.md) extends this foundation with exact-provider configuration and an explicit unbounded mode. This note continues to own structured failure facts, the failed-attempt recovery boundary, normal mode's transient defaults, visible single attempts, and durable retry status. [Terminal LLM stream failures](2026-07-29-terminal-llm-stream-failures.md) supersedes its thrown-error identity and stream-sidecar mechanism.
## Problem
Provider adapters can fail by throwing during dispatch or iteration or by ending with `finish { kind: 'error' | 'aborted' }`. The final adapter boundary normalizes thrown values to that terminal finish protocol before `dsh-agent-loop` receives them; middleware and result-processing defects remain thrown. The loop offers a terminal model-request failure to `agent/request-error`. An unhandled failure is terminal; a handling listener repairs policy-owned state, returns `{ kind: 'retry' }`, and stops waterfall delegation. The [retry-action decision](../simplification/2026-07-27-request-error-retry-action.md) owns this return contract.
-That boundary is already safe for another request attempt. Raw `assistant/chunk` events carry the failed `turn` and `step`, message derivation ignores them unless a successful `assistant/message` cites them, tool calls are dispatched only after a successful terminal finish and assembly, and a retry opens a new numbered turn from the durable log. The harness therefore does not need a second response lifecycle or tentative-output protocol to keep two attempts separate.
+That boundary is already safe for another request attempt. Raw `assistant/chunk` events carry the failed `turn` and `step`, message derivation ignores them unless a successful `assistant/message` cites them, tool calls are dispatched only after a successful terminal finish and assembly, and a retry reconstructs its next attempt from the durable log. The harness therefore does not need a second response lifecycle or tentative-output protocol to keep two attempts separate.
The prior boundary left three narrower gaps.
@@ -52,7 +52,7 @@ The shared transient-code set is intentionally small: adapter mappings for `RATE
`@deepseek-ai/dsh-llm-retry` is a function plugin that listens to `agent/request-error`. It introduces no service or new loop branch; the agent-loop package changes only the data carried through its existing failed-step recovery control flow.
-The `agent/request-error` waterfall carries the current `LlmFailure`, an immutable list of prior failures that authorized retry turns in the consecutive recovery sequence, and the serving registration's immutable retry policy. The loop transports but does not interpret that policy, owns the consecutive failure history, and clears it after a successful model request. Normal `dsh-llm-retry` policy counts durable retry records scheduled by the same exact-provider policy, while `dsh-compaction-basic` keeps its own context-overflow budget. Alternating transient and context-overflow failures therefore consume their owning finite budgets independently; the maximum request count is one plus the sum of the loaded finite budgets.
+The `agent/request-error` waterfall carries the current `LlmFailure`, an immutable list of prior failures that authorized retries in the consecutive recovery sequence, and the serving registration's immutable retry policy. The loop transports but does not interpret that policy, owns the consecutive failure history, and clears it after a successful model request. Normal `dsh-llm-retry` policy counts durable retry records scheduled by the same exact-provider policy, while `dsh-compaction-basic` keeps its own context-overflow budget. Alternating transient and context-overflow failures therefore consume their owning finite budgets independently; the maximum request count is one plus the sum of the loaded finite budgets.
The [provider-policy decision](../feature/2026-07-24-provider-retry-policies.md) owns the current configuration shape. Provider adapters register their nested `retryPolicy`; omission uses normal defaults: two transient retries, a 500 millisecond initial delay, a 10 second delay cap, 10 percent jitter, and the five transient codes above. The count and delay bounds match the conservative edge of the inspected implementations: [OpenCode uses two request retries with 500 ms/10 s bounds](https://github.com/anomalyco/opencode/blob/9976269ab1accfc9f9dc98a4a688c516934de422/%70ackages/llm/src/route/executor.ts#L36-L39), [Pi separates three agent-level retries from provider retries and defaults provider retries to zero](https://github.com/earendil-works/pi/blob/3da591ab74ab9ab407e72ed882600b2c851fae21/%70ackages/coding-agent/docs/settings.md#L139-L147), and [Codex uses finite request/stream budgets plus a five-minute idle timeout](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/model-provider-info/src/lib.rs#L25-L33). Ten percent follows [Codex's bounded jitter](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/codex-client/src/retry.rs#L40-L47).
@@ -68,7 +68,7 @@ The agent-spine demo bundle loads the plugin so the shared stdio/TUI, one-shot C
### Make one layer own visible attempts
-Adapters perform one provider request per `stream()` call. The pi-ai adapter removes public `maxRetries` and `maxRetryDelayMs` profile fields and disables library retries; the hand-written adapter keeps its current single-attempt behavior. This prevents an SDK budget from multiplying the agent budget and ensures every transient retry is represented by a closed failed step plus `llm/retry`.
+Adapters perform one provider request per `stream()` call. The pi-ai adapter removes public `maxRetries` and `maxRetryDelayMs` profile fields and disables library retries; the hand-written adapter keeps its current single-attempt behavior. This prevents an SDK budget from multiplying the agent budget and ensures every transient retry is represented by its recorded failed attempt plus `llm/retry`.
`ctx.llm.stream()` remains the raw one-attempt waterfall. Direct callers such as compaction summarization receive the structured failure but do not gain automatic retry, because they have no agent step boundary or general durable place to separate attempts. A future direct-call consumer may justify a buffering helper that retries only before emitting a chunk; this decision adds no such helper.
@@ -82,9 +82,9 @@ Boundary tests prove termination at both actual transports. The hand-written ada
### Keep attempts separate in the existing log
-A failed attempt may leave `assistant/chunk` events in its closed step, but it never appends `assistant/message` and never dispatches a tool. A retry closes the failed turn, opens the next numbered turn, reconstructs the request from the durable surface, and produces its own chunks. UIs may render live chunks while a step is open, then mark or clear that transient view when `llm/retry` identifies the failed step or `turn/end` records failure. Web validates the complete retry payload contract, clears the failed partial at `llm/retry`, projects consecutive retry-turn events into one stable row updated to the latest attempt, and derives scheduled, started, or cancelled status from subsequent turn facts. Its countdown anchors the scheduled delay to browser receipt rather than the Host event clock, uses ceiling-rounded seconds with a one-second floor, animates only while unresolved, and keeps exact latest failure details collapsed behind the row. Retry nodes anchor their own trajectory turn even when the failed attempt has no assistant node. Message derivation continues to ignore the failed chunks, and Web applies the same projection during history rebuild so refreshing cannot resurrect discarded partials or duplicate retry rows.
+A failed attempt may leave `assistant/chunk` events in its step, but it never appends `assistant/message` and never dispatches a tool. A retry continues inside the failing turn and step, reconstructs the request from the durable surface, and produces its own chunks; only the final outcome closes the turn. UIs may render live chunks while a step is open, then mark or clear that transient view when `llm/retry` identifies the failed attempt or `turn/end` records failure. Web validates the complete retry payload contract, clears the failed partial at `llm/retry`, projects each producer-correlated `retryId` chain into one stable row updated to the latest attempt, and derives scheduled, started, or cancelled status from `llm/retry-started` and the owning turn and step boundaries' closure. Its countdown anchors the scheduled delay to browser receipt rather than the Host event clock, uses ceiling-rounded seconds with a one-second floor, animates only while unresolved, and keeps exact latest failure details collapsed behind the row. Retry nodes anchor their own trajectory turn even when the failed attempt has no assistant node. Message derivation continues to ignore the failed chunks, and Web applies the same projection during history rebuild so refreshing cannot resurrect discarded partials or duplicate retry rows.
-If recovery is exhausted, the final failure is stored once on `turn/end.reason` with the structured facts. Web derives one `turn-error` node at that sequence position and renders its display-safe message and optional code inline; AUTH projections replace provider copy that may echo credential fragments with `API key is invalid`, while the raw diagnostic remains in the session log. The same fold runs for live events and history replay. If transient recovery continues, `llm/retry` is the durable home for that attempt's failure and delay, so its failed turn does not also gain a terminal error row. No standalone final-error event or response-id vocabulary is added.
+If recovery is exhausted, the final failure is stored once on `turn/end.reason` with the structured facts. Web derives one `turn-error` node at that sequence position and renders its display-safe message and optional code inline; AUTH projections replace provider copy that may echo credential fragments with `API key is invalid`, while the raw diagnostic remains in the session log. The same fold runs for live events and history replay. While transient recovery continues, `llm/retry` is the durable home for each intermediate failure and delay; the terminal row exists only once `turn/end` records the error, and because exhausted recovery shares the failing turn, the turn's retry history never suppresses that row — the settled retry chain and the terminal error render side by side. No standalone final-error event or response-id vocabulary is added.
## Out of scope
@@ -114,15 +114,15 @@ If recovery is exhausted, the final failure is stored once on `turn/end.reason`
- Each provider adapter validates its nested retry policy at Loader startup, and `ctx.llm` captures it with the route; normal mode delegates ineligible paths and makes at most `maxRetries + 1` provider requests when no other policy applies.
- HMR-during-backoff tests prove disposal unregisters the listener, aborts and awaits its captured callbacks, emits no retry decision after disposal, and leaves no timer or promise alive.
- Pure unit tests cover transient-code selection, exponential backoff and jitter bounds, valid and over-cap `Retry-After`, exhausted budgets, deterministic timer/random hooks, and abort during backoff.
-- Real agent-loop tests cover failure before chunks, partial chunks then failure, thrown and in-band failures, retry to success in a new turn, exhaustion to structured `turn/end.reason`, and composition with `dsh-compaction-basic` context-overflow recovery.
+- Real agent-loop tests cover failure before chunks, partial chunks then failure, thrown and in-band failures, retry to success inside the same turn, exhaustion to structured `turn/end.reason`, and composition with `dsh-compaction-basic` context-overflow recovery.
- The partial-chunk integration test proves failed chunks remain attributed to the failed step, no assistant message or tool side effect is committed for that step, and the successful retry records its own chunk seqs and provider/model route.
-- The plugin-owned `llm/retry` event is non-surface, survives JSONL and SQLite round trips, is ignored by message derivation, and drives TUI and Web retraction plus scheduled-retry rendering. Client tests cover complete wire validation, clock-independent countdown, cancellation versus completed retry labels, and trajectory attribution; keyless UI snapshots cover Web scheduling and success, a real Web composition test covers partial transport failure through recovery, and ACP automation snapshots confirm that a discarded attempt stays off the wire while the recovered reply is emitted.
+- The plugin-owned `llm/retry` event is non-surface, survives JSONL and SQLite round trips, is ignored by message derivation, and drives TUI and Web retraction plus scheduled-retry rendering. Client tests cover complete wire validation, clock-independent countdown, cancellation versus completed retry labels, and trajectory attribution; keyless UI snapshots cover Web scheduling and success, real Web composition tests cover partial transport failure through recovery and exhausted recovery's terminal error row beside the settled retry chain, and ACP automation snapshots confirm that a discarded attempt stays off the wire while the recovered reply is emitted.
- Idle-watchdog tests prove the stable signal is rearmed only while `next()` is outstanding, disarmed during consumer think time and in `finally`, and classified separately from a total-call deadline and an earlier caller abort; adapter tests prove the signal stops the underlying request rather than merely detaching it.
- Direct `ctx.llm.stream()` callers remain single-attempt and receive the same structured failure facts.
## Consequences
-- Every retry attempt is visible as a closed failed turn plus `llm/retry`, and adapter-level single-attempt behavior prevents hidden SDK retries from multiplying policy decisions. A retry can still duplicate provider billing even when no chunk arrived; normal mode limits that risk, while explicit always mode accepts it until cancellation or success.
+- Every retry attempt is visible inside its owning turn as the failed attempt's chunks plus `llm/retry`, and adapter-level single-attempt behavior prevents hidden SDK retries from multiplying policy decisions. A retry can still duplicate provider billing even when no chunk arrived; normal mode limits that risk, while explicit always mode accepts it until cancellation or success.
- Provider SDKs may hide status or retry headers. Those adapters retain the stable facts they expose and otherwise use a coarse code rather than letting recovery policy parse fragile text.
- Durable retry events expand the session protocol and UI state machine. Shipping the event and its consumer together prevents an unused telemetry vocabulary, but later schema changes still require persistence and replay work.
- Clearing a failed step's live chunks can visibly retract output. That is preferable to presenting discarded text or partial tool JSON as committed history, and snapshots pin the transition.
@@ -136,3 +136,4 @@ If recovery is exhausted, the final failure is stored once on `turn/end.reason`
- [Timeout deadline library](../../implemented/architecture/2026-07-06-timeout-deadline-library.md) separates shared deadline classification from capability-owned termination.
- [After-call compaction pressure and context-overflow recovery](../../implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md) owns the current closed-step request-recovery extension point and bounded overflow retry.
- [Provider-routed LLM adapters](../../implemented/architecture/2026-07-14-provider-routed-llm-adapters.md) owns explicit provider/model routing and the one-adapter-per-provider invariant.
+- [Terminal turn errors survive same-turn retry history](../bug-fix/2026-08-20-turn-error-survives-same-turn-retry-history.md) owns the removal of the Web retry-history suppression that hid exhausted recovery's terminal error row.
diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md
index a6b342b192..9e2263b058 100644
--- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md
+++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md
@@ -4,13 +4,13 @@ Status: implemented
[English](2026-06-21-bounded-llm-request-recovery.md) | 中文
-[按提供方配置的请求重试策略](../feature/2026-07-24-provider-retry-policies.md)在此基础上增加了确切提供方配置与显式无界 mode。本说明继续负责结构化失败事实、已关闭步骤的恢复边界、normal mode 的暂时性默认值、可见的单次尝试和持久重试状态。[LLM(大语言模型)流的终止失败](2026-07-29-terminal-llm-stream-failures.md)取代了其中关于抛出错误身份和流 sidecar 的机制。
+[按提供方配置的请求重试策略](../feature/2026-07-24-provider-retry-policies.zh.md)在此基础上增加了确切提供方配置与显式无界 mode。本说明继续负责结构化失败事实、失败尝试的恢复边界、normal mode 的暂时性默认值、可见的单次尝试和持久重试状态。[LLM(大语言模型)流的终止失败](2026-07-29-terminal-llm-stream-failures.zh.md)取代了其中关于抛出错误身份和流 sidecar 的机制。
## 问题
-提供方适配器可能在分发或迭代时抛出异常,也可能以 `finish { kind: 'error' | 'aborted' }` 结束。最终适配器边界会在 `dsh-agent-loop` 接收前把抛出值规范化为该终止 finish 协议;middleware 与结果处理缺陷仍会抛出。loop 会将终止模型请求失败交给 `agent/request-error`。未被处理的失败是终态;处理失败的监听器修复策略自有状态,返回 `{ kind: 'retry' }`,并停止 waterfall(瀑布式事件)委托。[重试动作决策](../simplification/2026-07-27-request-error-retry-action.md)规定这一返回约定。
+提供方适配器可能在分发或迭代时抛出异常,也可能以 `finish { kind: 'error' | 'aborted' }` 结束。最终适配器边界会在 `dsh-agent-loop` 接收前把抛出值规范化为该终止 finish 协议;middleware 与结果处理缺陷仍会抛出。loop 会将终止模型请求失败交给 `agent/request-error`。未被处理的失败是终态;处理失败的监听器修复策略自有状态,返回 `{ kind: 'retry' }`,并停止 waterfall(瀑布式事件)委托。[重试动作决策](../simplification/2026-07-27-request-error-retry-action.zh.md)规定这一返回约定。
-该边界已能安全地再次发起请求。原始 `assistant/chunk` 事件携带失败的 `turn` 和 `step`;除非某条成功的 `assistant/message` 引用这些事件,否则消息派生会忽略它们。只有终止性 finish 成功且组装完成后,系统才会分发工具调用;重试则会从持久日志开启新的编号轮次。因此,harness 无需引入第二套响应生命周期或暂定输出协议,即可分隔两次尝试。
+该边界已能安全地再次发起请求。原始 `assistant/chunk` 事件携带失败的 `turn` 和 `step`;除非某条成功的 `assistant/message` 引用这些事件,否则消息派生会忽略它们。只有终止性 finish 成功且组装完成后,系统才会分发工具调用;重试则会从持久日志重建下一次尝试。因此,harness 无需引入第二套响应生命周期或暂定输出协议,即可分隔两次尝试。
此前的边界还留有三个较窄的缺口。
@@ -46,15 +46,15 @@ agent loop(智能体循环)会将终止 finish 的 `LlmFailure` 传给 `agen
适配器会先提取结构化事实,再回退到消息检查。它们会验证 HTTP 状态,将 `Retry-After` 的秒数或日期解析为正的有限毫秒延迟,在提供方公开请求 id 时将其品牌化,并区分自身超时与调用方中止。提供方专用 code 和消息可以细化映射,但恢复监听器不会解析它们。
-共享的暂时性 code 集有意保持很小:适配器针对 `RATE_LIMIT` 和 `SERVER` 的映射,远程失败使用的显式 `TIMEOUT` 和 `TRANSPORT` code,以及提供方响应已完成却没有内容块时使用的 `EMPTY_RESPONSE`。两个适配器都会把最后一种情况归类为错误 finish;详见[空模型响应可重试](../bug-fix/2026-07-24-empty-model-response-is-retryable.md)。身份验证、配额、无效请求、上下文溢出、协议、中止和未知失败都保留不同的稳定 code,且默认不属于暂时性失败。新增 code 需要适配器 fixture(测试前置数据)和已记录的策略决策;无需扩展第二个失败类枚举。
+共享的暂时性 code 集有意保持很小:适配器针对 `RATE_LIMIT` 和 `SERVER` 的映射,远程失败使用的显式 `TIMEOUT` 和 `TRANSPORT` code,以及提供方响应已完成却没有内容块时使用的 `EMPTY_RESPONSE`。两个适配器都会把最后一种情况归类为错误 finish;详见[空模型响应可重试](../bug-fix/2026-07-24-empty-model-response-is-retryable.zh.md)。身份验证、配额、无效请求、上下文溢出、协议、中止和未知失败都保留不同的稳定 code,且默认不属于暂时性失败。新增 code 需要适配器 fixture(测试前置数据)和已记录的策略决策;无需扩展第二个失败类枚举。
### 将重试策略放在现有失败步骤扩展点上
`@deepseek-ai/dsh-llm-retry` 是监听 `agent/request-error` 的函数插件。它不引入服务或新的循环分支;agent-loop 包仅会更改通过现有失败步骤恢复控制流携带的数据。
-`agent/request-error` waterfall 携带当前 `LlmFailure`、在连续恢复序列中授权重试轮次的不可变先前失败列表,以及提供服务的注册项所携带的不可变重试策略。循环只传递而不解释该策略;它拥有连续失败历史,并在模型请求成功后清除。`dsh-llm-retry` 的 normal 策略统计由同一项确切提供方策略安排的持久重试记录,`dsh-compaction-basic` 则维护自己的上下文溢出预算。因此,暂时性失败与上下文溢出交替出现时,会各自独立消耗其有限预算;最大请求数等于 1 加上所有已加载有限预算之和。
+`agent/request-error` waterfall 携带当前 `LlmFailure`、在连续恢复序列中授权重试的不可变先前失败列表,以及提供服务的注册项所携带的不可变重试策略。循环只传递而不解释该策略;它拥有连续失败历史,并在模型请求成功后清除。`dsh-llm-retry` 的 normal 策略统计由同一项确切提供方策略安排的持久重试记录,`dsh-compaction-basic` 则维护自己的上下文溢出预算。因此,暂时性失败与上下文溢出交替出现时,会各自独立消耗其有限预算;最大请求数等于 1 加上所有已加载有限预算之和。
-当前配置形状由[提供方策略决策](../feature/2026-07-24-provider-retry-policies.md)规定。提供方适配器会注册嵌套的 `retryPolicy`;省略时使用 normal 默认值:两次暂时性重试、500 毫秒初始延迟、10 秒延迟上限、10% 抖动,以及上述五个暂时性 code。计数与延迟边界参考了所调查实现中较保守的一端:[OpenCode 使用两次请求重试,延迟边界为 500 毫秒/10 秒](https://github.com/anomalyco/opencode/blob/9976269ab1accfc9f9dc98a4a688c516934de422/%70ackages/llm/src/route/executor.ts#L36-L39);[Pi 将三次 agent 级重试与提供方重试分开,且提供方重试默认为零](https://github.com/earendil-works/pi/blob/3da591ab74ab9ab407e72ed882600b2c851fae21/%70ackages/coding-agent/docs/settings.md#L139-L147);[Codex 使用有限请求/流预算以及五分钟空闲超时](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/model-provider-info/src/lib.rs#L25-L33)。10% 抖动参考 [Codex 的有界抖动](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/codex-client/src/retry.rs#L40-L47)。
+当前配置形状由[提供方策略决策](../feature/2026-07-24-provider-retry-policies.zh.md)规定。提供方适配器会注册嵌套的 `retryPolicy`;省略时使用 normal 默认值:两次暂时性重试、500 毫秒初始延迟、10 秒延迟上限、10% 抖动,以及上述五个暂时性 code。计数与延迟边界参考了所调查实现中较保守的一端:[OpenCode 使用两次请求重试,延迟边界为 500 毫秒/10 秒](https://github.com/anomalyco/opencode/blob/9976269ab1accfc9f9dc98a4a688c516934de422/%70ackages/llm/src/route/executor.ts#L36-L39);[Pi 将三次 agent 级重试与提供方重试分开,且提供方重试默认为零](https://github.com/earendil-works/pi/blob/3da591ab74ab9ab407e72ed882600b2c851fae21/%70ackages/coding-agent/docs/settings.md#L139-L147);[Codex 使用有限请求/流预算以及五分钟空闲超时](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/model-provider-info/src/lib.rs#L25-L33)。10% 抖动参考 [Codex 的有界抖动](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/codex-client/src/retry.rs#L40-L47)。
对于预算未耗尽的合格失败,从 1 开始的暂时性重试计数使用有界指数退避。有效的 `providerRetryAfterMs` 只有在不超过 `maxDelayMs` 时才会取代指数退避;提供方延迟更长时,系统会委托给下一监听器,而不会违反提供方指令提前重试。本地退避乘以 `[1 - jitterRatio, 1 + jitterRatio]` 内的注入随机因子,并将最终值限制到 `maxDelayMs`;提供方延迟不加抖动。
@@ -68,7 +68,7 @@ agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次
### 由单一层负责可见的尝试
-适配器每次调用 `stream()` 只执行一次提供方请求。pi-ai 适配器移除公开的 `maxRetries` 和 `maxRetryDelayMs` profile 字段,并禁用库内部重试;手写适配器保持现有的单次尝试行为。这样既避免 SDK 预算成倍放大 agent 预算,又能确保每次暂时性重试都由一个已关闭的失败步骤加 `llm/retry` 表示。
+适配器每次调用 `stream()` 只执行一次提供方请求。pi-ai 适配器移除公开的 `maxRetries` 和 `maxRetryDelayMs` profile 字段,并禁用库内部重试;手写适配器保持现有的单次尝试行为。这样既避免 SDK 预算成倍放大 agent 预算,又能确保每次暂时性重试都由其记录在案的失败尝试加 `llm/retry` 表示。
`ctx.llm.stream()` 仍是原始的单次尝试 waterfall。压缩(compaction)摘要等直接调用方会收到结构化失败,但不会自动获得重试,因为它们没有 agent 步骤边界,也没有可供分隔尝试的通用持久位置。未来的直接调用消费方可能会需要一个缓冲辅助函数,仅在尚未发出任何分片时重试;本决策不增加此类辅助函数。
@@ -82,9 +82,9 @@ agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次
### 在现有日志中分隔尝试
-一次失败尝试可以在已关闭的步骤中留下 `assistant/chunk` 事件,但绝不会追加 `assistant/message`,也不会分发工具。重试会关闭失败轮次,开启下一个编号轮次,从持久表层重建请求,并生成自己的分片。步骤仍处于打开状态时,UI 可以渲染实时分片;当 `llm/retry` 标识失败步骤,或 `turn/end` 记录失败时,UI 再标记或清除这份暂时视图。Web 会验证完整的重试载荷约定,在 `llm/retry` 到达时清除失败的部分输出,将连续重试轮次的事件投影为稳定的一行,并用最新一次尝试更新该行,再从后续轮次事实派生 scheduled、started 或 cancelled 状态。倒计时以浏览器收到事件的时刻为计划延迟的起点,而不是使用 Host 事件时钟;它按向上取整且不低于 1 秒的秒数显示,仅在重试尚未结束时显示动画,并把最近一次失败的准确详情折叠在该行之后。即使失败尝试没有 assistant 节点,重试节点也会锚定自身的轨迹轮次。消息派生仍会忽略失败分片;Web 在重建历史时也会应用同一投影,因此刷新页面不会让已丢弃的部分输出重新出现,也不会生成重复的重试行。
+一次失败尝试可以在其步骤中留下 `assistant/chunk` 事件,但绝不会追加 `assistant/message`,也不会分发工具。重试在失败的轮次与步骤内继续,从持久表层重建请求,并生成自己的分片;只有最终结果才会关闭该轮次。步骤仍处于打开状态时,UI 可以渲染实时分片;当 `llm/retry` 标识失败尝试,或 `turn/end` 记录失败时,UI 再标记或清除这份暂时视图。Web 会验证完整的重试载荷约定,在 `llm/retry` 到达时清除失败的部分输出,将每条生产方关联的 `retryId` 重试链投影为稳定的一行,并用最新一次尝试更新该行,再从 `llm/retry-started` 与所属轮次、步骤边界的关闭派生 scheduled、started 或 cancelled 状态。倒计时以浏览器收到事件的时刻为计划延迟的起点,而不是使用 Host 事件时钟;它按向上取整且不低于 1 秒的秒数显示,仅在重试尚未结束时显示动画,并把最近一次失败的准确详情折叠在该行之后。即使失败尝试没有 assistant 节点,重试节点也会锚定自身的轨迹轮次。消息派生仍会忽略失败分片;Web 在重建历史时也会应用同一投影,因此刷新页面不会让已丢弃的部分输出重新出现,也不会生成重复的重试行。
-如果恢复预算耗尽,最终失败会连同结构化事实在 `turn/end.reason` 中存储一次。Web 会在该序列位置派生一个 `turn-error` 节点,并内联渲染适合展示的消息与可选错误码;AUTH 投影会把可能回显凭据片段的提供方文案替换为 `API key is invalid`,原始诊断仍保留在会话日志中。实时事件和历史回放使用同一套折叠逻辑。如果暂时性恢复继续,`llm/retry` 就是该次尝试的失败与延迟的持久归属位置,因此该失败轮次不会再获得终态错误行。本决策不增加独立的最终错误事件或响应 id 词汇。
+如果恢复预算耗尽,最终失败会连同结构化事实在 `turn/end.reason` 中存储一次。Web 会在该序列位置派生一个 `turn-error` 节点,并内联渲染适合展示的消息与可选错误码;AUTH 投影会把可能回显凭据片段的提供方文案替换为 `API key is invalid`,原始诊断仍保留在会话日志中。实时事件和历史回放使用同一套折叠逻辑。暂时性恢复继续期间,`llm/retry` 是每次中间失败与延迟的持久归属位置;终态错误行只在 `turn/end` 记录错误后才存在,而由于耗尽的恢复与失败共享同一轮次,该轮次的重试历史绝不会抑制这一行——定格的重试链与终态错误并列渲染。本决策不增加独立的最终错误事件或响应 id 词汇。
## 不在范围内
@@ -100,7 +100,7 @@ agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次
- **向 `dsh-llm` 增加响应开始、中断、丢弃、失败和提交事件**:拒绝采用,因为 agent 日志已经分隔原始分片、成功消息和编号尝试。第二套状态机会重复归属关系,又不能支持有界的同路由重试。
- **增加逻辑路由、能力矩阵和故障转移选择**:拒绝采用,因为当前请求已经显式指定提供方和模型,每个提供方由一个适配器负责,而且没有当前消费方要求自动回退或能够证明语义兼容性。
- **把 `retryable` 或 `failover` 放在 `LlmFailure` 上**:拒绝采用,因为适配器报告事实,部署策略决定动作。同一个 429 可以在交互式组合包中重试,也可以在成本受限的批处理中被拒绝。
-- **只要调用方仍处于活跃状态就无限重试**:[按提供方配置的策略](../feature/2026-07-24-provider-retry-policies.md)对显式 `always` 配置项推翻了这项拒绝,同时保留有界的 normal mode 作为默认值。
+- **只要调用方仍处于活跃状态就无限重试**:[按提供方配置的策略](../feature/2026-07-24-provider-retry-policies.zh.md)对显式 `always` 配置项推翻了这项拒绝,同时保留有界的 normal mode 作为默认值。
- **只通过进程 logger 记录重试状态**:拒绝采用,因为进程日志无法重建会话行为,也不能驱动回放后的 UI 状态。
- **只保留扁平 code**:拒绝采用,因为重试延迟和提供方请求 id 是结构化的提供方事实,而当不同协议失败共用一个稳定 code 时,诊断还需要 HTTP 状态。
@@ -114,15 +114,15 @@ agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次
- 每个提供方适配器都在 Loader 启动时验证其嵌套重试策略,`ctx.llm` 则将该策略与路由一同捕获;normal mode 会委托不合格路径,而且在没有其他策略时最多发起 `maxRetries + 1` 次提供方请求。
- 退避期间执行 HMR 的测试证明:dispose 过程会注销监听器、中止并等待其捕获的回调,dispose 后不发出重试决策,也不留下存活的定时器或 promise。
- 纯单元测试覆盖暂时性 code 选择、指数退避和抖动边界、有效及超出上限的 `Retry-After`、耗尽的预算、确定性定时器/随机数钩子,以及退避期间中止。
-- 真实 agent-loop 测试覆盖分片前失败、部分分片后失败、抛出及带内失败、在新轮次中重试至成功、耗尽后写入结构化 `turn/end.reason`,以及与 `dsh-compaction-basic` 上下文溢出恢复的组合。
+- 真实 agent-loop 测试覆盖分片前失败、部分分片后失败、抛出及带内失败、在同一轮次内重试至成功、耗尽后写入结构化 `turn/end.reason`,以及与 `dsh-compaction-basic` 上下文溢出恢复的组合。
- 部分分片集成测试证明:失败分片仍归属于失败步骤,该步骤不会提交 assistant 消息或工具副作用,成功的重试会记录自己的分片 seq 和提供方/模型路由。
-- 插件拥有的不进入表层的 `llm/retry` 事件可在 JSONL 和 SQLite 往返后保留,被消息派生忽略,并驱动 TUI 和 Web 撤回及计划重试渲染。客户端测试覆盖完整的 wire 验证、独立于时钟的倒计时、已取消与已完成重试标签的区别以及轨迹归属;无密钥 UI 快照覆盖 Web 的调度与成功,真实 Web 组合测试覆盖部分传输失败直至恢复,ACP 自动化快照确认,被丢弃的尝试不会通过协议发出,而恢复后的回复会正常发出。
+- 插件拥有的不进入表层的 `llm/retry` 事件可在 JSONL 和 SQLite 往返后保留,被消息派生忽略,并驱动 TUI 和 Web 撤回及计划重试渲染。客户端测试覆盖完整的 wire 验证、独立于时钟的倒计时、已取消与已完成重试标签的区别以及轨迹归属;无密钥 UI 快照覆盖 Web 的调度与成功,真实 Web 组合测试覆盖部分传输失败直至恢复,以及耗尽后终态错误行与定格重试链并列的画面,ACP 自动化快照确认,被丢弃的尝试不会通过协议发出,而恢复后的回复会正常发出。
- 空闲看门狗测试证明:只有 `next()` 尚未完成时才会重新布防稳定信号;在消费方思考期间及 `finally` 中会解除布防;它与总调用 deadline 以及更早发生的调用方中止分开分类。适配器测试证明该信号会终止底层请求,而不只是与其脱离。
- `ctx.llm.stream()` 的直接调用方仍只尝试一次,并收到相同的结构化失败事实。
## 后果
-- 每次重试尝试都以一个已关闭失败轮次加 `llm/retry` 的形式可见,适配器级的单次尝试行为会防止隐藏的 SDK 重试成倍增加策略决策。即使没有分片到达,重试仍可能造成提供方重复计费;normal mode 会限制此风险,而显式 always mode 会接受它,直至取消或成功。
+- 每次重试尝试都在其所属轮次内以失败尝试的分片加 `llm/retry` 的形式可见,适配器级的单次尝试行为会防止隐藏的 SDK 重试成倍增加策略决策。即使没有分片到达,重试仍可能造成提供方重复计费;normal mode 会限制此风险,而显式 always mode 会接受它,直至取消或成功。
- 提供方 SDK 可能隐藏状态或重试标头。适配器会保留 SDK 公开的稳定事实,否则使用粗粒度 code,而不会让恢复策略解析脆弱的文本。
- 持久重试事件扩展了会话协议和 UI 状态机。事件与其消费方一同交付,可避免产生无人使用的遥测词汇;但以后更改 schema 仍需要同步完成持久化和回放工作。
- 清除失败步骤的实时分片可能会明显撤回输出。与把丢弃的文本或不完整工具 JSON 呈现为已提交历史相比,这是更好的选择;快照固定这一转换。
@@ -131,8 +131,9 @@ agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次
## 相关资料
-- [结构化错误分类体系](../../implemented/architecture/2026-06-11-structured-error-taxonomy.md)负责稳定、可供机器路由的 code 与 cause chaining。
-- [可重建请求](../../implemented/architecture/2026-07-05-reconstructable-requests.md)使提供方/模型和完整请求输入在分发前持久化。
-- [超时 deadline 库](../../implemented/architecture/2026-07-06-timeout-deadline-library.md)将共享的 deadline 分类与能力自身拥有的终止操作分开。
-- [调用后压缩压力与上下文溢出恢复](../../implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)负责当前已关闭步骤的请求恢复扩展点与有界溢出重试。
-- [提供方路由的 LLM 适配器](../../implemented/architecture/2026-07-14-provider-routed-llm-adapters.md)负责显式提供方/模型路由与每个提供方仅有一个适配器的不变量。
+- [结构化错误分类体系](../../implemented/architecture/2026-06-11-structured-error-taxonomy.zh.md)负责稳定、可供机器路由的 code 与 cause chaining。
+- [可重建请求](../../implemented/architecture/2026-07-05-reconstructable-requests.zh.md)使提供方/模型和完整请求输入在分发前持久化。
+- [超时 deadline 库](../../implemented/architecture/2026-07-06-timeout-deadline-library.zh.md)将共享的 deadline 分类与能力自身拥有的终止操作分开。
+- [调用后压缩压力与上下文溢出恢复](../../implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md)负责当前已关闭步骤的请求恢复扩展点与有界溢出重试。
+- [提供方路由的 LLM 适配器](../../implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md)负责显式提供方/模型路由与每个提供方仅有一个适配器的不变量。
+- [Terminal turn errors survive same-turn retry history](../bug-fix/2026-08-20-turn-error-survives-same-turn-retry-history.zh.md)负责移除曾藏掉耗尽恢复终态错误行的 Web 重试历史抑制。
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 687743c0a4..1b5420b35a 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
@@ -3,4 +3,4 @@
# 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: 479d3a46dc41c5cc9ae9b77b81dbef3d6524370b
-2026-06-21-mandatory-app-attribution-headers.zh.md: 75162604623099d50ca87aafd5d45567e2121789
+2026-06-21-mandatory-app-attribution-headers.zh.md: 1b11cb6ef1e96609c6777134a85de298ca979c58
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 7516260462..1b11cb6ef1 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
@@ -6,7 +6,7 @@ Status: implemented
## 问题
-LLM(大语言模型)提供方请求应当标识发出请求的产品。这对提供方侧的技术支持、滥用调查、兼容性调试和流量分析都有价值。在本 Agent Note 之前,harness 只做了部分工作:手写的 DeepSeek 适配器发送了一个手动复制的 `User-Agent` 常量(`packages/llm/llm-deepseek/src/adapter.ts`),而基于 pi-ai 的孪生适配器则完全不发送 harness 自有的头部(`packages/llm/llm-pi-ai/src/adapter.ts`)。因此新适配器可以悄无声息地省略归属标识,而基于库的适配器也可能与手写适配器产生偏差——尽管[孪生适配器 Agent Note](2026-06-13-twin-llm-adapters.md) 的存在正是为了确保两种实现中的提供方约定真实可靠。
+LLM(大语言模型)提供方请求应当标识发出请求的产品。这对提供方侧的技术支持、滥用调查、兼容性调试和流量分析都有价值。在本 Agent Note 之前,harness 只做了部分工作:手写的 DeepSeek 适配器发送了一个手动复制的 `User-Agent` 常量(`packages/llm/llm-deepseek/src/adapter.ts`),而基于 pi-ai 的孪生适配器则完全不发送 harness 自有的头部(`packages/llm/llm-pi-ai/src/adapter.ts`)。因此新适配器可以悄无声息地省略归属标识,而基于库的适配器也可能与手写适配器产生偏差——尽管[孪生适配器 Agent Note](2026-06-13-twin-llm-adapters.zh.md) 的存在正是为了确保两种实现中的提供方约定真实可靠。
直接触发因素来自 OpenRouter 的[应用归属](https://openrouter.ai/docs/app-attribution)文档。OpenRouter 根据 `HTTP-Referer` 加上用于展示和分类的头部来创建应用页面和排名。这有价值,但它不是 HTTP 标准中的应用身份机制。风险在于:把 OpenRouter 的精确头部集当作通用标准来采纳,然后将提供方特有的头部泄漏到直连 DeepSeek 的请求、未来的 OpenAI/Anthropic/Vertex 适配器、测试服务器或无限期记录未知字段的代理中。
@@ -24,7 +24,7 @@ LLM(大语言模型)提供方请求应当标识发出请求的产品。这
## 决策
-在 LLM 适配器边界,提供方无关的应用归属是强制的,且仅使用标准 `User-Agent` 头部。规则:每个产品级 LLM 适配器在每个提供方 HTTP 请求上发送一个静态、非机密的应用身份,且每个适配器都有测试证明 `User-Agent` 到达了线路(mock 服务器断言收到的头部;对于基于库的适配器,通过库的头部钩子馈入同一个 mock 服务器断言)。这条规则约束应用归属,不约束提供方特有的请求身份;[DeepSeek 请求身份决策](../feature/2026-08-11-deepseek-request-user-id-header.md)另行负责其用户与会话头部。
+在 LLM 适配器边界,提供方无关的应用归属是强制的,且仅使用标准 `User-Agent` 头部。规则:每个产品级 LLM 适配器在每个提供方 HTTP 请求上发送一个静态、非机密的应用身份,且每个适配器都有测试证明 `User-Agent` 到达了线路(mock 服务器断言收到的头部;对于基于库的适配器,通过库的头部钩子馈入同一个 mock 服务器断言)。这条规则约束应用归属,不约束提供方特有的请求身份;[DeepSeek 请求身份决策](../feature/2026-08-11-deepseek-request-user-id-header.zh.md)另行负责其用户与会话头部。
OpenRouter 应用归属刻意未实现。`HTTP-Referer`、`X-OpenRouter-Title`、`X-Title` 和 `X-OpenRouter-Categories` 是 OpenRouter 特有的产品展示头部,不是提供方无关的模型请求归属。它们可以后续由 OpenRouter 适配器或显式 OpenRouter 模式提出,附带自己的隐私/产品决策、测试和文档。在此之前,即使请求指向 OpenRouter,也只发送本决策定义的共享 `User-Agent` 归属。
diff --git a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml
index bc92549f44..f0e60bbc20 100644
--- a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md
2026-06-24-web-capability-seam.md: 7e7b09f19864bd2ad8ad9d69579c1d5c79600cde
-2026-06-24-web-capability-seam.zh.md: d6051eec498edb640773ba367038581cebd1f654
+2026-06-24-web-capability-seam.zh.md: dbb41ee42d2c7503955ead2df32abe80b3a4f641
diff --git a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md
index d6051eec49..dbb41ee42d 100644
--- a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md
+++ b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md
@@ -16,7 +16,7 @@ harness 需要面向模型的 web 工具,但不能将模型约定绑定到某
## 决策
-Web 访问是一个一等能力 seam,遵循[能力 seam Agent Note](2026-06-13-capability-seams.md):
+Web 访问是一个一等能力 seam,遵循[能力 seam Agent Note](2026-06-13-capability-seams.zh.md):
1. `@deepseek-ai/dsh-web`(`packages/web/web`)拥有 `ctx.web`、提供方注册、提供方选择、共享的请求/结果词汇,以及 web 特有的错误。
2. 提供方包实现具体后端并向 `ctx.web` 注册能力,例如 `@deepseek-ai/dsh-web-search-exa`、`@deepseek-ai/dsh-web-search-perplexity`、`@deepseek-ai/dsh-web-search-deepseek` 和 `@deepseek-ai/dsh-web-fetch-http`。
@@ -280,7 +280,7 @@ SSRF/私有网络防护(阻断私有、回环、链路本地、多播及其他
## 测试
-每一层在自己的边界处固定:`dsh-web` 中的注册/选择/截断/abort 约定与 `WebError` 码;每个提供方基于录制的 fixture(测试前置数据)的请求/响应映射(Perplexity fixture 包含纯 URL 引用,以保持可选 source 字段的诚实性),加上每个真实提供方的自跳过带密钥冒烟测试;`web-fetch-http` 中的真实本地 HTTP 行为;`dsh-tool-web` 中通过真实工具注册表的启用驱动注册、结构化执行错误和结果格式化。一个真实 Loader 冒烟测试守护两种导出形状([事故复盘(postmortem) 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md)):`dsh-web` 是默认导出的服务,而提供方和 `tool-web` 是命名空间插件,误加 `export default` 会丢失 `inject`。
+每一层在自己的边界处固定:`dsh-web` 中的注册/选择/截断/abort 约定与 `WebError` 码;每个提供方基于录制的 fixture(测试前置数据)的请求/响应映射(Perplexity fixture 包含纯 URL 引用,以保持可选 source 字段的诚实性),加上每个真实提供方的自跳过带密钥冒烟测试;`web-fetch-http` 中的真实本地 HTTP 行为;`dsh-tool-web` 中通过真实工具注册表的启用驱动注册、结构化执行错误和结果格式化。一个真实 Loader 冒烟测试守护两种导出形状([事故复盘(postmortem) 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.zh.md)):`dsh-web` 是默认导出的服务,而提供方和 `tool-web` 是命名空间插件,误加 `export default` 会丢失 `inject`。
## 曾考虑的替代方案
@@ -329,10 +329,10 @@ SSRF/私有网络防护(阻断私有、回环、链路本地、多播及其他
- `web_fetch` 的 SSRF/私有网络防护:阻断私有、回环、链路本地、多播及其他非公开目的地,使 `web_fetch` 不再是 SSRF 原语。正确实现不仅仅是 URL 字符串检查——需要先 DNS 解析再连接到已验证的 IP(防御 DNS rebinding/TOCTOU)、跨重定向的每跳重新验证,以及 IPv6 边缘处理(私有范围、IPv4 映射地址)。所调研的参考实现均未做 IP 级阻断(OpenCode 做前缀检查后直接 fetch;Claude Code 依赖集中式主机名黑名单加「私有 URL 会失败」的提示词),因此没有可复制的实现,且这是 harness 唯一的 SSRF 防线——值得一次专门的设计/spike。在其落地之前,`web_fetch` 只能在无法触达敏感内部目标的部署中启用。
- `pdf` `WebFetchBody` 类别:`http` 提供方将可文本提取的 PDF 解码(尽力而为、有上限、`truncated`)为 `{ kind: 'pdf'; content; pageCount? }` 分支,`tool-web` 渲染它。这是 fetch 而非 `web_extract`——PDF 获取是具体的 HTTP 200 加确定性的本地解码,不是提供方侧对非 HTTP 资源的提取。添加它是跨 `dsh-web`(声明分支)、提供方(解码 + 将「二进制拒绝」收窄为「拒绝二进制,但可文本提取的 PDF 除外」;需要 OCR 的扫描/图片 PDF 不在范围内)和 `tool-web`(渲染)的协调变更。封闭的 `WebFetchBody` 联合类型使消费方在新分支被处理之前编译失败。
- 提供方支撑的提取作为独立的 `web_extract` 能力,而非静默扩展 `web_fetch`。
-- 权限策略集成:权限系统现已存在([沙箱与审批](../feature/2026-07-06-sandbox.md)、[web 权限预设](../feature/2026-07-23-web-permission-and-approval.md)),但只捆绑了沙箱模式与审批策略;web 权限策略仍未集成。
+- 权限策略集成:权限系统现已存在([沙箱与审批](../feature/2026-07-06-sandbox.zh.md)、[web 权限预设](../feature/2026-07-23-web-permission-and-approval.zh.md)),但只捆绑了沙箱模式与审批策略;web 权限策略仍未集成。
- `query` 和 `maxResults` 之外的提供方无关搜索控制,待 Exa 和 Perplexity 都能诚实遵守时再添加。
## 开放问题
- 产品应用包是否应在启动时探测 web 配置(当 web 被显式配置时将 `WEB_PROVIDER_CONFIGURED_MISSING`、`WEB_PROVIDER_CONFIGURED_UNAVAILABLE` 和 `WEB_PROVIDER_AMBIGUOUS` 视为致命错误),还是将配置错误留到首次执行时浮出?
-- 在已交付的权限系统([沙箱与审批](../feature/2026-07-06-sandbox.md)、[web 权限预设](../feature/2026-07-23-web-permission-and-approval.md))中,公开 web 访问的权限策略应放在哪里:`tools/execute` 上的专用 web 权限插件、提供方配置,还是两者兼有?
+- 在已交付的权限系统([沙箱与审批](../feature/2026-07-06-sandbox.zh.md)、[web 权限预设](../feature/2026-07-23-web-permission-and-approval.zh.md))中,公开 web 访问的权限策略应放在哪里:`tools/execute` 上的专用 web 权限插件、提供方配置,还是两者兼有?
diff --git a/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.i18n.yaml
index c49cd144b9..421739cb57 100644
--- a/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md
2026-06-26-file-context-as-event-gate.md: a8316741c3ce8548db5b5def431425589994995c
-2026-06-26-file-context-as-event-gate.zh.md: 78b1b759edc0340fd7be103fc9bfec0b125e0800
+2026-06-26-file-context-as-event-gate.zh.md: 618985f33073cf9fd69cf2c7fda7112d9752468f
diff --git a/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.zh.md b/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.zh.md
index 78b1b759ed..618985f330 100644
--- a/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.zh.md
+++ b/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.zh.md
@@ -6,7 +6,7 @@ Status: implemented
## 问题
-[拆分文件系统 seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md) 在面向模型的工具与 `ctx.fs` 提供方之间放置了 `ctx.fileContext`:`dsh-tool-fs` 注入 `fileContext`,并将每次 `read`/`write`/`edit` 路由到它的方法。这使得 `fileContext` **位于关键路径上且不可省略**。工具不经过它就无法访问 `ctx.fs`,策略层掌控着 fs I/O 和读取窗口,而一个不需要观测状态策略的部署也无法简单地移除该包——`dsh-tool-fs` 会因无法解析 `ctx.fileContext` 而失败。
+[拆分文件系统 seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.zh.md) 在面向模型的工具与 `ctx.fs` 提供方之间放置了 `ctx.fileContext`:`dsh-tool-fs` 注入 `fileContext`,并将每次 `read`/`write`/`edit` 路由到它的方法。这使得 `fileContext` **位于关键路径上且不可省略**。工具不经过它就无法访问 `ctx.fs`,策略层掌控着 fs I/O 和读取窗口,而一个不需要观测状态策略的部署也无法简单地移除该包——`dsh-tool-fs` 会因无法解析 `ctx.fileContext` 而失败。
这把三件本应可分离的事情耦合在了一起:
@@ -33,7 +33,7 @@ provider dsh-fs-local local implementation of ctx.fs
该模型是叠加式的:裸 `ctx.fs` 执行原子化、无约束的文本 I/O,而 `dsh-fs-observation-policy` 叠加观测状态、先读后编辑和版本守卫。因此移除策略层后工具仍可用,只是不受约束。正式发布的 agent(智能体)配置会加载策略;裸模式的存在是为了让策略在服务边界保持可选,而非作为正常部署姿态。
-[文件系统缺失观测后续决策](../bug-fix/2026-08-09-filesystem-absence-observation.md)把记录载荷从仅表示成功的版本细化为显式的存在/缺失状态,并要求带防护的创建以不替换方式发布。事件门控归属与无 I/O 策略边界保持不变。
+[文件系统缺失观测后续决策](../bug-fix/2026-08-09-filesystem-absence-observation.zh.md)把记录载荷从仅表示成功的版本细化为显式的存在/缺失状态,并要求带防护的创建以不替换方式发布。事件门控归属与无 I/O 策略边界保持不变。
`dsh-tool-fs` 不再注入 `fileContext`。它注入 `fs` 和 `tools`/`systemPrompt`。
@@ -152,7 +152,7 @@ interface Events {
## 取代关系
-本 Agent Note 修正——而非推翻——[拆分文件系统 seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md)。四层拆分、提供方约定和新鲜度*策略*均保留。变更的是**工具与策略层之间的耦合方式**:强制性方法服务变为插件拥有的事件门控,fs I/O + 读取窗口从 `fileContext` 上移至 `dsh-tool-fs`。拆分文件系统 seam Agent Note 中关于 `dsh-tool-fs` 注入 `fileContext` 以及 `fileContext` 拥有 `read`/`write`/`edit` 的描述已在同一变更中更新。
+本 Agent Note 修正——而非推翻——[拆分文件系统 seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.zh.md)。四层拆分、提供方约定和新鲜度*策略*均保留。变更的是**工具与策略层之间的耦合方式**:强制性方法服务变为插件拥有的事件门控,fs I/O + 读取窗口从 `fileContext` 上移至 `dsh-tool-fs`。拆分文件系统 seam Agent Note 中关于 `dsh-tool-fs` 注入 `fileContext` 以及 `fileContext` 拥有 `read`/`write`/`edit` 的描述已在同一变更中更新。
## 验证
@@ -160,7 +160,7 @@ interface Events {
## 曾考虑的替代方案
-- **保留 `ctx.fileContext` 作为关键路径上的方法服务**——[拆分文件系统 seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md) 最初落地的形态;否决,因为工具无法在没有策略层的情况下运行,使策略对基本操作是承重性的,而非可选的收紧。
+- **保留 `ctx.fileContext` 作为关键路径上的方法服务**——[拆分文件系统 seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.zh.md) 最初落地的形态;否决,因为工具无法在没有策略层的情况下运行,使策略对基本操作是承重性的,而非可选的收紧。
- **策略侧版本检查**(`dsh-fs-observation-policy` 在其 waterfall 处理器中 stat 并比较版本)——否决,因为该检查与工具实际写入之间存在 TOCTOU 间隙;提供方的 mutation 临界区是唯一无竞态的位置,因此策略只选择 CAS 基准并对先前观测进行门控。
- **每工具 `/read`/`/write`/`/edit` 子路径插件**——实现时放弃:没有消费方需要单工具部署,且子路径发布迫使引入兄弟工具包都不需要的定制 `tsdown`/`tsconfig`/`files`/workspace-constraint 处理;每工具的注册辅助函数仍作为根插件组合的内部模块保留。
diff --git a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.i18n.yaml
index 7a5d35f882..04eec8e0e1 100644
--- a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md
2026-06-30-bash-stdin-env-trusted-plugin-api.md: 41be63fff598587ee9b873cf7edf51da788bc02a
-2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md: 4f0338cbb6a265ff629a81e9c9a12c4456941ba0
+2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md: 60721539d9d37857e145e289bb262482588139bf
diff --git a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md
index 4f0338cbb6..60721539d9 100644
--- a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md
+++ b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md
@@ -8,7 +8,7 @@ Status: implemented
钩子子系统以 Claude Code 和 Codex 的方式运行外部钩子命令:钩子是一条 shell 命令,通过 **stdin 上的 JSON** 接收事件载荷,并从若干**环境变量**(`CLAUDE_PROJECT_DIR`、`CLAUDE_PLUGIN_ROOT`、`PLUGIN_ROOT`……)读取上下文。harness 已经在 `ctx.shell` 能力 seam 后面有一个完善的命令执行器([dsh-shell](../../../../packages/shell/shell) → [dsh-bash-local](../../../../packages/shell/bash-local)),具备进程组终止、输出截断/spill 处理和凭证擦除功能。复用它来执行钩子意味着钩子桥接层无需重新实现子进程底层机制——但该 seam 此前无法写入 stdin 或设置额外 env。本次变更添加这两个输入。
-`stdin` 和 `env` 不构成新的模型能力,因为普通 shell 语法已经能提供两者。环境凭证由 `dsh-bash-local` 的子环境擦除机制保护,而非靠隐藏这些 Service Definition 字段;模型工具参数是静态 JSON,不会展开 shell 变量。因此这些字段服务于受信的进程内调用方(如钩子桥接层),它们需要传递结构化输入和 `CLAUDE_*` 变量,而不必将其嵌入模型可见的 shell 文本。环境变量规则见 [defensive-patterns.md](../../../../docs/defensive-patterns.md)。
+`stdin` 和 `env` 不构成新的模型能力,因为普通 shell 语法已经能提供两者。环境凭证由 `dsh-bash-local` 的子环境擦除机制保护,而非靠隐藏这些 Service Definition 字段;模型工具参数是静态 JSON,不会展开 shell 变量。因此这些字段服务于受信的进程内调用方(如钩子桥接层),它们需要传递结构化输入和 `CLAUDE_*` 变量,而不必将其嵌入模型可见的 shell 文本。环境变量规则见 [defensive-patterns.md](../../../../docs/defensive-patterns.zh.md)。
## 决策
@@ -16,7 +16,7 @@ Status: implemented
三个有意为之的选择:
-1. **模型侧工具不暴露 `stdin` 和 `env`。** Shell 语法已覆盖这些需求,重复参数只会增加接口面而不带来权限隔离。工具仅从声明的模型参数、signal 和 owner 构建请求;受信的进程内调用方可以直接设置请求字段。harness 自有变量使用[托管环境决策](../feature/2026-07-10-agent-session-identity-and-log-location.md)规定的独立 `dshEnv` 通道,因此普通 `env` 无法替换它们。
+1. **模型侧工具不暴露 `stdin` 和 `env`。** Shell 语法已覆盖这些需求,重复参数只会增加接口面而不带来权限隔离。工具仅从声明的模型参数、signal 和 owner 构建请求;受信的进程内调用方可以直接设置请求字段。harness 自有变量使用[托管环境决策](../feature/2026-07-10-agent-session-identity-and-log-location.zh.md)规定的独立 `dshEnv` 通道,因此普通 `env` 无法替换它们。
2. **`env` 在凭证擦除之后合并,因此调用方显式设置的条目即使具有凭证形态的名称也会胜出。** 后续的托管命名空间决策负责管理 `DSH_*`:这类环境条目会被移除,受信的 `dshEnv` 最后合并,因此普通 `env` 条目永远无法顶掉托管值。完整顺序为 `scrub(process.env, including DSH_*)` → `ENV_OVERRIDES` → 普通 `env` → `dshEnv`。
@@ -30,4 +30,4 @@ Status: implemented
## 后果
-钩子桥接层通过既有的 bash seam 传递 JSON 载荷和钩子特定变量,保留其进程组终止、截断和 spill 行为。面向模型的行为不变,bash 工具仍是模型调用请求构建的唯一所有者。相关词汇定义见 [bash 数据结构参考](../../../../docs/subsystems/shell.md)。
+钩子桥接层通过既有的 bash seam 传递 JSON 载荷和钩子特定变量,保留其进程组终止、截断和 spill 行为。面向模型的行为不变,bash 工具仍是模型调用请求构建的唯一所有者。相关词汇定义见 [bash 数据结构参考](../../../../docs/subsystems/shell.zh.md)。
diff --git a/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml
index 171f33fe17..feb512101f 100644
--- a/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.md
2026-06-30-event-domain-semantics.md: 70da718b5471ce309a090c8aade3e7290cc949dc
-2026-06-30-event-domain-semantics.zh.md: 6795e1f6f583d6fe7d34efc5e1619da38ab1c89f
+2026-06-30-event-domain-semantics.zh.md: c3b12a167da0a41b792914d82a675a98b3a0b860
diff --git a/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.zh.md b/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.zh.md
index 6795e1f6f5..c3b12a167d 100644
--- a/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.zh.md
+++ b/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.zh.md
@@ -6,7 +6,7 @@ Status: implemented
## 问题
-harness 通过 Cordis 事件分类体系扩展 agent loop(智能体循环)(见[微内核事件分类体系 Agent Note](2026-06-11-microkernel-event-taxonomy.md))。随着该分类体系的增长,三个事件域之间的界限变得模糊:
+harness 通过 Cordis 事件分类体系扩展 agent loop(智能体循环)(见[微内核事件分类体系 Agent Note](2026-06-11-microkernel-event-taxonomy.zh.md))。随着该分类体系的增长,三个事件域之间的界限变得模糊:
- `session/*` 承载持久的、事件溯源的日志(`SessionEventMap`)。
- `agent/*` 承载运行时实时信号,向插件传递 `Agent` 句柄。
@@ -26,14 +26,14 @@ harness 通过 Cordis 事件分类体系扩展 agent loop(智能体循环)
**边界规则:** 持久的、可回放的事实是 `SessionEvent`;实时拦截或瞬态/活对象信号是 `agent`/`tools` Cordis 事件。轮次或步骤边界是持久事实,因此存在于会话日志中并从 `session/event` 源读取——不会被镜像为 `agent/*` emit。
-**将规则应用于边界镜像:** 全部四个边界镜像——`agent/turn-start`、`agent/turn-end`、`agent/step-start`、`agent/step-end`——被**移除**。没有生产消费方需要在边界处获取活的 `Agent`:ACP(Agent Client Protocol)桥接将其进行中的提示词与精确对应的 `session/event` `turn/start`/`turn/end` 事件对关联,其他 transcript 消费方同样从持久流派生边界。见[移除边界镜像事件 Agent Note](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md),该决策由它负责。移除 emit 也简化了循环的 `closeStep`/`closeTurn`(各只需一次 append,无需配对 emit)。
+**将规则应用于边界镜像:** 全部四个边界镜像——`agent/turn-start`、`agent/turn-end`、`agent/step-start`、`agent/step-end`——被**移除**。没有生产消费方需要在边界处获取活的 `Agent`:ACP(Agent Client Protocol)桥接将其进行中的提示词与精确对应的 `session/event` `turn/start`/`turn/end` 事件对关联,其他 transcript 消费方同样从持久流派生边界。见[移除边界镜像事件 Agent Note](../simplification/2026-06-20-remove-agent-boundary-mirror-events.zh.md),该决策由它负责。移除 emit 也简化了循环的 `closeStep`/`closeTurn`(各只需一次 append,无需配对 emit)。
## 后果
- 循环不再 emit 任何边界镜像;`closeStep` 仅追加 `step/end`,`closeTurn` 仅追加 `turn/end`。`Session.append` 负责 post-commit observer 隔离,因此抛出异常的边界 observer 无法改变轮次结果或饿死后续消费方;事件接纳失败或内部校验失败仍会在边界进入日志之前向外抛出。
- 之前通过已移除 emit 观察边界的测试,现在观察持久的 `turn/start`/`turn/end`/`step/start`/`step/end` 会话事件——它们所锁定的行为(边界顺序、步骤计数)不变;只是读取的源移到了规范源。那些测试*抛出异常的轮次边界 emit 监听器*的用例被删除,因为该代码路径不再存在(没有 emit 可供抛出)。按照 [AGENTS.md「测试记录行为,而非黄金真相」](../../../../AGENTS.md),行为与其测试一同迁移(或一同消亡)。
- 循环仅在 `append('step/start')` 返回后才标记步骤已打开(`stepOpen = true`)。内部分发校验在日志推入之前运行,可能在不打开步骤的情况下拒绝;post-commit `session/event` observer 的失败被隔离在 `Session.append` 内部。因此该标记精确表示已提交的、欠一个后续 `step/end` 的边界。
-- 完整实现见[简化 Agent Note「停止将持久边界镜像为 agent 事件」](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md):全部四个边界镜像被移除,所有消费方从 `session/event` 读取边界。`agent/steering`(不是边界镜像)不在该 Agent Note 范围内,由其后续 Agent Note [移除 `agent/steering` 镜像 emit](../../archived/simplification/2026-07-04-remove-agent-steering-mirror.md) 单独移除——它镜像的是持久的中途 steering `user/message`。
+- 完整实现见[简化 Agent Note「停止将持久边界镜像为 agent 事件」](../simplification/2026-06-20-remove-agent-boundary-mirror-events.zh.md):全部四个边界镜像被移除,所有消费方从 `session/event` 读取边界。`agent/steering`(不是边界镜像)不在该 Agent Note 范围内,由其后续 Agent Note [移除 `agent/steering` 镜像 emit](../../archived/simplification/2026-07-04-remove-agent-steering-mirror.md) 单独移除——它镜像的是持久的中途 steering `user/message`。
- 生成的 Cordis 事件表面(`docs/subsystems/` 各页)不再列出镜像事件。
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 fa8401fdc8..933b3d2826 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
@@ -3,4 +3,4 @@
# 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: 52626d3d9200146df95aee7836d37bbaf7e6a9ec
-2026-07-02-tool-render-intent-union.zh.md: 43fda366694aecda07b276d34c882e75d6b4aa95
+2026-07-02-tool-render-intent-union.zh.md: bc91b89c53c3c43b6c483dc0d4876a00f5a936b7
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 43fda36669..bc91b89c53 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
@@ -4,7 +4,7 @@ Status: implemented
[English](2026-07-02-tool-render-intent-union.md) | 中文
-> render-intent 联合类型对 UI 传输层仍然有效;其 ACP(Agent Client Protocol)映射已被 [ACP 作为仅面向自动化的协议](../simplification/2026-07-23-acp-automation-only-protocol.md)取代。
+> render-intent 联合类型对 UI 传输层仍然有效;其 ACP(Agent Client Protocol)映射已被 [ACP 作为仅面向自动化的协议](../simplification/2026-07-23-acp-automation-only-protocol.zh.md)取代。
## 问题
@@ -78,5 +78,5 @@ terminal 意图只用于展示。harness 仍通过自身的 bash 服务执行命
## 相关
- 取代早先被否决的折叠工具自有呈现提案(已否决——「等两个真实工具和两个真实消费方,然后做带标签 render-intent 联合类型」)中的推迟决定。该条件现已满足;本 Agent Note 即为那个联合类型。
-- 被[结果时已应用 hunk 差异](../../archived/architecture/2026-07-02-result-time-applied-hunk-diffs.md)(已归档)扩展:后者添加了一个持久化的 `meta` 通道,使 write/edit 在结果时输出 `DiffResultView`(应用后的变更:带上下文行的 contextual hunk / 每个 `replace_all` 位点一个,或创建时的整文件 diff)——值/呈现拆分与持久化的 `presentationMeta` 通道现由[规范工具输出约定](2026-07-20-canonical-tool-output-contract.md)拥有。
+- 被[结果时已应用 hunk 差异](../../archived/architecture/2026-07-02-result-time-applied-hunk-diffs.md)(已归档)扩展:后者添加了一个持久化的 `meta` 通道,使 write/edit 在结果时输出 `DiffResultView`(应用后的变更:带上下文行的 contextual hunk / 每个 `replace_all` 位点一个,或创建时的整文件 diff)——值/呈现拆分与持久化的 `presentationMeta` 通道现由[规范工具输出约定](2026-07-20-canonical-tool-output-contract.zh.md)拥有。
- 将 `ToolTerminal` 折入当前 UI 传输层使用的带标签 `terminal` 视图。
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 800449486f..13871d1cff 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
@@ -3,4 +3,4 @@
# 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: 9a53619d9510e3f4fa561f8420b2da3bedbbf4bb
-2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md: 6174f277f0a4e8c46ff8d732411f28b4ea97728c
+2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md: dc1164198df0f92b843c75b618f140d8aef86e4f
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 6174f277f0..dc1164198d 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
@@ -40,7 +40,7 @@ Status: implemented
### Subagent 对话历史描述符
-`SubagentProvider.inheritsParentContext` 描述的是对话历史初始化,而非作用域、服务、工具或权限。spawn 和 ACP 将其设为 `false`;fork 设为 `true`。`dsh-tool-subagent` 根据该标志派生工具描述和提示词参数描述,包括 fork 继承已完成轮次但不继承进行中轮次这一点。提供方生命周期事件使该措辞与响应式提供方注册保持同步;其设计动机见[提供方生命周期事件 Agent Note](2026-07-05-subagent-provider-lifecycle-events.md)。
+`SubagentProvider.inheritsParentContext` 描述的是对话历史初始化,而非作用域、服务、工具或权限。spawn 和 ACP 将其设为 `false`;fork 设为 `true`。`dsh-tool-subagent` 根据该标志派生工具描述和提示词参数描述,包括 fork 继承已完成轮次但不继承进行中轮次这一点。提供方生命周期事件使该措辞与响应式提供方注册保持同步;其设计动机见[提供方生命周期事件 Agent Note](2026-07-05-subagent-provider-lifecycle-events.zh.md)。
## 曾考虑的替代方案
@@ -49,7 +49,7 @@ Status: implemented
- **在每个 persona 中手写模型名称**:与上方一行的 `model:` 键重复,配置修改后静默失实;正是本决策要治愈的病症。
- **宽松插值(未知引用保留原样或替换为空)**:一个拼写错误 `{{modle}}`(或一个空洞)会被发送给模型,直到 transcript(文本记录)审查时才会被发现。
- **在配置中为每个 subagent 实例编写措辞**:面向模型的行文回到每个部署 × 实例中,重蹈在 leaf YAML 中手写指导的漂移。**根据提供方名称选择措辞**:`providerName` 本身是配置,重命名提供方后会静默获得错误的措辞。
-- **在 `apply` 时解析提供方(加载顺序要求)**与**仅用 section 承载 subagent 措辞(在 assemble 时惰性解析)**:提供方生命周期事件的替代方案;两者均在[提供方生命周期事件 Agent Note](2026-07-05-subagent-provider-lifecycle-events.md)中被否决。
+- **在 `apply` 时解析提供方(加载顺序要求)**与**仅用 section 承载 subagent 措辞(在 assemble 时惰性解析)**:提供方生命周期事件的替代方案;两者均在[提供方生命周期事件 Agent Note](2026-07-05-subagent-provider-lifecycle-events.zh.md)中被否决。
## 不在范围内
diff --git a/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.i18n.yaml
index 336d9669ed..a66c6f6065 100644
--- a/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.i18n.yaml
@@ -3,4 +3,4 @@
# 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-subagent-provider-lifecycle-events.md
2026-07-05-subagent-provider-lifecycle-events.md: 503c0d638785e3a4944c8903b8d7d469c68b1881
-2026-07-05-subagent-provider-lifecycle-events.zh.md: e7d1d8c398a6d77efb2dbccc365e0040f38f2d33
+2026-07-05-subagent-provider-lifecycle-events.zh.md: d9d15b4ff688506b84b81c8649e1de4ce50e086d
diff --git a/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.zh.md b/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.zh.md
index e7d1d8c398..d9d15b4ff6 100644
--- a/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.zh.md
@@ -6,9 +6,9 @@ Status: implemented
## 问题
-[提示词变量 Agent Note](2026-07-05-prompt-variables-and-tool-guidance-ownership.md) 让 `dsh-tool-subagent` 从其提供方派生面向模型的措辞:`SubagentProvider.inheritsParentContext`(spawn 和 ACP(Agent Client Protocol)为 `false`,fork 为 `true`)同时驱动工具描述和 `prompt` 参数描述,使 fork 工具不再在上下文继承问题上对模型撒谎。这一修复引入了跨 fiber 的数据依赖:工具描述在工具注册时固定(这是有意为之——描述是 tool-choice 引导所在之处),但提供方在自己的插件 fiber 上到达,时机不确定。
+[提示词变量 Agent Note](2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md) 让 `dsh-tool-subagent` 从其提供方派生面向模型的措辞:`SubagentProvider.inheritsParentContext`(spawn 和 ACP(Agent Client Protocol)为 `false`,fork 为 `true`)同时驱动工具描述和 `prompt` 参数描述,使 fork 工具不再在上下文继承问题上对模型撒谎。这一修复引入了跨 fiber 的数据依赖:工具描述在工具注册时固定(这是有意为之——描述是 tool-choice 引导所在之处),但提供方在自己的插件 fiber 上到达,时机不确定。
-如果在工具插件的 `apply` 时刻解析提供方,就会产生一个隐式的加载顺序要求(「在 cordis.yml 中把后端列在工具前面」)。这个要求不成立,因为 Cordis Loader 并发启动同级条目,且 `Entry.init()` 不会等待激活完成:延迟到达的后端即使列在前面,也可能让工具 fiber 失败。Loader 不提供同级顺序保证——「异步状态不是同步状态」(见[防御性模式](../../../../docs/defensive-patterns.md))。
+如果在工具插件的 `apply` 时刻解析提供方,就会产生一个隐式的加载顺序要求(「在 cordis.yml 中把后端列在工具前面」)。这个要求不成立,因为 Cordis Loader 并发启动同级条目,且 `Entry.init()` 不会等待激活完成:延迟到达的后端即使列在前面,也可能让工具 fiber 失败。Loader 不提供同级顺序保证——「异步状态不是同步状态」(见[防御性模式](../../../../docs/defensive-patterns.zh.md))。
## 决策
@@ -31,6 +31,6 @@ Status: implemented
## 后果
- 从命名提供方派生状态的消费方响应 `subagent/provider-added`/`-removed` 事件,而非在 `apply` 时读取注册表;`dsh-tool-subagent` 是参考实现。
-- **添加时大声失败;移除时按监听器隔离。** 添加监听器可以回滚注册。移除在 disposal 期间运行,因此单个监听器抛异常只会被记录日志,不会饿死后续镜像或干扰拆解流程。`start()` 仍在每次运行时按名称解析提供方,防止陈旧工具调用已移除的后端。见[事件目录](../../../../docs/subsystems/subagent.md#cordis-surface)与[生产者/消费方映射](../../../../docs/event-producer-consumer.md)。
+- **添加时大声失败;移除时按监听器隔离。** 添加监听器可以回滚注册。移除在 disposal 期间运行,因此单个监听器抛异常只会被记录日志,不会饿死后续镜像或干扰拆解流程。`start()` 仍在每次运行时按名称解析提供方,防止陈旧工具调用已移除的后端。见[事件目录](../../../../docs/subsystems/subagent.zh.md#cordis-surface)与[生产者/消费方映射](../../../../docs/event-producer-consumer.zh.md)。
- **工具不存在的窗口期。** 在后端 disposal 与重新注册之间(HMR 重载期间),模型看不到 subagent 工具。这是诚实的状态——替代方案是一个向空处分发的工具——工具注册表发出的 `tools/change` 事件会使提示词组装保持最新状态。
- **两个等待中的 fiber 共享同一 `toolName` 是无效配置,被延迟捕获。** 如果两个 `dsh-tool-subagent` 加载实例分别指定了不同的提供方但相同的 `toolName`,两者都会等待,先到达的提供方先注册;第二次注册仅在其提供方到达时才抛异常。插件中的 `TODO(subagent-dup-toolname)` 记录了这一影响范围;工具注册表的重名拒绝机制仍是最终防线。
diff --git a/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.i18n.yaml
index cf8eaf225c..cc278292d9 100644
--- a/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.i18n.yaml
@@ -3,4 +3,4 @@
# 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-windows-jsonl-durable-publish.md
2026-07-05-windows-jsonl-durable-publish.md: 546cde6086f3c84e22b4d4de144a48bb3425cd4e
-2026-07-05-windows-jsonl-durable-publish.zh.md: 205460bcd374bd351cfdf541eb4041c09461229d
+2026-07-05-windows-jsonl-durable-publish.zh.md: b99032e9d7e72e2028f39c5af91659278fd81343
diff --git a/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.zh.md b/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.zh.md
index 205460bcd3..b99032e9d7 100644
--- a/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.zh.md
@@ -28,7 +28,7 @@ Windows 通过持久的暂存发布来创建缺失目录:创建一个以固定
## 影响
-该后端在各平台上维持同一项外部约定:首次追加要么把完整日志发布到最终名称,要么失败且不覆盖已有日志。平台分流只是实现细节;`SessionPersistence` API 和 JSONL 逻辑记录格式均不改变。后续的 [Zstandard 编码决策](2026-07-19-zstandard-jsonl-session-logs.md)会先作用于不透明字节,然后才由任一平台执行发布。
+该后端在各平台上维持同一项外部约定:首次追加要么把完整日志发布到最终名称,要么失败且不覆盖已有日志。平台分流只是实现细节;`SessionPersistence` API 和 JSONL 逻辑记录格式均不改变。后续的 [Zstandard 编码决策](2026-07-19-zstandard-jsonl-session-logs.zh.md)会先作用于不透明字节,然后才由任一平台执行发布。
Windows 测试会在原生 Windows 上执行真实的 Win32 发布路径。断电行为属于 API 约定属性,单元测试无法证明;可测试的不变量包括:Windows 物化不会调用目录 fsync、最终路径冲突会失败、达到最大长度的目标路径组件仍可物化、临时日志在发布前已经执行 fsync,并且生成的日志可以正常加载。
diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml
index e7e49aa115..580e74f217 100644
--- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md
2026-07-06-timeout-deadline-library.md: 38f048d16ecba0e5278ae34b0c88b7889dcfa47a
-2026-07-06-timeout-deadline-library.zh.md: e8b0ab62b19d931c33027d5e6c9919804e80aa99
+2026-07-06-timeout-deadline-library.zh.md: c8d189c2a7588ee57b0f7fe02137b78c9ad7ff9d
diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md
index e8b0ab62b1..c8d189c2a7 100644
--- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md
@@ -103,7 +103,7 @@ export function timeoutOf(x: AbortSignal | { reason?: unknown }, code?: string):
- `AbortSignal.any` 和 `using`/`Symbol.dispose` 在此首次进入本仓库(Node ≥ 24 基线,已满足)。
- 模型流现在共享一个可重启的定时器约定,不会把滑动的空闲间隔变成总调用截止时间,也不会计入消费方思考时间。能够观察到带外传输活动的适配器可以对尚未结算的 demand 调用 `pulse()`;被屏蔽的活动对 watchdog 仍不可见。该原语仍然只做通知;适配器测试证明其传输观察到稳定信号并终止。
-以下内容不在本次范围内,列出以标明边界:`web_search` 可以在其工具 schema 和快照覆盖规划完成后获得可选的面向模型的 `timeout_ms`;基于 ripgrep 的文件系统发现工具([打包的 ripgrep 搜索](2026-08-01-packaged-ripgrep-search.md))通过 `dsh-tool-call-timeout-policy` 和 `exec.signal` 消费同样的提供方自有 deadline 形状;`tools/execute` waterfall(瀑布式事件)中间件可以通过驱动 `exec.signal` 为每次工具调用设置默认 deadline——那将是一个*消费*本库的插件,仍然只做通知,硬终止仍是各能力自己的事。
+以下内容不在本次范围内,列出以标明边界:`web_search` 可以在其工具 schema 和快照覆盖规划完成后获得可选的面向模型的 `timeout_ms`;基于 ripgrep 的文件系统发现工具([打包的 ripgrep 搜索](2026-08-01-packaged-ripgrep-search.zh.md))通过 `dsh-tool-call-timeout-policy` 和 `exec.signal` 消费同样的提供方自有 deadline 形状;`tools/execute` waterfall(瀑布式事件)中间件可以通过驱动 `exec.signal` 为每次工具调用设置默认 deadline——那将是一个*消费*本库的插件,仍然只做通知,硬终止仍是各能力自己的事。
## 曾考虑的替代方案
diff --git a/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.i18n.yaml
index 5e943fed2b..398eaab528 100644
--- a/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.md
2026-07-06-tool-result-retention-library.md: 8d938db8f5fa78398a39e97cc877308200f7d60f
-2026-07-06-tool-result-retention-library.zh.md: 0b2fe841beed21511c4088732713d3fd9fd9048f
+2026-07-06-tool-result-retention-library.zh.md: 747b545ee8c900c13d80c7aef6caecc8ae8dc0ad
diff --git a/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.zh.md b/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.zh.md
index 0b2fe841be..747b545ee8 100644
--- a/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.zh.md
@@ -103,7 +103,7 @@ type TextRetentionStrategy =
`grep` 在分组前使用 `ItemRetainer`,并将其配置为 `{ kind: 'head', maxItems: grepMaxMatches }`。执行器解析 ripgrep 输出、映射路径、应用逐行预览截断,并输入扁平匹配项。调用 `finish()` 后,工具按文件对保留的匹配项分组;如果行内结果达到上限,还可以通过 spill seam 保存完整匹配列表。分组不属于 retainer,因为上限针对匹配总数,而不是文件数;逐匹配项的预览截断和 `incomplete` 也与结果级保留相互独立。
-`bash` 可以使用 `TextRetainer`,配置为 `tail` 或 `headTail`,并读取至进程结束。bash 执行器仍负责 spill 文件、退出状态、信号、超时与后台任务行为;保留辅助函数只在需要该行为时替换临时实现的内存首尾核算。长时间运行任务的所有权与[通用长时间运行工具的运行时](2026-06-20-generic-long-running-tool-runtime.md)相互独立。
+`bash` 可以使用 `TextRetainer`,配置为 `tail` 或 `headTail`,并读取至进程结束。bash 执行器仍负责 spill 文件、退出状态、信号、超时与后台任务行为;保留辅助函数只在需要该行为时替换临时实现的内存首尾核算。长时间运行任务的所有权与[通用长时间运行工具的运行时](2026-06-20-generic-long-running-tool-runtime.zh.md)相互独立。
`web_fetch` 可以使用 `TextRetainer`,配置为 `head` 或 `headTail`;如果提供方必须在内部读取和解码,也可以保留由提供方负责的正文上限。无论采用哪种方式,fetch 结果中的 `truncated` 仍是提供方/工具事实,该库只提供保留文本与省略元数据。
@@ -138,7 +138,7 @@ const formatGrepNotice = (notice: RetentionNotice): string =>
**已交付内容。** `@deepseek-ai/dsh-output-retention` 导出 `ItemRetainer`、`TextRetainer`、结果类型(`RetainedItems`、`RetainedText`)、策略类型(`ItemRetentionStrategy`、`TextRetentionStrategy`)、`Omitted`、`PushDecision`、`RetentionNotice`,以及中性的提示辅助函数 `describeOmitted`/`formatRetentionNotice`,且不依赖 Cordis 或任何工具包。单元测试覆盖具有精确省略计数的条目头部保留、文本头部保留、文本尾部保留、首尾字节保留、零预算、UTF-8 边界处理(2、3、4 字节码位,以及每个裁切位置上的无效起始字节)和未知省略量的措辞。
-**已记录但尚未迁移的内容。** `glob`、`grep`、`bash`、`web_fetch` 与 `web_search` 的映射已记录在[包 README](../../../../packages/util/output-retention/README.md) 中,但本次改动并未把每个工具都迁移到该库;迁移工作刻意留作独立的后续任务。`read` 被明确记录为不在范围内:其 `read-render` 行窗口约定(`offset`/`limit`、`totalLines`、offset 范围错误、逐行预览截断,以及针对所选窗口的字节上限)不属于通用保留,而一个 `Omitted` 计数也无法同时表达行窗口两侧。
+**已记录但尚未迁移的内容。** `glob`、`grep`、`bash`、`web_fetch` 与 `web_search` 的映射已记录在[包 README](../../../../packages/util/output-retention/README.zh.md) 中,但本次改动并未把每个工具都迁移到该库;迁移工作刻意留作独立的后续任务。`read` 被明确记录为不在范围内:其 `read-render` 行窗口约定(`offset`/`limit`、`totalLines`、offset 范围错误、逐行预览截断,以及针对所选窗口的字节上限)不属于通用保留,而一个 `Omitted` 计数也无法同时表达行窗口两侧。
**该库维持的边界。** `truncated` 表示 retainer 因预算省略了原本可用的内容,绝不表示上游不完整。工具专用状态,包括 `incomplete`、权限失败、提供方局部失败、跳过二进制文件、bash spill 路径恢复和无效 UTF-8,均留在工具领域字段中、位于 retainer 之外。未来改动迁移某项工具时,该包的 README 与测试必须证明,除了有意改变的提示措辞外,模型可见的结果文本没有变化。
@@ -152,6 +152,6 @@ const formatGrepNotice = (notice: RetentionNotice): string =>
**把 `read` 窗口交给 `ItemRetainer`。** v1 不予采纳:`read` 是当前唯一的窗口消费方,其语义属于文件分页,而不是通用保留。一个 `Omitted` 计数无法表示行窗口两侧,而且 `read` 还携带 `totalLines`、offset 范围错误、逐行预览截断和针对所选输出的字节上限。让 `read-render` 由工具所有,可以避免共享库围绕一项特例膨胀。
-**让截断成为 `ToolExecutionResult` 的一部分。** 不予采纳:工具注册表将不得不理解工具专用的恢复指引、分组、行号、退出状态和提供方语义。保留是由工具的 Native renderer(原生渲染器)使用的库;模型可见投影继续由工具所有,而[规范值](2026-07-20-canonical-tool-output-contract.md)可以保留完整的已采集结果。
+**让截断成为 `ToolExecutionResult` 的一部分。** 不予采纳:工具注册表将不得不理解工具专用的恢复指引、分组、行号、退出状态和提供方语义。保留是由工具的 Native renderer(原生渲染器)使用的库;模型可见投影继续由工具所有,而[规范值](2026-07-20-canonical-tool-output-contract.zh.md)可以保留完整的已采集结果。
**在每个面向模型的工具 schema 中公开上限。** 不作为默认方案:Claude Code 的 grep 公开 `head_limit`/`offset`,但本 harness 会把常规预算保留为部署配置,除非模型确实需要控制分页。未来可以为具体工具增加类似 read 的续传字段;它不属于共享保留原语。
diff --git a/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.i18n.yaml
index 10a29ee499..373b1293ce 100644
--- a/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md
2026-07-07-tool-call-timeout-policy.md: 92618cc8c761b38d7e516c9d00eb3de1c37831a8
-2026-07-07-tool-call-timeout-policy.zh.md: cc633ceaa3840331826f6475e603e78a98f0afd2
+2026-07-07-tool-call-timeout-policy.zh.md: 0303fadd134c6eb8c41449823b62f1458f4523ba
diff --git a/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md b/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md
index cc633ceaa3..0303fadd13 100644
--- a/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md
@@ -6,7 +6,7 @@ Status: implemented
## 问题
-[超时/截止时间 Agent Note](2026-07-06-timeout-deadline-library.md) 将计时与分类原语提取到了 `@deepseek-ai/dsh-timeout`,但超时策略仍然附着在各个能力和面向模型的 schema 上。`bash` 暴露了 `timeoutMs`;`web_fetch` 暴露了 `timeout_ms`;`web_search` 没有面向模型的超时参数,尽管提供方已经遵循 `exec.signal`;未来的 grep/glob 工具要么直接导入超时库,要么自行发明超时策略。对于一个插件 SDK 来说,这是错误的编写范式:工具作者通常只需将 `exec.signal` 转发给其调用的实现,而部署策略来决定预算。
+[超时/截止时间 Agent Note](2026-07-06-timeout-deadline-library.zh.md) 将计时与分类原语提取到了 `@deepseek-ai/dsh-timeout`,但超时策略仍然附着在各个能力和面向模型的 schema 上。`bash` 暴露了 `timeoutMs`;`web_fetch` 暴露了 `timeout_ms`;`web_search` 没有面向模型的超时参数,尽管提供方已经遵循 `exec.signal`;未来的 grep/glob 工具要么直接导入超时库,要么自行发明超时策略。对于一个插件 SDK 来说,这是错误的编写范式:工具作者通常只需将 `exec.signal` 转发给其调用的实现,而部署策略来决定预算。
与此同时,仓库中并非所有超时都是面向模型的工具调用预算。钩子通过直接调用 `ctx.shell` 执行命令钩子,而非通过 `ctx.tools.execute()`;`bash` 模型工具通过同一个后端复用前台执行、后台启动、后台轮询和钩子复用。一步到位地将所有超时移入工具插件会混淆这些路径,并有破坏钩子超时语义的风险。
@@ -16,7 +16,7 @@ Status: implemented
- `@deepseek-ai/dsh-timeout` 仍是拥有 `deadline()` 和 `timeoutOf()` 的共享库。
- `@deepseek-ai/dsh-tools` 在 `tools/pre-execute` 和 `tools/post-execute` 之间有一个环绕分发的 waterfall(瀑布式事件)`tools/execute`。
-- [仓库命名约定](2026-08-11-repository-naming-contract-and-rename-ledger.md)使用 `@deepseek-ai/dsh-tool-call-timeout-policy`,准确说明该策略所限制的操作。插件从 runtime 读取每个工具声明的 `timeoutMs`,并通过派生新的 `exec.signal` 来包装有此声明的调用。
+- [仓库命名约定](2026-08-11-repository-naming-contract-and-rename-ledger.zh.md)使用 `@deepseek-ai/dsh-tool-call-timeout-policy`,准确说明该策略所限制的操作。插件从 runtime 读取每个工具声明的 `timeoutMs`,并通过派生新的 `exec.signal` 来包装有此声明的调用。
执行流水线如下:
@@ -52,7 +52,7 @@ catch 是基础 `next`(而非 waterfall 之外的东西)这一点至关重
searchTimeoutMs: 30000
```
-超时放在工具定义上而非自由文本名称映射中,消除了拼错名称导致策略不生效的问题。`defineTool` 校验预算为正有限数。分发期间,执行器派生截止信号并将其赋给 `exec.signal`;注册表依据[工具取消约定](2026-07-19-cooperative-tool-cancellation.md),在执行工具体之前将该截止信号与调用方的原始信号融合。执行器随后恢复调用方信号,并将自身的超时转换为 `TOOL_TIMEOUT`;没有预算的工具原样通过。
+超时放在工具定义上而非自由文本名称映射中,消除了拼错名称导致策略不生效的问题。`defineTool` 校验预算为正有限数。分发期间,执行器派生截止信号并将其赋给 `exec.signal`;注册表依据[工具取消约定](2026-07-19-cooperative-tool-cancellation.zh.md),在执行工具体之前将该截止信号与调用方的原始信号融合。执行器随后恢复调用方信号,并将自身的超时转换为 `TOOL_TIMEOUT`;没有预算的工具原样通过。
信号替换采用**就地修改 `exec.signal`** 的方式,而非向 `next()` 传递新对象。Cordis 的 waterfall `next()` 忽略传入的任何参数,并以共享的 payload 数组重新调用下游监听器(`vendor/cordis/src/events.ts`),因此修改共享对象是包装器向注册表提供截止信号的方式。注册表会在进入工具体前再次融合已捕获的调用方信号;插件则在 `finally` 中将 `exec.signal` 恢复为调用方的原始值,使 `tools/post-execute` 永远不会看到本插件的截止信号。
diff --git a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml
index efed9ca5ea..20d0e4f85c 100644
--- a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md
2026-07-08-agent-scope-contexts.md: eb3f6f247bac1a1d81aa2644132c7b9cc04d602c
-2026-07-08-agent-scope-contexts.zh.md: fe82ac18d07de97330e86461e6bdf86edc37d2ae
+2026-07-08-agent-scope-contexts.zh.md: a0f4ffb0ef80dd2fc1ee61c9ab3f4730c28c687e
diff --git a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md
index fe82ac18d0..a0f4ffb0ef 100644
--- a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md
@@ -16,7 +16,7 @@ Status: implemented
每个存活的 agent 拥有一个扁平的注册层,通过 `agent.ctx` 暴露。代码通过拥有某项贡献的上下文进行注册;具备作用域感知的服务将部署全局注册与恰好一个匹配的 agent 层合并;操作从其真实 agent 选择该层;该层在 agent 的完整发布生命周期内存在。
-Cordis 是 SDK 底层的插件框架。Cordis **上下文**是插件用来访问服务和注册效果的对象,效果的清理跟随该上下文。[Cordis 入门](../../../../docs/cordis-primer.md)对该框架有更详细的说明。
+Cordis 是 SDK 底层的插件框架。Cordis **上下文**是插件用来访问服务和注册效果的对象,效果的清理跟随该上下文。[Cordis 入门](../../../../docs/cordis-primer.zh.md)对该框架有更详细的说明。
对大多数贡献者而言,完整约定是四条规则:
@@ -45,7 +45,7 @@ flowchart LR
缺失的交叉边即隔离规则:Agent A 的本地注册不会进入 Agent B 的视图,父级的注册也不会仅因父级拥有子级的生命周期就进入子级。
-配套的[运行时设计 Agent Note](2026-07-12-agent-scope-runtime-design.md) 阐述了实现与正确性推理。[subagent 组合控制 Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) 负责独立的 `persona`、`toolFilter` 和 `maxDepth` 功能。
+配套的[运行时设计 Agent Note](2026-07-12-agent-scope-runtime-design.zh.md) 阐述了实现与正确性推理。[subagent 组合控制 Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md) 负责独立的 `persona`、`toolFilter` 和 `maxDepth` 功能。
### 注册来源决定可见性与清理
@@ -104,7 +104,7 @@ setup 接收一个完整的受信 Cordis 上下文,因此可以组合普通插
在 Cordis 层面,`Scoped` 是一个不透明的路由接收器。它携带用于选择监听器的过滤器,但本身不是领域对象。因此事件签名将真实的 `Agent`、工具执行、审批请求或其他主体作为显式参数保留,供监听器检查。
-以 `{ global: true }` 注册的监听器有意绕过上下文受众过滤,但其清理仍跟随注册上下文。注册表成员变更通知保持不过滤,因为它们描述的是共享注册表状态而非某个 agent 的操作。详尽的事件参考是各[子系统页面](../../../../docs/subsystems/core.md)上生成的 `cordis-surface` 区块的集合——每个事件作用域在其所属页面上(`agent/*` 与 `agent-loop/*` 在 core.md 本页)。
+以 `{ global: true }` 注册的监听器有意绕过上下文受众过滤,但其清理仍跟随注册上下文。注册表成员变更通知保持不过滤,因为它们描述的是共享注册表状态而非某个 agent 的操作。详尽的事件参考是各[子系统页面](../../../../docs/subsystems/core.zh.md)上生成的 `cordis-surface` 区块的集合——每个事件作用域在其所属页面上(`agent/*` 与 `agent-loop/*` 在 core.md 本页)。
### 创建最后发布,dispose 最后撤销
@@ -138,6 +138,8 @@ flowchart TB
detach --> revoke["Dispose the agent scope"]
```
+
+
## 安全与权限是非目标
agent 作用域组合的是受信的同进程注册。它不沙箱化插件、不定义父到子的权限格、不在创建时冻结授权、也不保证子级不能做超出父级的事。
@@ -146,6 +148,8 @@ agent 作用域组合的是受信的同进程注册。它不沙箱化插件、
需要非升权保证的部署需要独立的权限表示、传播规则和执行检查。父级子集授权、创建时授权快照、显式未来授权 API,以及通用的能力/输出/终止标签均不在本决策范围内。
+
+
## 曾考虑的替代方案
被否决的设计要么将可见性与清理分离,要么只覆盖一类注册,要么重复共享基础设施,要么将生命周期所有权与继承混为一谈。
diff --git a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml
index 5f9f8ba421..15abdc91f0 100644
--- a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md
2026-07-08-tool-output-spill-files.md: 4d1d4b7b665f34b362df2f8c8aeb06d96bf2668f
-2026-07-08-tool-output-spill-files.zh.md: 1a3387830861c6d05318024870325523b8eeee36
+2026-07-08-tool-output-spill-files.zh.md: 3e7ce4f57a0078c6a4b919436946be8e172fa7cb
diff --git a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md
index 1a33878308..3e7ce4f57a 100644
--- a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md
@@ -8,9 +8,9 @@ Status: implemented
工具输出需要有界的模型可见预览,但部分超大结果仍可能在之后有用。抓取的页面正文或冗长的工具响应不应完整占用下一次模型请求,但模型应能使用现有文件读取工具,在之后查看经过格式化的完整结果。
-这项改动之前的行为并不一致。`dsh-bash-local` 已经会在内存尾部溢出时,把完整 stdout/stderr 流写入私有的临时 spill 文件;普通文本工具结果则仍以内联形式返回,除非工具自行实现上限。[工具结果保留库](2026-07-06-tool-result-retention-library.md)负责预览机制,但不负责存储,也不负责把这些机制应用于最终工具结果的执行流水线策略。
+这项改动之前的行为并不一致。`dsh-bash-local` 已经会在内存尾部溢出时,把完整 stdout/stderr 流写入私有的临时 spill 文件;普通文本工具结果则仍以内联形式返回,除非工具自行实现上限。[工具结果保留库](2026-07-06-tool-result-retention-library.zh.md)负责预览机制,但不负责存储,也不负责把这些机制应用于最终工具结果的执行流水线策略。
-其形态与超时策略设计一致:工具作者声明规范值与 Native renderer(原生渲染器),由策略插件在渲染后的内容上执行部署默认的上下文预算。工具仍可在提供方采集上限处提前 spill;由工具负责的展示 spill 可以保留已完整采集的规范值,而只替换展示内容。[规范工具输出约定](2026-07-20-canonical-tool-output-contract.md)规定了这项区分。
+其形态与超时策略设计一致:工具作者声明规范值与 Native renderer(原生渲染器),由策略插件在渲染后的内容上执行部署默认的上下文预算。工具仍可在提供方采集上限处提前 spill;由工具负责的展示 spill 可以保留已完整采集的规范值,而只替换展示内容。[规范工具输出约定](2026-07-20-canonical-tool-output-contract.zh.md)规定了这项区分。
## 决策
diff --git a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml
index 0321d494a0..ec2f5318d6 100644
--- a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md
2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md: 89c8917484c11057ff2e6b55e2311e83f724f7ed
-2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md: 96f7aa1b0ea4e44ca6318caca2c785d898b1d00d
+2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md: 48f954f2c4720894548ecab387965d3a3d1799b0
diff --git a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md
index 96f7aa1b0e..48f954f2c4 100644
--- a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md
@@ -22,9 +22,9 @@ Compact-basic 会在每个拟议请求之前包装 `agent/pre-step`。在续步
### 请求恢复只覆盖最终模型边界
-`agent/request-error` 表示来自最终适配器边界的终止失败。适配器选择、分发、iterator 构造与迭代抛出会在 agent loop(智能体循环)消费前成为终止 `error` 或 `aborted` finish;适配器直接发出的终止 finish 进入同一路径。提示词装配、请求 middleware、请求日志、结果处理、工具、step 监听器与清理仍属于普通失败。[LLM(大语言模型)流的终止失败](2026-07-29-terminal-llm-stream-failures.md)规定这一规范化边界。
+`agent/request-error` 表示来自最终适配器边界的终止失败。适配器选择、分发、iterator 构造与迭代抛出会在 agent loop(智能体循环)消费前成为终止 `error` 或 `aborted` finish;适配器直接发出的终止 finish 进入同一路径。提示词装配、请求 middleware、请求日志、结果处理、工具、step 监听器与清理仍属于普通失败。[LLM(大语言模型)流的终止失败](2026-07-29-terminal-llm-stream-failures.zh.md)规定这一规范化边界。
-恢复运行前,失败 step 已经关闭。负责处理的监听器修复持久状态、返回 `{ kind: 'retry' }`,并停止 waterfall(瀑布式事件)委托。循环随后关闭失败 turn,并从持久日志开启一个重试 turn,中间不发布空闲通知。重试策略与尝试计数由插件自己拥有;compaction-basic 在链路到达终态 `agent/settled` 时清除对应 agent 的溢出计数。两个 DeepSeek 适配器都把识别出的提供方上下文限制错误规范化为 `CONTEXT_WINDOW_EXCEEDED`。[重试动作决策](../simplification/2026-07-27-request-error-retry-action.md)规定这一返回边界。
+恢复运行前,失败 step 已经关闭。负责处理的监听器修复持久状态、返回 `{ kind: 'retry' }`,并停止 waterfall(瀑布式事件)委托。循环随后关闭失败 turn,并从持久日志开启一个重试 turn,中间不发布空闲通知。重试策略与尝试计数由插件自己拥有;compaction-basic 在链路到达终态 `agent/settled` 时清除对应 agent 的溢出计数。两个 DeepSeek 适配器都把识别出的提供方上下文限制错误规范化为 `CONTEXT_WINDOW_EXCEEDED`。[重试动作决策](../simplification/2026-07-27-request-error-retry-action.zh.md)规定这一返回边界。
如果取消发生在 assistant 工具调用已经持久化之后、所有调用完成分发之前,循环会为每个尚未分发的调用记录一对合成的 `tool/call` 与 aborted `tool/result`,随后进入正常中止路径。因此,表层不会仅因取消赢得竞态而留下孤立的持久工具调用。
@@ -58,4 +58,4 @@ Compact-basic 会在每个拟议请求之前包装 `agent/pre-step`。在续步
代价是在共享 pre-step waterfall 中执行压力工作,并需要适配器持续维护溢出分类。提供方措辞与启发式字符密度仍是维护风险。表层压缩依然无法修复仅信封本身就超出窗口的情况,也不能拆分不可分割的非工具节点,或修复不可剪枝的剩余部分仍然过大的工具单元。若可移除的文本工具结果是主要体积,可选剪枝器仍可修复原本不可分割的工具配对。
-[已领取 pre-step 生命周期](2026-07-31-claimed-pre-step-inbox-lifecycle.md)取代了本记录原先的 post-step 触发方式。服务拆分、独立 token meter、平衡范围约定、日志中记录的锁、摘要替换与唯一 `summarize()` 子类 hook 均保持不变。
+[已领取 pre-step 生命周期](2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md)取代了本记录原先的 post-step 触发方式。服务拆分、独立 token meter、平衡范围约定、日志中记录的锁、摘要替换与唯一 `summarize()` 子类 hook 均保持不变。
diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml
index 21c607072c..b8bcc4246e 100644
--- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md
2026-07-10-single-file-executable-sdk-runtime-distribution.md: 40433d99e5d1aa569c3fdf094a280d3de62ad588
-2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 54030fa4b0742fbc282bc327b0ca22747e6a20bd
+2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: cff72ae10eb82c65c499123cc559cc6ad7e440ab
diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md
index 54030fa4b0..cff72ae10e 100644
--- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md
@@ -27,8 +27,8 @@ exe 使用 [@yao-pkg/pkg](https://github.com/yao-pkg/pkg)(vercel/pkg 归档后
确定性协议实现(`server.ts` / `transport.ts`)按 `acp/acp` + `examples/acp-demo` 的既有模式落为两包——对外服务接口本身也是插件:
-- [`packages/sdk/server`](../../../../packages/sdk/server/README.md)(`@deepseek-ai/dsh-sdk-jsonrpc-server`):纯协议插件;执行 `apply` 时,在进程 stdio 上挂载 `HarnessSdkJsonRpcServer` 与按行分隔的 JSON-RPC 传输层,资源释放走 `ctx.effect()`。是否提供服务由 `cordis.yml` 决定;未挂载该插件的配置会启动一个不提供此服务的合法进程。协议级退出归插件所有(应答并确保 `shutdown` 响应发送完毕后,对根运行时执行 dispose(资源释放),让待处理的持久化操作完成,再调用 `exit(0)`;HMR(热模块替换)式卸载只停止服务,不退出进程)。
-- [`packages/examples/jsonrpc-demo`](../../../../packages/examples/jsonrpc-demo/README.md)(`@deepseek-ai/dsh-sdk-jsonrpc-demo`):轻量应用入口——`installFailLoud` + `loadEnv` + 配置发现 + [`dsh-app-boot`](../../../../packages/boot/app-boot/src/index.ts) 的 `boot()`;`boot()` 完成后入口即完成,服务器由 `cordis.yml` 中的 `dsh-sdk-jsonrpc-server` 条目启动。它只依赖 `app-boot`。进程级退出归 `bin` 所有(stdin EOF/SIGTERM → dispose 后返回 0,SIGINT → 130)。
+- [`packages/sdk/server`](../../../../packages/sdk/server/README.zh.md)(`@deepseek-ai/dsh-sdk-jsonrpc-server`):纯协议插件;执行 `apply` 时,在进程 stdio 上挂载 `HarnessSdkJsonRpcServer` 与按行分隔的 JSON-RPC 传输层,资源释放走 `ctx.effect()`。是否提供服务由 `cordis.yml` 决定;未挂载该插件的配置会启动一个不提供此服务的合法进程。协议级退出归插件所有(应答并确保 `shutdown` 响应发送完毕后,对根运行时执行 dispose(资源释放),让待处理的持久化操作完成,再调用 `exit(0)`;HMR(热模块替换)式卸载只停止服务,不退出进程)。
+- [`packages/examples/jsonrpc-demo`](../../../../packages/examples/jsonrpc-demo/README.zh.md)(`@deepseek-ai/dsh-sdk-jsonrpc-demo`):轻量应用入口——`installFailLoud` + `loadEnv` + 配置发现 + [`dsh-app-boot`](../../../../packages/boot/app-boot/src/index.ts) 的 `boot()`;`boot()` 完成后入口即完成,服务器由 `cordis.yml` 中的 `dsh-sdk-jsonrpc-server` 条目启动。它只依赖 `app-boot`。进程级退出归 `bin` 所有(stdin EOF/SIGTERM → dispose 后返回 0,SIGINT → 130)。
配置发现有两个通道,均缺失时立即报错:优先使用 `DSH_CORDIS_CONFIG` 环境变量(SDK 客户端约定),其次使用 argv 位置参数;没有默认路径或内置回退——「实际启动的插件由外部 `cordis.yml` 决定」是硬语义。
@@ -44,11 +44,11 @@ exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真
[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 恢复被 legacy deploy 提升回源 manifest 的 `node_modules` 下的任何直接工作区包,同时省略其包内依赖树,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的每个符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg--` 写入 `dist-exe/`,并拷回运行时目录。Linux 安装会从源码构建 `pty.node`;CI 会在打包前进入匹配架构的 manylinux 2.28 容器重新构建该 addon,而 `--legacy` 部署会省略这一副作用目录,因此构建器会把它从根安装目录复制到暂存闭包。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` 伴随文件;pkg 运行时通过 `process.pkg` 选择该伴随文件,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。CI 将这些产物作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。
-CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml):[必需的 Python 运行时拉取请求验证](../testing/2026-08-12-required-python-runtime-pull-request-ci.md)调用它构建 linux-x64,手动派发 `workflow_dispatch` 或 PR(Pull Request)的 `build-exe` 标签可以显式选择构建目标,[公开发布工作流](../process/2026-08-11-python-publication-workflow.md)则调用它构建全部目标。linux-x64、linux-arm64(`ubuntu-24.04-arm`)和 macos-arm64 三个平台分别进行原生构建,并缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都使用 mock SSE(Server-Sent Events)模型,分别通过默认配置和自定义 `cordis.yml` 驱动 SDK,再通过 NDJSON JSON-RPC 直接驱动 exe,校验 JSONL 与最终响应;最后把发布形态的 wheel 包安装到干净的 venv 中,并在不传 `runtime_bin` 的情况下运行。Linux 还会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并在 manylinux 2.28 容器中运行;macOS 则验证可执行文件的部署目标符合 wheel 包标签。完整构建三个目标时保留 4 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 3 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v` 标签流水线,构建一个 SDK wheel 包和 3 个原生运行时 wheel 包,再由单个串行任务校验并将这 4 个文件发布到项目的 PyPI 注册表。Windows 不在目标范围内。
+CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml):[必需的 Python 运行时拉取请求验证](../testing/2026-08-12-required-python-runtime-pull-request-ci.zh.md)调用它构建 linux-x64,手动派发 `workflow_dispatch` 或 PR(Pull Request)的 `build-exe` 标签可以显式选择构建目标,[公开发布工作流](../process/2026-08-11-python-publication-workflow.zh.md)则调用它构建全部目标。linux-x64、linux-arm64(`ubuntu-24.04-arm`)和 macos-arm64 三个平台分别进行原生构建,并缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都使用 mock SSE(Server-Sent Events)模型,分别通过默认配置和自定义 `cordis.yml` 驱动 SDK,再通过 NDJSON JSON-RPC 直接驱动 exe,校验 JSONL 与最终响应;最后把发布形态的 wheel 包安装到干净的 venv 中,并在不传 `runtime_bin` 的情况下运行。Linux 还会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并在 manylinux 2.28 容器中运行;macOS 则验证可执行文件的部署目标符合 wheel 包标签。完整构建三个目标时保留 4 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 3 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v` 标签流水线,构建一个 SDK wheel 包和 3 个原生运行时 wheel 包,再由单个串行任务校验并将这 4 个文件发布到项目的 PyPI 注册表。Windows 不在目标范围内。
### Python SDK 分发:双载体,exe 用于生产,`node` 用于开发
-Python SDK 位于 [`python/`](../../../../python/README.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含检入的默认 `runtime/cordis.yml`、构建注入的平台 exe 及其必需的 `-rg` 伴随文件和可选的 macOS helper,以及构建注入的 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 的自动解析**只查找 exe**;`node` 载体仅在显式设置 `DSH_RUNTIME_MODE=node` 时启用(运行 `runtime/node/node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js`,需要系统 Node ≥22.19),定位为本仓库成员的开发验证通道,不随 wheel 包分发。
+Python SDK 位于 [`python/`](../../../../python/README.zh.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含检入的默认 `runtime/cordis.yml`、构建注入的平台 exe 及其必需的 `-rg` 伴随文件和可选的 macOS helper,以及构建注入的 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 的自动解析**只查找 exe**;`node` 载体仅在显式设置 `DSH_RUNTIME_MODE=node` 时启用(运行 `runtime/node/node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js`,需要系统 Node ≥22.19),定位为本仓库成员的开发验证通道,不随 wheel 包分发。
[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的 `X.Y.Z` 或预发布版本,把预发布版本转换为 PEP 440 写法,并以该 wheel 包版本暂存两个包,让 `deepseek-harness-sdk` 精确依赖匹配版本的 `deepseek-harness-runtime-bin`。可选的 `python-v` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。暂存过程还会把仓库许可证放入两个 wheel 包,并把第三方声明放入内置运行时 wheel 包。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe 及其架构匹配的 `-rg` 伴随文件,macOS wheel 包还包含与其架构匹配的 spawn helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64`,或针对 Node 24 可执行文件 macOS 13.5 部署目标而保守选择的 `py3-none-macosx_14_0_arm64` 标签;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、伴随文件缺失或多余,以及不支持的平台。
diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml
index 02aeec5ad6..7d8d2db1cf 100644
--- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md
2026-07-12-agent-scope-runtime-design.md: 9d70b8048b1d2bb50290d34158d9deb329d5e15e
-2026-07-12-agent-scope-runtime-design.zh.md: a505f5337559294795c1080937c6079cee235baa
+2026-07-12-agent-scope-runtime-design.zh.md: 278bcede47fee9f67d3d2d2d7135e5357c120161
diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md
index a505f53375..278bcede47 100644
--- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md
@@ -6,7 +6,7 @@ Status: implemented
## 问题
-[agent(智能体)作用域约定](2026-07-08-agent-scope-contexts.md)对贡献者而言很简单:通过 `agent.ctx` 注册,解析出一个全局加单 agent 的视图,仅在 setup 完成后发布,并保持作用域直到工作停止。运行时必须在协作式插件框架、异步创建、可重入监听器、持久化会话提交以及 worker 或进程故障等场景下维护这份约定。
+[agent(智能体)作用域约定](2026-07-08-agent-scope-contexts.zh.md)对贡献者而言很简单:通过 `agent.ctx` 注册,解析出一个全局加单 agent 的视图,仅在 setup 完成后发布,并保持作用域直到工作停止。运行时必须在协作式插件框架、异步创建、可重入监听器、持久化会话提交以及 worker 或进程故障等场景下维护这份约定。
主要的设计风险是为每个竞态条件引入第二套机制。独立的预留、就绪哨兵、取消中继、快照层和保护注册表可能镜像同一个事实,直到没有读者能分辨哪个才是权威的。这些机制还会诱使运行时把可信的类型化调用当作敌对的序列化边界来处理。
@@ -30,7 +30,7 @@ Status: implemented
本 Agent Note 余下部分按依赖顺序展开这些选择:Cordis 机制、作用域路由、创建与会话提交、工具与提示词、subagent 与工作流,最后是可执行检查。
-[7 月 8 日 Agent Note](2026-07-08-agent-scope-contexts.md) 仍然是贡献者约定。独立的 [subagent 组合控制 Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) 拥有 `persona`、`toolFilter` 和 `maxDepth`;本文仅讨论它们的 setup 如何融入生命周期。
+[7 月 8 日 Agent Note](2026-07-08-agent-scope-contexts.zh.md) 仍然是贡献者约定。独立的 [subagent 组合控制 Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md) 拥有 `persona`、`toolFilter` 和 `maxDepth`;本文仅讨论它们的 setup 如何融入生命周期。
## Cordis 模型:上下文、fiber、effect、receiver 与 waterfall
@@ -58,6 +58,8 @@ Cordis 使用 dispatch receiver(`this`)过滤监听器,而 harness 的监
Cordis waterfall 是中间件风格的 dispatch。每个监听器接收 `next()`:调用它则委托给剩余监听器和基础操作,不调用则短路或替换下游结果。Waterfall 驱动提示词组装和工具策略;普通 emit 事件同步通知,parallel 事件等待所有监听器但没有否决结果。
+
+
## 作用域路由:一个不透明键选择一层
scope 包实现了 Cordis 路由所需的最小对象。其载体仅持有一个组合的服务过滤器和作用域谓词,而包私有地记录不透明键,并单独暴露会等待作用域 fiber 完全停稳的 disposer。
@@ -72,7 +74,7 @@ Receiver 是一个小型载体而非领域对象的透明代理。需要 agent
### 注册表读取叠加一个精确 layer
-作用域感知的注册表使用 `ScopedLayers`,拥有一个即时创建的全局 aggregate 和按标识键惰性创建的 aggregate。读取解析全局 layer 和至多一个精确局部 layer;它不创建状态,也从不遍历父级链。注册可见性与 Cordis effect 所有权都从同一个上下文派生,而回收会等待具体 layer 的完整 aggregate 变空(见[决策](2026-07-12-scoped-layers-store.md))。
+作用域感知的注册表使用 `ScopedLayers`,拥有一个即时创建的全局 aggregate 和按标识键惰性创建的 aggregate。读取解析全局 layer 和至多一个精确局部 layer;它不创建状态,也从不遍历父级链。注册可见性与 Cordis effect 所有权都从同一个上下文派生,而回收会等待具体 layer 的完整 aggregate 变空(见[决策](2026-07-12-scoped-layers-store.zh.md))。
每个服务保留其领域规则。命名 command 和提示词视图使用共享的、保持插入顺序的 shadow 合并;工具保留更丰富的 resolver,因为限制会在加入局部工具前过滤全局工具,保留的 Code Mode transport 则单独插入。提示词变量和工具 guard 保持实时迭代,而工具提供方成员关系按每次 assembly 物化。Scope 提供存储生命周期和命名遮蔽,而非通用的注册表视图。
@@ -158,6 +160,8 @@ sequenceDiagram
此顺序让最终的 agent 和会话事件能使用匹配的作用域监听器,并使持久化观察者在最终刷新完成前保持附加。作用域 dispose 放在最后,因为注册撤销是外部可见的生命期边界。
+
+
## 会话追加:物化、验证、提交、通知
会话事件跨越持久化边界,因此追加操作拥有其数据。算法的其余部分使用一条已附加的注册表条目和一个提交点。
@@ -208,7 +212,7 @@ Session 头部、种子和追加的事件是无损 JSON 数据。Session 构造
私有解析器应用当前展示模式、活跃的全局限制、精确的局部叠加和局部遮蔽。Schema、查找、执行、Code Mode SDK 生成和限制验证都使用该解析器或其限制前的全局名称视图。
-[subagent 组合控制 Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md#tool-filtering-is-one-live-global-view-rule) 拥有用户可见的 allow/deny 语义。实现要求是一致性:被过滤掉的全局工具不能通过另一条查找路径仍可执行,局部遮蔽的定义就是被展示和执行的同一个定义。
+[subagent 组合控制 Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md#tool-filtering-is-one-live-global-view-rule) 拥有用户可见的 allow/deny 语义。实现要求是一致性:被过滤掉的全局工具不能通过另一条查找路径仍可执行,局部遮蔽的定义就是被展示和执行的同一个定义。
`ToolRestriction` 接受 readonly 的 allow/deny 名称并将其编译为内部集合。多个限制取交集。公开的 `visible()` 和 `knownNames()` 方法是不必要的,因为只有注册表需要中间视图。
@@ -230,6 +234,8 @@ SystemPrompt 首先将全局加 agent 的段、变量和工具提供方解析为
Scope 直接解决了真正的隔离问题。结构化输出贡献注册在子级的精确作用域中,而 Code Mode 从同一个已解析的工具视图派生其传输和 SDK。第二套命名保护系统需要另一套所有权和碰撞规则来覆盖任意 schema 提供方(包括有意贡献重复名称的提供方),却不创建新的信任边界。
+
+
### 结构化输出仅提交权威结果
结构化输出将子作用域组合与两阶段执行提交相结合。子级在发布前注册其 `structured_output` 工具和指令;可信的 assembly 监听器可以变换这些普通贡献,并有责任在期望子级完成时保持协议。工具体验证候选值并按当前 `ToolExecution` 暂存,但成功捕获仅由不可变的 `tools/result` 观察决定。
@@ -242,6 +248,8 @@ Scope 直接解决了真正的隔离问题。结构化输出贡献注册在子
纯 Code Mode 的注册表贡献从原生 wire schema 中省略 `structured_output`,并通过生成的 SDK 暴露它。Assembly waterfall 可以有意改变该展示;执行仍然针对子作用域定义进行验证,监听器拥有其创建的任何替代模型可见路由的一致性。
+
+
### 三个执行边界有意设为单向
提示词组装有意是协作式的,但三个执行事实在其可扩展阶段之后需要单向结算:
@@ -266,7 +274,7 @@ subagent 启动有一次所有权转移。提供方拥有未发布资源,直
### 服务约定有一个取消通道
-`SubagentProvider.start()` 和 `SubagentRuntime.start()` 返回 `Promise`。Promise 会在后端跨过发布边界后兑现,因此调用方和 `subagent/start` 观察者从不需要第二个 `run.started` promise。提供方工作如果在发布前失败,`start()` 就会被拒绝;发布后的提示词、轮次、取消与基础设施结果会通过 `SubagentRun.result` 结算,且不会隐藏 child id,这也是[持久化目录决策](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)所要求的约定。
+`SubagentProvider.start()` 和 `SubagentRuntime.start()` 返回 `Promise`。Promise 会在后端跨过发布边界后兑现,因此调用方和 `subagent/start` 观察者从不需要第二个 `run.started` promise。提供方工作如果在发布前失败,`start()` 就会被拒绝;发布后的提示词、轮次、取消与基础设施结果会通过 `SubagentRun.result` 结算,且不会隐藏 child id,这也是[持久化目录决策](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md)所要求的约定。
`SubagentStartRequest.signal` 是必需的。中止它会在启动期间,以及已发布 run 的剩余就绪或轮次工作中请求取消。`SubagentRun.dispose()` 也请求取消并等待完全停稳。没有单独的公开 `run.cancel()` 通道。
@@ -292,6 +300,8 @@ Start 仅在 `initialize` 和 `newSession` 成功后才 resolve。Abort、spawn
Worker 和子进程桥接比同进程注册表需要更多状态,因为消息、进程死亡和清理可以独立结算。它们的状态围绕这些真实事实组织,而非重复的取消协议。
+
+
### 工作流子级是待定 start 或已发布记录
工作流宿主保持待定的提供方 start promise 和已发布的子级记录。子级仅在异步 `SubagentRuntime.start()` 兑现时才从待定变为已发布;被拒绝的 start 清理其部分提供方工作且不产生子级生命周期对。
@@ -308,7 +318,7 @@ Worker 边界仍然序列化请求和结果。宿主保留首个终端结果仲
### ACP 提示词结算不依赖更新投递
-[仅面向自动化的 ACP 桥接层](../simplification/2026-07-23-acp-automation-only-protocol.md)直接将一个进行中的提示词与其观察到的用户消息轮次关联。它不从日志水位线扫描,也不使用会话状态作为第二个调和预言机。
+[仅面向自动化的 ACP 桥接层](../simplification/2026-07-23-acp-automation-only-protocol.zh.md)直接将一个进行中的提示词与其观察到的用户消息轮次关联。它不从日志水位线扫描,也不使用会话状态作为第二个调和预言机。
即使已提交消息的更新无法送达客户端,会话事件监听器也会从匹配的 `turn/end` 结算关联。因此更新投递不能让会话永久处于进行中状态。ACP 创建由服务器分配 id 的全新会话,并拥有由此产生的每个 agent 句柄,直到连接拆除。
@@ -330,13 +340,13 @@ TypeScript 无法管控 JavaScript 强制转换、直接 Cordis dispatch、进
### 生成的产物使公开约定保持对齐
-事件目录、服务目录、生产者/消费方矩阵、配置目录、模块图、工具目录、type-equiv 块和作用域事件解析器映射都是从源码生成或受新鲜度门禁约束的。[TypeScript 语义门禁 Agent Note](../process/2026-07-14-typescript-program-backed-semantic-gates.md) 拥有 Program 构造、语义事件发现和解析器生成规则。
+事件目录、服务目录、生产者/消费方矩阵、配置目录、模块图、工具目录、type-equiv 块和作用域事件解析器映射都是从源码生成或受新鲜度门禁约束的。[TypeScript 语义门禁 Agent Note](../process/2026-07-14-typescript-program-backed-semantic-gates.zh.md) 拥有 Program 构造、语义事件发现和解析器生成规则。
行为测试固定了作用域路由和 dispose、最终写入注册表时的碰撞清理、发布回滚、有序完全停稳、持久化前/后提交行为、跨展示和执行的活跃工具过滤、协作式提示词组装、原生和 Code Mode 中的结构化输出提交、异步 subagent 启动和信号取消、worker 终端仲裁、ACP 结算和进程拆除。
## 曾考虑的替代方案
-[7 月 8 日 Agent Note](2026-07-08-agent-scope-contexts.md#alternatives-considered) 拥有公开扁平作用域约定的替代方案。此处的替代方案关注实现形态。
+[7 月 8 日 Agent Note](2026-07-08-agent-scope-contexts.zh.md#alternatives-considered) 拥有公开扁平作用域约定的替代方案。此处的替代方案关注实现形态。
### 使用透明代理作为作用域载体
@@ -389,4 +399,4 @@ Worker 消息、进程死亡和持久化输入确实跨越所有权和序列化
该设计信任同进程中的类型化插件。它不防御任意强制转换、有状态 getter、违反 readonly 约定的修改,或插件有意在支持的组合 API 之外使用环境服务访问。
-[安全与权限非目标](2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)仍然是根本性的。这些机制证明注册组合、发布和生命期所有权;它们不证明隔离或父到子的非升权。
+[安全与权限非目标](2026-07-08-agent-scope-contexts.zh.md#security-and-authority-are-non-goals)仍然是根本性的。这些机制证明注册组合、发布和生命期所有权;它们不证明隔离或父到子的非升权。
diff --git a/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.i18n.yaml
index 86871f2bcd..205c3c4e39 100644
--- a/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md
2026-07-12-scoped-layers-store.md: 221711748007d5f909aec3fdfcf3919884d11cdb
-2026-07-12-scoped-layers-store.zh.md: 7f3ad11e23f02eeafe955efe08525bda8a9e9996
+2026-07-12-scoped-layers-store.zh.md: 97ad637b91a2b9b2cad9847d8b4cdbe23ff14a48
diff --git a/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.zh.md b/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.zh.md
index 7f3ad11e23..97ad637b91 100644
--- a/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.zh.md
@@ -6,7 +6,7 @@ Status: implemented
## 问题
-agent(智能体)作用域机制([决策](2026-07-08-agent-scope-contexts.md)、[运行时设计](2026-07-12-agent-scope-runtime-design.md))让支持作用域的注册表反复呈现同一种形态:一个全局注册层,加上一个与具体 agent 精确对应的层。七个注册门面都采用这一形态:`tools.register`、`tools.restrict` 和 `tools.guard`(位于 `dsh-tools`);`SystemPrompt.section`、`SystemPrompt.tools` 和 `SystemPrompt.variable`(位于 `dsh-system-prompt`);以及 `CommandRuntime.register`(位于 `dsh-commands`)。
+agent(智能体)作用域机制([决策](2026-07-08-agent-scope-contexts.zh.md)、[运行时设计](2026-07-12-agent-scope-runtime-design.zh.md))让支持作用域的注册表反复呈现同一种形态:一个全局注册层,加上一个与具体 agent 精确对应的层。七个注册门面都采用这一形态:`tools.register`、`tools.restrict` 和 `tools.guard`(位于 `dsh-tools`);`SystemPrompt.section`、`SystemPrompt.tools` 和 `SystemPrompt.variable`(位于 `dsh-system-prompt`);以及 `CommandRuntime.register`(位于 `dsh-commands`)。
如果没有共享原语,每个门面都要围绕自己的领域状态重复相同的生命周期编排:从调用方上下文导出可见性,按需创建专属容器,把属主绑定到同一个 Cordis fiber,先装入 undo 再通知观察者,原样返回 Cordis 的 disposer,并回收空的专属状态。各自分离的映射与集合类型也会让服务缺少一个表示某个 scope 完整贡献的对象。
diff --git a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml
index 3f4683f480..34523f2723 100644
--- a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md
2026-07-14-provider-routed-llm-adapters.md: 78c8d6788006c503b532ff2bbddd30342415f0a4
-2026-07-14-provider-routed-llm-adapters.zh.md: 5e73cab5f1f2b1296c9a486d1c833e95bb5674a0
+2026-07-14-provider-routed-llm-adapters.zh.md: af8bc4fe27a50d47d7b49b51eada67afe889fc13
diff --git a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md
index 5e73cab5f1..af8bc4fe27 100644
--- a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md
@@ -20,7 +20,7 @@ Status: implemented
`GenerateOptions` 与 `LlmCallConfig` 在 `model: string` 之外携带 `provider: string`,`AgentOptions` 则携带对应的可选创建字段。只有两个值都非空时,agent loop(智能体循环)请求才有效;两个值也都会写入请求头日志。`agent/request` 可以在任意步骤返回替换后的字段组合,因此会话可以切换提供方与模型,无需改变 Cordis 插件生命周期。
-`LlmRuntime` 按提供方注册和解析适配器。`registerAdapter(providers, adapter)` 在修改注册表前检查整个提供方列表,遇到重复项时返回 `DUPLICATE_ADAPTER`,并以一个 effect 为单位整体 dispose(资源释放)。模型 ID 不作为注册键;仍由选中的适配器负责验证或转发。后续的 [LLM 目录与 ACP 模型选择 Agent Note](2026-07-15-llm-model-catalog-and-acp-selection.md) 增加了建议性的 `listProviders()` / `listModels()` 发现接口,但不会把目录成员关系变成请求校验规则。
+`LlmRuntime` 按提供方注册和解析适配器。`registerAdapter(providers, adapter)` 在修改注册表前检查整个提供方列表,遇到重复项时返回 `DUPLICATE_ADAPTER`,并以一个 effect 为单位整体 dispose(资源释放)。模型 ID 不作为注册键;仍由选中的适配器负责验证或转发。后续的 [LLM 目录与 ACP 模型选择 Agent Note](2026-07-15-llm-model-catalog-and-acp-selection.zh.md) 增加了建议性的 `listProviders()` / `listModels()` 发现接口,但不会把目录成员关系变成请求校验规则。
在一个 Cordis 上下文中,一个提供方只能有一个适配器所有者。`dsh-llm-deepseek` 注册 `deepseek`;`dsh-llm-pi-ai` 也可以注册 `deepseek`,但同时加载两个所有者属于配置错误,不采用顺序规则或回退行为。若部署选择手写的 DeepSeek 实现,需从 pi-ai 配置中排除 `deepseek`;若部署选择 pi-ai 的 DeepSeek 实现,则不挂载 `dsh-llm-deepseek`。
@@ -40,11 +40,11 @@ pi-ai 的通用流选项不支持停止序列。若 Harness `stop` 选项已定
助手消息携带请求的 `provider` 和 `model`,以及可选的 JSON 可序列化适配器回放状态。成功的 `assistant/message` 会话事件记录这些字段,`deriveMessages()` 返回助手消息时也会包含它们。用户、系统、上下文与工具结果消息不携带助手路由字段。提供方/模型字段是 agent loop 的权威数据;适配器仅拥有其不透明回放状态 payload。
-成功的终止 `finish` 分片可以以 `ReplayEnvelope` 形式携带回放状态:不透明的响应级元数据,加上与发射块序列对齐的可选逐块条目。`BlockAssembler` 对内容与元数据只做一次保留/丢弃决定——max-token 组装丢弃工具调用时,数据同一位置的条目一并丢弃——因此 agent loop 附加到已组装助手消息模型来源中的状态始终描述存储的块,见 [max-token 回放状态对齐决定](../bug-fix/2026-08-15-max-token-replay-state-alignment.md)。agent loop 不公开响应改写钩子。错误或中止响应不会生成正常助手消息,因此不会进入后续模型历史。
+成功的终止 `finish` 分片可以以 `ReplayEnvelope` 形式携带回放状态:不透明的响应级元数据,加上与发射块序列对齐的可选逐块条目。`BlockAssembler` 对内容与元数据只做一次保留/丢弃决定——max-token 组装丢弃工具调用时,数据同一位置的条目一并丢弃——因此 agent loop 附加到已组装助手消息模型来源中的状态始终描述存储的块,见 [max-token 回放状态对齐决定](../bug-fix/2026-08-15-max-token-replay-state-alignment.zh.md)。agent loop 不公开响应改写钩子。错误或中止响应不会生成正常助手消息,因此不会进入后续模型历史。
pi-ai 回放状态用其成功 `AssistantMessage` 的带版本最小投影填充该结构:一个响应半区(源 API/提供方/模型、响应 ID/模型、停止原因),以及逐块的文本签名、thinking 签名和工具调用签名。它不会重复 Harness 内容块中已有的文本或工具参数,也不包含诊断信息、时间戳、用量或错误。后续请求中,只有历史提供方和目标提供方当前归同一个适配器实例所有时,`LlmRuntime` 才会把回放状态交给目标适配器。适配器在能够恢复历史响应时,将 Harness 记录的内容与回放状态组合,并负责所需的跨模型或跨提供方转换。持久化内容保持权威:适配器收到无法使用的回放状态——未知 kind 或版本、格式错误的元数据、或与内容不再匹配的块结构——会把该消息降级为提供方无关转换并带出诊断;其他适配器只能收到提供方无关的内容以及提供方/模型字段。
-该状态属于模型可见的回放输入,因此遵循现有的[请求可重建规则](2026-07-05-reconstructable-requests.md):它同时存在于终止 `finish` 分片和驱动派生的已组装 `assistant/message` 模型来源中。恢复和 fork 会原样保留该状态。压缩(compaction)遮蔽助手消息时,也会从活动 surface 中移除其回放状态;摘要属于普通的提供方无关内容。
+该状态属于模型可见的回放输入,因此遵循现有的[请求可重建规则](2026-07-05-reconstructable-requests.zh.md):它同时存在于终止 `finish` 分片和驱动派生的已组装 `assistant/message` 模型来源中。恢复和 fork 会原样保留该状态。压缩(compaction)遮蔽助手消息时,也会从活动 surface 中移除其回放状态;摘要属于普通的提供方无关内容。
### 在所有请求生产方中传播目标
diff --git a/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.i18n.yaml
index 458999f905..570b68263c 100644
--- a/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md
2026-07-15-agent-initiator-scope.md: 63540c0ec6b29a10613e01f1ed9ced24e8f2d277
-2026-07-15-agent-initiator-scope.zh.md: a0e3638081d875adc2412191829ab84c8dcd697c
+2026-07-15-agent-initiator-scope.zh.md: 3ea893aa5f6992bf09965436c1db3144d2fae5ac
diff --git a/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md b/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md
index a0e3638081..3ea893aa5f 100644
--- a/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md
@@ -12,7 +12,7 @@ harness 中存在两种有用但不同的上下文概念。Cordis `Context` 负
## 决策
-必需的 `ctx.agents` 服务使用 Node `AsyncLocalStorage` 携带发起 Agent。它直接存储同一个 `Agent`,不引入只有一个字段的帧;另一个私有运行标记只记录嵌套边界的谱系,供 teardown 记账使用,不携带身份。[核心数据目录](../../../../docs/subsystems/core.md#initiating-agent)标明了所携带的类型。
+必需的 `ctx.agents` 服务使用 Node `AsyncLocalStorage` 携带发起 Agent。它直接存储同一个 `Agent`,不引入只有一个字段的帧;另一个私有运行标记只记录嵌套边界的谱系,供 teardown 记账使用,不携带身份。[核心数据目录](../../../../docs/subsystems/core.zh.md#initiating-agent)标明了所携带的类型。
`currentInitiator()` 用于可选读取,`requireInitiator()` 抛出 `no initiating agent is active`,`withInitiator(agent, operation)` 保留操作返回的同步值或 Promise 本身。`withoutInitiator(operation)` 会建立清空边界,供不得继承 Agent 的工作使用。会话仍通过 `agent.session` 推导;轮次、步骤、工具调用、`signal`、模型、`cwd`、沙箱和授权继续由现有归属方管理。
@@ -28,7 +28,7 @@ harness 中存在两种有用但不同的上下文概念。Cordis `Context` 负
宿主感知的传输层可以从 `ctx.agents.requireInitiator().session.id` 推导由部署方拥有的 `X-Harness-Session-Id` 等请求头;模型可见 schema 和参数中不包含该请求头。本决策不让现有生产 MCP 或 Web 传输层采用此请求头。测试替身传输层用于证明可信边界,而不会把宿主路由策略分配给现有的提供方无关 seam。
-本决策扩展 [Agent 注册作用域约定](2026-07-08-agent-scope-contexts.md)及其[运行时设计](2026-07-12-agent-scope-runtime-design.md),不会改变其中 `agent.ctx` 的静态含义。
+本决策扩展 [Agent 注册作用域约定](2026-07-08-agent-scope-contexts.zh.md)及其[运行时设计](2026-07-12-agent-scope-runtime-design.zh.md),不会改变其中 `agent.ctx` 的静态含义。
## 验证
diff --git a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.i18n.yaml
index 69af08605c..c9fe4938a3 100644
--- a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md
2026-07-15-llm-model-catalog-and-acp-selection.md: 3dddbe7e9fae74e4ce1ec1c8e93a40c3352b5a54
-2026-07-15-llm-model-catalog-and-acp-selection.zh.md: 145fd0bc379ff8132d09ec628b6381a18f755cfa
+2026-07-15-llm-model-catalog-and-acp-selection.zh.md: b93b333c6f25a779b1d307b6141bea9f5eb1e407
diff --git a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.zh.md b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.zh.md
index 145fd0bc37..b93b333c6f 100644
--- a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.zh.md
@@ -4,7 +4,7 @@ Status: implemented
[English](2026-07-15-llm-model-catalog-and-acp-selection.md) | 中文
-> 目录决策仍然有效。ACP(Agent Client Protocol)会话级模型选择已由 [ACP 作为仅面向自动化的协议](../simplification/2026-07-23-acp-automation-only-protocol.md)取代。
+> 目录决策仍然有效。ACP(Agent Client Protocol)会话级模型选择已由 [ACP 作为仅面向自动化的协议](../simplification/2026-07-23-acp-automation-only-protocol.zh.md)取代。
## 问题
diff --git a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml
index aee9c76d55..277e0b097d 100644
--- a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md
2026-07-15-lsp-capability-seam.md: 90f9daf4b890bd53621916e492d4d82baaa3e8cc
-2026-07-15-lsp-capability-seam.zh.md: b85b478b25a375b04e1301d893e53198554641e3
+2026-07-15-lsp-capability-seam.zh.md: bdb5e812e94a4aec4402fe83ca9c818bfa00f9f9
diff --git a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md
index b85b478b25..bdb5e812e9 100644
--- a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md
@@ -79,7 +79,7 @@ interface LspService {
映射键规范化为带前导点的小写扩展名,并按 `filePath` 的最后一个扩展名选择;语言 id 仅用于文档同步。seam 中的位置和范围从零开始按 UTF-16 计数。`findReferences` 始终包含声明:提供方在内部执行该约束,本地映射设置 `context.includeDeclaration: true`,调用方不能配置。封闭结果联合将导航统一为位置,将 `hover` 统一为内容或 `null`;导航结果携带提供方的规范工作区 URI,使消费方在执行世界的命名空间内相对化文件 URI。seam 不公开协议类型、进程或文档控制,也不提供通用请求逃生口。
-`dsh-lsp-stdio` 负责服务器配置、JSON-RPC、进程与临时文档状态和协议转换。它通过 `ctx.fs` 读取,通过 `ctx.subprocess` 启动,只依赖二者的 Service Definition 包而非具体提供方;[可移植执行环境决策](2026-07-28-portable-execution-world-consumers.md)负责定义这种配对。服务器表的键是提供方 id。插件在注册前解析每个服务器的本地设置;如果后续映射无效或发生冲突,插件会撤销此前的注册,并为每个提供方保留独立进程池。`dsh-tool-lsp` 在运行时只注入 `tools`、`lsp` 和 `systemPrompt`,通过包内的 `sessionCwd(exec)` 辅助函数从 `exec.agent?.session.header.cwd` 取得工作区,其取值方式与文件系统工具一致,也不导入提供方。
+`dsh-lsp-stdio` 负责服务器配置、JSON-RPC、进程与临时文档状态和协议转换。它通过 `ctx.fs` 读取,通过 `ctx.subprocess` 启动,只依赖二者的 Service Definition 包而非具体提供方;[可移植执行环境决策](2026-07-28-portable-execution-world-consumers.zh.md)负责定义这种配对。服务器表的键是提供方 id。插件在注册前解析每个服务器的本地设置;如果后续映射无效或发生冲突,插件会撤销此前的注册,并为每个提供方保留独立进程池。`dsh-tool-lsp` 在运行时只注入 `tools`、`lsp` 和 `systemPrompt`,通过包内的 `sessionCwd(exec)` 辅助函数从 `exec.agent?.session.header.cwd` 取得工作区,其取值方式与文件系统工具一致,也不导入提供方。
## 面向模型的约定
diff --git a/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml
index 47e7156ab8..6ad1773f6f 100644
--- a/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md
2026-07-15-replay-token-meter-service.md: 10261189fca621b305b3ec6347b54471d81c8ba3
-2026-07-15-replay-token-meter-service.zh.md: 4e460b3c32630f205f447a16205bd4c9b1d89faf
+2026-07-15-replay-token-meter-service.zh.md: 370d3cf932d37702c5298bc79e9dc8f5a37835b9
diff --git a/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md b/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md
index 4e460b3c32..370d3cf932 100644
--- a/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md
@@ -16,7 +16,7 @@ Status: implemented
`@deepseek-ai/dsh-token-meter` 是 `packages/llm/` 下的单个具体包,并注册 `ctx.tokenMeter`。在第二种实现出现之前,它不会被拆成接口与后端。`TokenMeter` 本身公开 `measure(session, requestHeader?)` 与 `estimateMessage(message)`;消费方直接调用这个单例服务。
-服务没有配置。估算采用固定的每 token 四个字符启发式规则,并加上结构开销。服务不提供模型 profile、容量设置、密度设置、分词器后端或语言专用策略。对精确提供方/模型容量的查询由适配器单独负责,具体见[路由模型上下文与压缩策略 Agent Note](2026-07-20-routed-model-context-and-compaction-policy.md)。
+服务没有配置。估算采用固定的每 token 四个字符启发式规则,并加上结构开销。服务不提供模型 profile、容量设置、密度设置、分词器后端或语言专用策略。对精确提供方/模型容量的查询由适配器单独负责,具体见[路由模型上下文与压缩策略 Agent Note](2026-07-20-routed-model-context-and-compaction-policy.zh.md)。
### 逐会话回放折叠
diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml
index 342f2073ea..fcca895513 100644
--- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md
2026-07-16-explicit-turn-cancellation.md: 86faad9929d3eb5b00e66bb1c46a2e35b135d954
-2026-07-16-explicit-turn-cancellation.zh.md: 5397b368628c196efc2b35c00855267246ac6e85
+2026-07-16-explicit-turn-cancellation.zh.md: acf56e0629668a227324045ffd0521619dc45f33
diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md
index 5397b36862..acf56e0629 100644
--- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md
@@ -8,7 +8,7 @@ Status: implemented
取消是一种生命周期短于 Agent(智能体)驱动器的控制能力。自由文本字符串无法完整区分所有调用方,步骤级控制器也无法中断提示词提交、提示词组装、继续决策或轮次终止策略。持久化 `Error`、`AbortSignal.reason` 或后端私有对象还会向持久化回放暴露不稳定的运行时细节。
-[发起 Agent 作用域决策](2026-07-15-agent-initiator-scope.md)有意让 AsyncLocalStorage 只携带同一个 Agent。若把轮次、步骤或 signal 状态加入这个与驱动器同生命周期的边界,陈旧的异步后代就会看似仍对后续轮次拥有权限。因此,取消需要一个轮次归属方并显式传播,且不创建另一套环境上下文或公开的轮次包装层。
+[发起 Agent 作用域决策](2026-07-15-agent-initiator-scope.zh.md)有意让 AsyncLocalStorage 只携带同一个 Agent。若把轮次、步骤或 signal 状态加入这个与驱动器同生命周期的边界,陈旧的异步后代就会看似仍对后续轮次拥有权限。因此,取消需要一个轮次归属方并显式传播,且不创建另一套环境上下文或公开的轮次包装层。
## 决策
@@ -18,9 +18,9 @@ Agent 拥有仅用于运行时的 `AgentCancelCause` 联合类型 `{ kind: 'user
AgentLoop 为每个待启动轮次私有地持有一个 `TurnCancellation`。它在通知 `agent/status = running` 前安装该持有者,使其中唯一的 `AbortController` 持续覆盖 inbox 领取、`agent/pre-step`、提示词组装、每个步骤、模型与工具执行以及 `agent/turn-stopping`;随后在发布 `turn/end` 前立即清除所安装的那个持有者。因此,即使驱动器状态可能在持久化刷新结算前保持 `running`,终态事件观察者及其后的持久化刷新也无法取消已完成的轮次工作。所有参与的方法、事件和请求值都会收到同一个显式 signal;下一个轮次会收到全新的 signal。
-对于轮次被认领前已取消的排队工作,驱动器只保留一个不携带取消原因的运行前标记。实际生效的 `cancel()` 会先发出仅供观察的 `agent/cancel-requested` 通知并携带最终确定的类型化取消原因,然后才清除排队工作和 steering(中途引导)工作或中止持有者;通知失败不能阻止此次停止,空闲状态下调用则不发出任何通知。通知观察者同步加入队列的工作也会被这次清除,而稍后由 signal 中止观察者加入队列的工作会被锁存,并在被中止的活动收敛到空闲时执行——`disposed` 取消则将其停放([取消收敛窗口唤醒锁存](../bug-fix/2026-08-07-cancel-convergence-wake-latch.md))。若 `running` 监听器同步取消旧工作并发送替代提示词,驱动器会丢弃已中止的持有者,并为替代提示词创建全新的持有者。同一活跃持有者上的重复取消遵循首次请求优先,后续调用仍可清除新入队的待处理工作。
+对于轮次被认领前已取消的排队工作,驱动器只保留一个不携带取消原因的运行前标记。实际生效的 `cancel()` 会先发出仅供观察的 `agent/cancel-requested` 通知并携带最终确定的类型化取消原因,然后才清除排队工作和 steering(中途引导)工作或中止持有者;通知失败不能阻止此次停止,空闲状态下调用则不发出任何通知。通知观察者同步加入队列的工作也会被这次清除,而稍后由 signal 中止观察者加入队列的工作会被锁存,并在被中止的活动收敛到空闲时执行——`disposed` 取消则将其停放([取消收敛窗口唤醒锁存](../bug-fix/2026-08-07-cancel-convergence-wake-latch.zh.md))。若 `running` 监听器同步取消旧工作并发送替代提示词,驱动器会丢弃已中止的持有者,并为替代提示词创建全新的持有者。同一活跃持有者上的重复取消遵循首次请求优先,后续调用仍可清除新入队的待处理工作。
-显式事件签名传递单个 payload 对象:agent 作用域事件在 payload 中携带 `agent` 和 `signal`,`next` 位于最后;其余 API 保持 `signal` 紧邻 waterfall(瀑布式事件)的最终 `next` 之前。`PreStepContext` 与 `RequestFailureContext` 已退役,其字段并入 `agent/pre-step` 与 `agent/request-error` 的 payload([payload-object 事件](2026-08-06-agent-event-payload-objects.md))。进入 pre-step 时、请求配置、请求错误恢复、模型生成、工具执行、审批、轮次停止以及 subagent 或工作流请求都会收到当前 signal。钩子桥接器也必须提供 `RunHookOptions.signal`,使轮次取消能够到达 Bash 执行器终止进程组并等待其退出的边界。`SystemPrompt.assemble()` 在 `AssembleContext` 中携带 `signal?: AbortSignal`,因为该对象是显式请求值,也可表示轮次之外不携带 signal 的组装。监听器可以配合该 signal 取消,但不得保留它来控制其他轮次。
+显式事件签名传递单个 payload 对象:agent 作用域事件在 payload 中携带 `agent` 和 `signal`,`next` 位于最后;其余 API 保持 `signal` 紧邻 waterfall(瀑布式事件)的最终 `next` 之前。`PreStepContext` 与 `RequestFailureContext` 已退役,其字段并入 `agent/pre-step` 与 `agent/request-error` 的 payload([payload-object 事件](2026-08-06-agent-event-payload-objects.zh.md))。进入 pre-step 时、请求配置、请求错误恢复、模型生成、工具执行、审批、轮次停止以及 subagent 或工作流请求都会收到当前 signal。钩子桥接器也必须提供 `RunHookOptions.signal`,使轮次取消能够到达 Bash 执行器终止进程组并等待其退出的边界。`SystemPrompt.assemble()` 在 `AssembleContext` 中携带 `signal?: AbortSignal`,因为该对象是显式请求值,也可表示轮次之外不携带 signal 的组装。监听器可以配合该 signal 取消,但不得保留它来控制其他轮次。
`ctx.agents` 仍只携带发起 Agent。环境中的 Agent 并不代表存活、当前轮次或取消权限。cause 读取器是 loop 私有的,它直接陈述机器私有的 slot 不变量(只有 `cancel()` 会中止轮次控制器,且总是携带规范的冻结 cause),而不是对 reason 做结构化再校验;不存在从任意 signal 读取 cause 的公开辅助函数。并发 Agent 会同时隔离各自的发起方身份和轮次 signal;子驱动会遮蔽父发起方,而父请求 signal 仍通过 subagent seam 传递。
diff --git a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml
index e772ff9036..29807cfb7c 100644
--- a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml
@@ -3,4 +3,4 @@
# 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-cooperative-tool-cancellation.md
2026-07-19-cooperative-tool-cancellation.md: 781202688a5cbcd7076ee694fc7dd9489683d8e8
-2026-07-19-cooperative-tool-cancellation.zh.md: a5ce2e671e92f4758ddeb3d3556d8af574467f7f
+2026-07-19-cooperative-tool-cancellation.zh.md: ec35734eef91c5c774d1be814b221e9fdb8f65fa
diff --git a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md
index a5ce2e671e..ec35734eef 100644
--- a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md
@@ -42,7 +42,7 @@ Status: implemented
工具主体一旦启动,注册表就会等待它完成。取消通过融合信号到达工具主体,但注册表不会与其 promise 竞速或丢弃该 promise。协作式实现会停止自身工作或继续转发取消,并在所持有的工作完全停稳后完成;不协作的同进程实现可能让注册表无限期保持等待。进程、worker、网络和提供方层仍负责各自的终止机制。
-这项决策只要求工具调用边界携带取消信号。让工具主体可达的异步能力也必须接收信号,属于另一项迁移,见提议中的[工具可达能力 seam 中的必填取消](../../proposed/architecture/2026-07-19-required-cancellation-through-tool-capability-seams.md)。
+这项决策只要求工具调用边界携带取消信号。让工具主体可达的异步能力也必须接收信号,属于另一项迁移,见提议中的[工具可达能力 seam 中的必填取消](../../proposed/architecture/2026-07-19-required-cancellation-through-tool-capability-seams.zh.md)。
## 验证
@@ -62,7 +62,7 @@ Status: implemented
**禁止环绕包装层替换信号。** 不予采纳,因为截止时间和嵌套操作作用域需要词法派生信号。捕获并融合调用方信号既保留组合能力,也不允许切断调用方取消。
-**让工具 promise 与取消竞速。** 不予采纳,因为这种方式会在副作用仍可能存活时报告完成,违反[dispose(资源释放)必须完全停稳的规则](../../../../docs/defensive-patterns.md#dispose-must-reach-quiescence-not-just-request-it)。
+**让工具 promise 与取消竞速。** 不予采纳,因为这种方式会在副作用仍可能存活时报告完成,违反[dispose(资源释放)必须完全停稳的规则](../../../../docs/defensive-patterns.zh.md#dispose-must-reach-quiescence-not-just-request-it)。
## 后果
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 b864b41f76..1ed087f38c 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
@@ -3,4 +3,4 @@
# 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: 620803668e88f5a462ab2a75e6e916a85d433ed6
-2026-07-19-gui-layering-and-rpc-protocol.zh.md: 8145b6e5af149f79b45e03c80aeea90da90b3729
+2026-07-19-gui-layering-and-rpc-protocol.zh.md: 137cbce3e5dbc0be2736472f6e26d58112422697
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 8145b6e5af..137cbce3e5 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
@@ -4,7 +4,7 @@ Status: implemented
[English](2026-07-19-gui-layering-and-rpc-protocol.md) | 中文
-> 分工线:本篇 = 分层模型 + 通道无关的 RPC 协议;协议的 Web 实现由 HTTP 上行加 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.md)组成,浏览器对象层见 [Web 客户端架构笔记](2026-07-19-gui-web-client-architecture.md)。
+> 分工线:本篇 = 分层模型 + 通道无关的 RPC 协议;协议的 Web 实现由 HTTP 上行加 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.zh.md)组成,浏览器对象层见 [Web 客户端架构笔记](2026-07-19-gui-web-client-architecture.zh.md)。
## Problem
@@ -23,13 +23,13 @@ Status: implemented
目录按照如下分层:
- `packages/host/*`:包只提供 Host 侧能力(代表了以现在 Harness 实体插件系统为主体的 Node.js 代码核心工程),除此之外,还包含
- 统一后端协议(fetch、HTTP、流式接口等)定义和支持,见本篇「消息协议」起各节
-- `packages/client/*`:包只提供 Client 侧能力,每包单边不混。这里住三类包(两条轴归 [client 插件装载笔记](2026-07-23-client-plugin-loading-model.md) 所有):
+- `packages/client/*`:包只提供 Client 侧能力,每包单边不混。这里住三类包(两条轴归 [client 插件装载笔记](2026-07-23-client-plugin-loading-model.zh.md) 所有):
- **纯库**(`ui-slots`、`ui-primitives`,外加内核包 `loader`):普通根入口包,静态打包进壳;两个客户端库播种进模块表。
- **静态到达 entry 包**(`connection`、`runtime`、`ui-theme`、`i18n`、`hmr`):无 `dsh.client` 键、无浏览器 bundle——壳把它们的 `src/client/` 半边打进自己的 bundle 并向 `ctx.modules` 登记;它们与其余单元一样,作为 host 独家撰写的图里的 entry 受治理。
- **fetch 到达插件包**(`ui-layout`、`ui-sidebar`、`ui-conversation`、`ui-trajectory`):双入口——根入口是 node 半边(空 `apply`,其存在是为了让 host Loader 管辖生命周期、让 web 插件注册表发现 package.json 的 `dsh.client` 声明);实现住在 `src/client/` 下,经 `./client` 子路径发布(tsdown 闭包工厂 bundle)。跨插件消费 `/client` 只限类型;值层面的协作走 cordis 服务。
- `apps/` 作为对外导出的应用入口,可以由 Client / Host 混合组装。
- `apps/web`(`dsh-web-frontend`)是 vite 应用:`dsh-client-web` 导出的壳 API 之上的一层薄 `main.ts`。
- - `apps/cli`(`@deepseek-ai/dsh`)分发命令:`dsh web` = Host + webserver + 构建出的 `dsh-web-frontend` dist;`dsh --profile headless` = [直接使用核心 Agent/Session 的入口](2026-08-09-headless-direct-core-entry-point.md),不含 Host、HTTP 或浏览器层。
+ - `apps/cli`(`@deepseek-ai/dsh`)分发命令:`dsh web` = Host + webserver + 构建出的 `dsh-web-frontend` dist;`dsh --profile headless` = [直接使用核心 Agent/Session 的入口](2026-08-09-headless-direct-core-entry-point.zh.md),不含 Host、HTTP 或浏览器层。
- 将来的 Electron 应用经由 IPC fetch 载体复用同一套 web client 包。
```
@@ -50,9 +50,9 @@ harness core packages ──────────────────┘
- `runtime → apiproxy` 单向;apiproxy 仅依赖类型定义。
- client 侧包**永不 import** host 侧包的运行时(只吃 `/api`、`/client` 两个浏览器安全子路径)。
- `webserver` 不依赖 `runtime`:它提供 `{ fetch }` 特定实现 ——「webserver ← runtime」只是运行时注入关系,不是包依赖。
-- client 侧跨包 import 插件包一律走 `/client` 子路径,且插件包之间只限类型 import——跨插件值 import 在 tsdown 纯度门禁处即构建错误(值层面的协作走 cordis 服务;边规则归 [client 插件装载笔记](2026-07-23-client-plugin-loading-model.md) 所有)。
+- client 侧跨包 import 插件包一律走 `/client` 子路径,且插件包之间只限类型 import——跨插件值 import 在 tsdown 纯度门禁处即构建错误(值层面的协作走 cordis 服务;边规则归 [client 插件装载笔记](2026-07-23-client-plugin-loading-model.zh.md) 所有)。
-TypeScript 以 solution 根引用的**两个聚合 program** 检查(`tsconfig.json` = solution;`tsconfig.host.json` = host 侧 + 测试,排除 `packages/client`;`tsconfig.client.json` = client 各包及其测试):两侧在相同键(`sessions`、`loader`)下以不同服务合并 cordis `Context` 接口,单一 program 会同时看到两份声明合并而报冲突。共享叶子包(session/llm/tools/apiproxy 等)只构建一次,由两个 program 共同引用([拓扑](../process/2026-07-22-tsconfig-solution-root-two-aggregates.md))。
+TypeScript 以 solution 根引用的**两个聚合 program** 检查(`tsconfig.json` = solution;`tsconfig.host.json` = host 侧 + 测试,排除 `packages/client`;`tsconfig.client.json` = client 各包及其测试):两侧在相同键(`sessions`、`loader`)下以不同服务合并 cordis `Context` 接口,单一 program 会同时看到两份声明合并而报冲突。共享叶子包(session/llm/tools/apiproxy 等)只构建一次,由两个 program 共同引用([拓扑](../process/2026-07-22-tsconfig-solution-root-two-aggregates.zh.md))。
协议侧:TS interface(`packages/host/apiproxy/src/api/`,零 Node 依赖,浏览器可 import);wire 消息统一为**双向模型**——每条逻辑消息按「谁发起 × request/response」分类(两轴四格,后文称四象限),与物理通道解耦;客户端统一继承 `AbstractApiClient`(协议不变量全在基类,平台差异只是 `doFetch` 传输切面)。
@@ -167,7 +167,7 @@ export type ResponseValue =
### 帧(server→client,具名 union)
-两条逻辑流:mux 流(`/api/events.mux`,全 session 聚合)与 host 流(`/api/events.host`,host 级事件)。浏览器通过每流一条下行 WebSocket 消费,进程内 fetch 载体以 SSE 保持同构;物理边界见 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.md)。帧示例一行:
+两条逻辑流:mux 流(`/api/events.mux`,全 session 聚合)与 host 流(`/api/events.host`,host 级事件)。浏览器通过每流一条下行 WebSocket 消费,进程内 fetch 载体以 SSE 保持同构;物理边界见 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.zh.md)。帧示例一行:
| 帧 type | 载荷 | 何时发 |
|---|---|---|
@@ -214,7 +214,7 @@ export type ResponseValue =
| 子类 | 所在包 | doFetch | 用途 |
|---|---|---|---|
| `InProcessApiClient` | apiproxy 本包 | 注入的 `{ fetch }` handler | **同构点**:`new InProcessApiClient(toFetchHandler(api))` 全程不过网络但真跑 wire 序列化/zod/SSE 帧;载体测试与调用方可以在不打开端口的情况下运行这套协议,而产品 `dsh --profile headless` 直接驱动 core |
-| `WebApiClient` | dsh-client-connection | `globalThis.fetch` 上行 + 每逻辑流一条同源 WebSocket 下行 | 浏览器客户端;物理边界见 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.md) |
+| `WebApiClient` | dsh-client-connection | `globalThis.fetch` 上行 + 每逻辑流一条同源 WebSocket 下行 | 浏览器客户端;物理边界见 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.zh.md) |
| `FixtureApiClient` | dsh-client-connection | 不用(协议层覆写) | 无 server 的 UI 开发(`?fixture`):覆写 `callUnary`/`openMux`/`openHost`/`respond` 虚方法,自己就是假 server(帧 rpcId 由它 mint,语义自洽) |
| IPC 桥子类(假想示例——尚无此形态) | Electron 壳 | IPC 序列化往返 | 只需换 doFetch,约定/基类零改 |
diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml
index 2d5aaa3a25..9ee365f446 100644
--- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml
@@ -3,4 +3,4 @@
# 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-web-client-architecture.md
2026-07-19-gui-web-client-architecture.md: 8b4f940299cbba78d403c34b1e5fc9740e44f2c2
-2026-07-19-gui-web-client-architecture.zh.md: a86f9d6f71bed0ce147ffee39bd921037d6c1c4c
+2026-07-19-gui-web-client-architecture.zh.md: 705b1337dd97ac37bd01bdcc5aa22484b7971908
diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md
index a86f9d6f71..705b1337dd 100644
--- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md
@@ -4,7 +4,7 @@ Status: implemented
[English](2026-07-19-gui-web-client-architecture.md) | 中文
-> 分工线:通道无关的分层模型与 RPC 协议(消息模型/类型体系/约定面/客户端基类)见 [分层与 RPC 协议笔记](2026-07-19-gui-layering-and-rpc-protocol.md);本篇 = 浏览器侧:client cordis 树如何装载、UI 插件如何经 slot 与服务组合、React-free 对象层如何以不可变快照供给 React。
+> 分工线:通道无关的分层模型与 RPC 协议(消息模型/类型体系/约定面/客户端基类)见 [分层与 RPC 协议笔记](2026-07-19-gui-layering-and-rpc-protocol.zh.md);本篇 = 浏览器侧:client cordis 树如何装载、UI 插件如何经 slot 与服务组合、React-free 对象层如何以不可变快照供给 React。
## Problem
@@ -30,21 +30,21 @@ Status: implemented
## client cordis 树与装载链
-装载链——两类包(普通包 vs dsh.client 插件)、模块系统/插件治理器之分、host 独家撰写的带修订号 entry 图之上的双阶段 boot、热重载——归 [client 插件装载笔记](2026-07-23-client-plugin-loading-model.md) 所有。本篇赖以立足的事实:浏览器启动与 host 相同的 vendored `@cordisjs/plugin-loader`,由 client 模块系统(`ctx.modules`,`packages/client/modules`)填上其 `internal` 约定;凡带产品行为的单元都是 host 独家撰写的 `__DSH_BOOT__` 图里的 entry——每个生产插件包(含基础设施)都携带 `dsh.client` 声明、以 fetch 到达的 `./client` tsdown 闭包 bundle 供给,`immediately` 行的差别仅在 boot 第一阶段预取,而普通包(react 家族、cordis、尚未升格的库)保持打进壳、已播种、对图不可见;bundle 执行 `window.__ModuleLoader__.load({ id, factory })`,其 `require` 由 lazy CJS 模块表应答(种子词条 + 已登记工厂,首次 require 时物化并记忆化——跨插件值 import 是构建错误,协作走 cordis 服务);全局样式与 CSS Modules 都内联在其持有插件的 bundle 中,物化时注入为 `', '>')
+}
+
+function assertNever(row: never): never {
+ throw new Error(`webserver: unknown index injection row ${JSON.stringify(row)}`)
+}
+
+/** Render one row to markup with its placement. */
+function renderRow(row: IndexInjection): { placement: IndexInjectionPlacement; markup: string } {
+ switch (row.kind) {
+ case 'global': {
+ // `<` is escaped in JSON so a row-controlled string cannot break out of
+ // the script element.
+ const name = JSON.stringify(row.name).replaceAll('<', '\\u003c')
+ const value = row.value === undefined
+ ? 'undefined'
+ : JSON.stringify(row.value).replaceAll('<', '\\u003c')
+ return { placement: 'head', markup: `` }
+ }
+ case 'script':
+ return { placement: row.placement, markup: `` }
+ case 'script-src':
+ return { placement: row.placement, markup: `` }
+ case 'style':
+ return { placement: 'head', markup: `` }
+ case 'html':
+ return { placement: row.placement, markup: row.html }
+ default:
+ return assertNever(row)
+ }
+}
+
+/** Insert `markup` into `html` at `at`. */
+function splice(html: string, at: number, markup: string): string {
+ return `${html.slice(0, at)}${markup}${html.slice(at)}`
+}
+
+/**
+ * Render rows into an index.html body: head rows immediately after the
+ * opening head tag, body rows immediately after the opening body tag, each
+ * group in table order.
+ * @param html - the raw index.html body.
+ * @param rows - the collected injection table.
+ * @returns the html with every row rendered.
+ */
+export function renderIndexInjections(html: string, rows: readonly IndexInjection[]): string {
+ let head = ''
+ let body = ''
+ for (const row of rows) {
+ const rendered = renderRow(row)
+ if (rendered.placement === 'head') head += rendered.markup
+ else body += rendered.markup
+ }
+ let out = html
+ if (head !== '') {
+ const open = /]*)?>/i.exec(out)
+ // Headless fixture pages may lack ; prepending keeps the rows ahead
+ // of every document script.
+ out = open === null ? `${head}${out}` : splice(out, open.index + open[0].length, head)
+ }
+ if (body !== '') {
+ const open = /]*)?>/i.exec(out)
+ // Body-less fragments receive the rows at the end, where the HTML parser
+ // has already synthesized a body.
+ out = open === null ? `${out}${body}` : splice(out, open.index + open[0].length, body)
+ }
+ return out
+}
diff --git a/packages/host/webserver/tests/webserver.spec.ts b/packages/host/webserver/tests/webserver.spec.ts
index 2cbd285856..ffe5b4648d 100644
--- a/packages/host/webserver/tests/webserver.spec.ts
+++ b/packages/host/webserver/tests/webserver.spec.ts
@@ -15,7 +15,7 @@ import { afterEach, describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import Include from '@deepseek-ai/cordis-plugin-include'
-import HttpServer from '../src/index.ts'
+import HttpServer, { renderIndexInjections } from '../src/index.ts'
let root: string | undefined
let context: Context | undefined
@@ -200,6 +200,54 @@ describe('real Loader composition', () => {
await expect(request(port, '/probe')).rejects.toThrow()
})
+ it('collects injection rows fresh per render and layers taps over the rendered rows', { timeout: 60_000 }, async () => {
+ const loaded = await loadComposition()
+ const server = loaded.webServer
+ let flag = 'dark'
+ loaded.on('webserver/index-inject', (table) => {
+ table.push(
+ { kind: 'script', placement: 'head', text: 'window.__Q__=1' },
+ { kind: 'script-src', placement: 'head', src: '/plugins/a.js?rev="1"&x=' },
+ { kind: 'global', name: '__DSH_BOOT__', value: { rev: '' } },
+ { kind: 'style', text: 'body{margin:0}' },
+ { kind: 'html', placement: 'head', html: '' },
+ { kind: 'script', placement: 'body', text: `window.__P__=${JSON.stringify(flag)}` },
+ )
+ })
+
+ const html = server.renderIndex('shell')
+ // Head rows land right after the opening head tag in table order; the body
+ // row lands right after the opening body tag.
+ const order = [
+ '',
+ '',
+ '',
+ 'globalThis["__DSH_BOOT__"] = {"rev":"\\u003c/script>\\u003cb>"}',
+ '',
+ '',
+ '',
+ '',
+ 'shell',
+ ].map(part => html.indexOf(part))
+ expect(order).toEqual([...order].sort((a, b) => a - b))
+ expect(order.every(at => at !== -1)).toBe(true)
+
+ // Fresh collection per render: the listener reads live state at emit time.
+ flag = 'light'
+ expect(server.renderIndex('')).toContain('window.__P__="light"')
+
+ // Raw taps still run, over the already-rendered rows.
+ const untap = server.tapIndex(h => h.replace('window.__Q__=1', 'window.__Q__=2'))
+ expect(server.renderIndex('')).toContain('window.__Q__=2')
+ untap()
+
+ // Tag-less fragments: head rows prepend, body rows append.
+ expect(renderIndexInjections('x', [
+ { kind: 'script', placement: 'head', text: 'H' },
+ { kind: 'script', placement: 'body', text: 'B' },
+ ])).toBe('x')
+ })
+
it('fails the fiber when the port is already taken (fail-loud at activation)', { timeout: 60_000 }, async () => {
const first = await loadComposition()
const takenPort = first.webServer.port
diff --git a/packages/identity/README.i18n.yaml b/packages/identity/README.i18n.yaml
index 653704a83c..f1f3b0c384 100644
--- a/packages/identity/README.i18n.yaml
+++ b/packages/identity/README.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/identity/README.md
README.md: ebbc7d937dfa793edf9a8617d86a1820e87866da
-README.zh.md: 3d29fc3dee7599e366cb91c09d6a73c8acddab96
+README.zh.md: cd5fa06a0e9c6c277292b5904a1617acaa8dcfc3
diff --git a/packages/identity/README.zh.md b/packages/identity/README.zh.md
index 3d29fc3dee..cd5fa06a0e 100644
--- a/packages/identity/README.zh.md
+++ b/packages/identity/README.zh.md
@@ -6,4 +6,4 @@
| 包 | 职责 | ctx key |
|---|---|---|
-| [`anonymous-user-id/`](anonymous-user-id/README.md) | 为遥测、反馈和 DeepSeek 请求持久化一个限定于 Harness home 的匿名关联 id | — |
+| [`anonymous-user-id/`](anonymous-user-id/README.zh.md) | 为遥测、反馈和 DeepSeek 请求持久化一个限定于 Harness home 的匿名关联 id | — |
diff --git a/packages/identity/anonymous-user-id/package.json b/packages/identity/anonymous-user-id/package.json
index f967e48d2b..c596482b22 100644
--- a/packages/identity/anonymous-user-id/package.json
+++ b/packages/identity/anonymous-user-id/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-anonymous-user-id",
"description": "Shared anonymous user identity for DeepSeek Harness telemetry and feedback correlation",
- "version": "0.1.0-rc.7",
+ "version": "0.1.0-rc.8",
"publishConfig": {
"access": "public"
},
diff --git a/packages/interaction/README.i18n.yaml b/packages/interaction/README.i18n.yaml
index 116db6b260..7086f59493 100644
--- a/packages/interaction/README.i18n.yaml
+++ b/packages/interaction/README.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/interaction/README.md
README.md: a7842e40aa708ee9ec159dd51b8a94a6b2b18539
-README.zh.md: ec329365331efd2769078078d1da4a8e1b3b57e0
+README.zh.md: 1a760484f887a459348612c867213d95e7257420
diff --git a/packages/interaction/README.zh.md b/packages/interaction/README.zh.md
index ec32936533..1a760484f8 100644
--- a/packages/interaction/README.zh.md
+++ b/packages/interaction/README.zh.md
@@ -6,12 +6,12 @@
| 包 | 职责 | ctx 键 |
|---|---|---|
-| [`commands/`](commands/README.md) | 为交互式适配器注册并分派用户命令。 | `ctx.commands` |
-| [`user-approval/`](user-approval/README.md) | 协调一次性审批决策。 | `ctx.approval` |
-| [`permission/`](permission-presets/README.md) | 呈现并持久化面向用户的权限预设。 | `ctx.permissionPresets` |
-| [`user-questions/`](user-questions/README.md) | 定义与提供方无关的用户问答 seam。 | `ctx.userQuestions` |
-| [`tool-ask-user/`](tool-ask-user/README.md) | 向模型提供用户问题。 | (注册到 `ctx.tools`) |
+| [`commands/`](commands/README.zh.md) | 为交互式适配器注册并分派用户命令。 | `ctx.commands` |
+| [`user-approval/`](user-approval/README.zh.md) | 协调一次性审批决策。 | `ctx.approval` |
+| [`permission/`](permission-presets/README.zh.md) | 呈现并持久化面向用户的权限预设。 | `ctx.permissionPresets` |
+| [`user-questions/`](user-questions/README.zh.md) | 定义与提供方无关的用户问答 seam。 | `ctx.userQuestions` |
+| [`tool-ask-user/`](tool-ask-user/README.zh.md) | 向模型提供用户问题。 | (注册到 `ctx.tools`) |
-这些包通过现有的 agent 和会话约定集成,而不改变循环。交互式应用提供具体的命令、审批和提问适配器;自动化使用 [`acp/`](../acp/README.md),可运行的演示组合包位于 [`examples/`](../examples/README.md)。产品 [`dsh`](../../apps/cli/README.md) CLI(命令行界面)直接组合这些包。
+这些包通过现有的 agent 和会话约定集成,而不改变循环。交互式应用提供具体的命令、审批和提问适配器;自动化使用 [`acp/`](../acp/README.zh.md),可运行的演示组合包位于 [`examples/`](../examples/README.zh.md)。产品 [`dsh`](../../apps/cli/README.zh.md) CLI(命令行界面)直接组合这些包。
-子系统参考:[approval.md](../../docs/subsystems/approval.md)、[permission-presets.md](../../docs/subsystems/permission-presets.md)、[user-questions.md](../../docs/subsystems/user-questions.md)与 [commands.md](../../docs/subsystems/commands.md)。仅自动化的 ACP 传输是 [`acp/`](../acp/README.md),SDK 的 JSON-RPC 服务器端是 [`sdk/server`](../sdk/README.md),共享 bin 启动胶水是 [`boot/`](../boot/README.md)。
+子系统参考:[approval.md](../../docs/subsystems/approval.zh.md)、[permission-presets.md](../../docs/subsystems/permission-presets.zh.md)、[user-questions.md](../../docs/subsystems/user-questions.zh.md)与 [commands.md](../../docs/subsystems/commands.zh.md)。仅自动化的 ACP 传输是 [`acp/`](../acp/README.zh.md),SDK 的 JSON-RPC 服务器端是 [`sdk/server`](../sdk/README.zh.md),共享 bin 启动胶水是 [`boot/`](../boot/README.zh.md)。
diff --git a/packages/interaction/commands/README.i18n.yaml b/packages/interaction/commands/README.i18n.yaml
index fa8ce400f3..cde56d4ef9 100644
--- a/packages/interaction/commands/README.i18n.yaml
+++ b/packages/interaction/commands/README.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/interaction/commands/README.md
README.md: 4a4cb2a70b56ba1a18e9f4719541a50a9683510c
-README.zh.md: f89ccd1a5cc9189b2810026481d4c57855481111
+README.zh.md: d331e3e2f359572e4953831ea0c4dbe8d2fd64c3
diff --git a/packages/interaction/commands/README.zh.md b/packages/interaction/commands/README.zh.md
index f89ccd1a5c..d331e3e2f3 100644
--- a/packages/interaction/commands/README.zh.md
+++ b/packages/interaction/commands/README.zh.md
@@ -2,7 +2,7 @@
[English](README.md) | 中文
-由插件负责、供交互式 UI 适配器使用的面向用户命令注册表。[插件命令注册 Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md)定义了其边界与分发约定。
+由插件负责、供交互式 UI 适配器使用的面向用户命令注册表。[插件命令注册 Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.zh.md)定义了其边界与分发约定。
## 服务约定
@@ -24,7 +24,7 @@
#### 模型看到的内容
-注册表自身不会提交任何内容。已知斜杠命令在 UI 命令平面执行,其 `CommandResult` 文本不会作为用户消息提交。已交付的适配器会拒绝未知斜杠命令输入,而不是将其变成模型提示词。命令生产方可以显式使用接收命令的 `Agent`;例如,[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-interactions)在选择 plan mode 后,会提交 `/plan [message]` 中的可选消息。图片附件遵循同一规则:执行器只负责把它们准入为持久化附件对象,是否以及如何成为模型可见的消息内容由声明接受的生产方决定。
+注册表自身不会提交任何内容。已知斜杠命令在 UI 命令平面执行,其 `CommandResult` 文本不会作为用户消息提交。已交付的适配器会拒绝未知斜杠命令输入,而不是将其变成模型提示词。命令生产方可以显式使用接收命令的 `Agent`;例如,[`dsh-plan-mode`](../../plan/plan-mode/README.zh.md#model-and-human-interactions)在选择 plan mode 后,会提交 `/plan [message]` 中的可选消息。图片附件遵循同一规则:执行器只负责把它们准入为持久化附件对象,是否以及如何成为模型可见的消息内容由声明接受的生产方决定。
#### Token 影响
diff --git a/packages/interaction/commands/package.json b/packages/interaction/commands/package.json
index 84e2d41b88..746d2aabb9 100644
--- a/packages/interaction/commands/package.json
+++ b/packages/interaction/commands/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-commands",
"description": "Plugin-owned human command registry for DeepSeek Harness UIs",
- "version": "0.1.0-rc.7",
+ "version": "0.1.0-rc.8",
"publishConfig": {
"access": "public"
},
diff --git a/packages/interaction/permission-presets/README.i18n.yaml b/packages/interaction/permission-presets/README.i18n.yaml
index 7e7359526c..d547571951 100644
--- a/packages/interaction/permission-presets/README.i18n.yaml
+++ b/packages/interaction/permission-presets/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/interaction/permission-presets/README.md
-README.md: 2b671f9e6e835c529453dc7d4ca7bc2eff01f2b6
-README.zh.md: 686138c1f2b0b3770771d0e6d6a785ed17cc8621
+README.md: 7e993ece949aa54d0810d8a7434d3e74d86f4797
+README.zh.md: 2946f340b2023f707d4d00c9b8161a71b4d05853
diff --git a/packages/interaction/permission-presets/README.md b/packages/interaction/permission-presets/README.md
index 2b671f9e6e..7e993ece94 100644
--- a/packages/interaction/permission-presets/README.md
+++ b/packages/interaction/permission-presets/README.md
@@ -4,17 +4,17 @@ English | [中文](README.zh.md)
User-facing permission presets through `ctx.permissionPresets` ([`PermissionPresetService`](src/index.ts)). Each configured name bundles `sandbox/mode` with `approval/policy`; the defaults are `workspace-write` (`workspace-write` + `ask`) and `danger-full-access` (`danger-full-access` + `never`). UI adapters may expose the table as one selector, while sandbox execution and approval continue to consume their own knobs.
-`set(session, name)` records a changed selection in a log-only `permissionPresets/preset` event, then calls each knob's setter only when its effective value changes. The selection event precedes the knob events and preserves user intent when presets share a bundle; a net-zero selection appends nothing. `current(events)` prefers a still-matching recorded selection, then the first matching table entry, and otherwise returns `custom`. Clients may display `custom` as the current value, but cannot select it.
+`set(session, name)` records a changed selection in a log-only `permission/preset` event, then calls each knob's setter only when its effective value changes. The selection event precedes the knob events and preserves user intent when presets share a bundle; a net-zero selection appends nothing. `current(events)` prefers a still-matching recorded selection, then the first matching table entry, and otherwise returns `custom`. Clients may display `custom` as the current value, but cannot select it.
-The service owns the `permissionPresets` Settings namespace. Its `defaultPreset` is the default for future sessions: the composition entry uses `Config.defaultPreset`, or infers the preset matching the composed sandbox and approval defaults when omitted. A committed Settings change is read when the next session is created; creation pins `permissionPresets/preset`, `sandbox/mode`, and `approval/policy` into that session, so later changes never alter an existing session. A resumed seed, including an explicitly empty one marked by `session/end-seed`, preserves its effective permission and receives only missing durable facts rather than the latest user default. Mounting the service also sweeps already-live sessions, so an HMR replacement pins any session created while the plugin was absent.
+The service owns the `permission` Settings namespace. Its `defaultPreset` initializes fresh sessions and default-origin Workspace blanks that Web explicitly confirms for New Session reuse: the composition entry uses `Config.defaultPreset`, or infers the preset matching the composed sandbox and approval defaults when omitted. Session creation reads the current setting and pins `permission/preset`, `sandbox/mode`, and `approval/policy`; the preset fact records whether it came from the default, an explicit selection, or legacy-knob inference. A committed setting change does not scan or rewrite existing sessions. When the Web workspace runtime selects a member blank with matching cwd that is not archived, the host rechecks those facts, adopts that exact session, including a cold persisted one, and advances it only when it has not started a turn, its latest selection is default-origin, and its effective knobs still match that selection. Explicit picks, inferred or origin-less legacy selections, independently changed knobs, and ordinary seeded resumes remain pinned. Mounting the service also sweeps already-live sessions, so an HMR replacement pins any session created while the plugin was absent.
The service requires a confining `ctx.shell` executor and `ctx.approval`. A table entry named `custom` throws at load. When composition defaults match no preset, the plugin requires an explicit `defaultPreset`; an independently constructed zero-event session may still derive `custom`. See the [sandbox switching design](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
-Two optional children ship the product surfaces over the same service: a `permissions` session-projection unit (`src/types.ts` declares the key; the unit folds the three whole-value knob events and views the select — table options plus a current-only `custom` — over the composition defaults) and the `/permissionPresets` command (bare invocation reports the current preset and the table; a preset argument switches through `set`). Each child activates only when its registry (`ctx.sessionProjections` / `ctx.commands`) is composed.
+Two optional children ship the product surfaces over the same service: a `permissions` session-projection unit (`src/types.ts` declares the key; the unit folds the three whole-value knob events and views the select — table options plus a current-only `custom` — over the composition defaults) and the `/permission` command (bare invocation reports the current preset and the table; a preset argument switches through `set`). Each child activates only when its registry (`ctx.sessionProjections` / `ctx.commands`) is composed.
## Model Experience
-Indirectly, through `dsh-user-approval` and `dsh-tool-bash`, which render the approval-policy prompt, switch notice, and sandboxed tool outcomes selected by this service's knob events; `permissionPresets/preset` itself is log-only.
+Indirectly, through `dsh-user-approval` and `dsh-tool-bash`, which render the approval-policy prompt, switch notice, and sandboxed tool outcomes selected by this service's knob events; `permission/preset` itself is log-only.
#### KV Cache effect
@@ -25,4 +25,4 @@ No direct invalidation; the named consumer owns any request-prefix changes.
- **Only two mechanism knobs are bundled** — presets select sandbox mode and approval policy; an agent/profile choice is not part of `PresetSpec` yet.
- **`custom` is derived-only** — callers can switch away from an unmatched knob combination but cannot target or persist a named custom preset through this service.
- **The preset table is process-level** — configuration is fixed for the plugin lifetime; changing available presets requires reloading the plugin.
-- **Stored defaults must remain in the preset table** — removing the referenced preset makes Permission settings registration fail until the `permissionPresets` section in `settings.yaml` is updated or reset.
+- **Stored defaults must remain in the preset table** — removing the referenced preset makes Permission settings registration fail until the `permission` section in `settings.yaml` is updated or reset.
diff --git a/packages/interaction/permission-presets/README.zh.md b/packages/interaction/permission-presets/README.zh.md
index 686138c1f2..2946f340b2 100644
--- a/packages/interaction/permission-presets/README.zh.md
+++ b/packages/interaction/permission-presets/README.zh.md
@@ -4,17 +4,17 @@
通过 `ctx.permissionPresets`([`PermissionPresetService`](src/index.ts))提供面向用户的权限预设。每个配置名称都会将 `sandbox/mode` 与 `approval/policy` 组成一组;默认项为 `workspace-write`(`workspace-write` + `ask`)和 `danger-full-access`(`danger-full-access` + `never`)。UI 适配器可以将该表作为单个选择器公开,而沙箱执行与审批仍分别消费各自的调节项。
-`set(session, name)` 会先在仅写日志的 `permissionPresets/preset` 事件中记录已变更的选择,再仅对实际值发生变化的调节项调用 setter。选择事件先于调节项事件,并在多个预设共享同一组取值时保留用户意图;净变化为零的选择不会追加任何内容。`current(events)` 优先返回仍与当前调节项匹配的已记录选择,其次返回表中第一个匹配项,否则返回 `custom`。客户端可以把 `custom` 显示为当前值,但不能选择它。
+`set(session, name)` 会先在仅写日志的 `permission/preset` 事件中记录已变更的选择,再仅对实际值发生变化的调节项调用 setter。选择事件先于调节项事件,并在多个预设共享同一组取值时保留用户意图;净变化为零的选择不会追加任何内容。`current(events)` 优先返回仍与当前调节项匹配的已记录选择,其次返回表中第一个匹配项,否则返回 `custom`。客户端可以把 `custom` 显示为当前值,但不能选择它。
-该服务拥有 `permissionPresets` Settings namespace。其 `defaultPreset` 是未来会话的默认值:组合项使用 `Config.defaultPreset`;省略时,则推断与组合后的沙箱和审批默认值匹配的 preset。已提交的 Settings 变更会在下一个会话创建时读取;创建过程将 `permissionPresets/preset`、`sandbox/mode` 和 `approval/policy` 固定到该会话中,因此后续变更绝不会改变现有会话。恢复的 seed,包括由 `session/end-seed` 标记的显式空 seed,都会保留其有效权限,只补齐缺失的持久事实,而不会采用最新的用户默认值。挂载服务时还会遍历所有已存活会话,因此 HMR(热模块替换)会固定插件缺席期间创建的所有会话。
+该服务拥有 `permission` Settings namespace。其 `defaultPreset` 会初始化新建会话,以及 Web 明确确认为新会话复用目标、且权限来自默认值的 Workspace 空白会话:组合项使用 `Config.defaultPreset`;省略时,则推断与组合后的沙箱和审批默认值匹配的 preset。创建会话时会读取当前设置,并固定 `permission/preset`、`sandbox/mode` 和 `approval/policy`;preset 事实还会记录它来自默认值、显式选择还是旧旋钮推断。已提交的设置变更不会扫描或改写现有会话。当 Web workspace runtime 选中 cwd 匹配、属于该 Workspace 且未归档的空白会话时,host 会重新检查这些事实,并接纳这个确切会话(包括冷存储中的持久会话),而且只在它尚未开始轮次、最近选择来自默认值且有效旋钮仍匹配该选择时推进默认值。显式选择、由旧旋钮推断或没有来源标记的旧选择、独立变更的旋钮,以及普通 seed 恢复都会继续固定原权限。挂载服务时还会遍历所有已存活会话,因此 HMR(热模块替换)会固定插件缺席期间创建的所有会话。
-该服务要求存在具有约束能力的 `ctx.shell` 执行器和 `ctx.approval`。表中名为 `custom` 的条目会在加载时抛出异常。当组合默认值与任何 preset 都不匹配时,插件要求显式配置 `defaultPreset`;独立构造的零事件会话仍可能推导出 `custom`。详见[沙箱切换设计](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。
+该服务要求存在具有约束能力的 `ctx.shell` 执行器和 `ctx.approval`。表中名为 `custom` 的条目会在加载时抛出异常。当组合默认值与任何 preset 都不匹配时,插件要求显式配置 `defaultPreset`;独立构造的零事件会话仍可能推导出 `custom`。详见[沙箱切换设计](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md)。
-两个可选子功能在同一服务之上提供产品界面:`permissions` 会话投影单元(`src/types.ts` 声明该 key;单元以组合默认值为基础折叠三个全量值可调参数事件,并生成选择器视图,其中包含表内选项和仅作当前值的 `custom`)与 `/permissionPresets` 命令(不带参数调用时报告当前预设与表;预设参数经 `set` 切换)。每个子功能仅在其注册表(`ctx.sessionProjections` / `ctx.commands`)被组合时激活。
+两个可选子功能在同一服务之上提供产品界面:`permissions` 会话投影单元(`src/types.ts` 声明该 key;单元以组合默认值为基础折叠三个全量值可调参数事件,并生成选择器视图,其中包含表内选项和仅作当前值的 `custom`)与 `/permission` 命令(不带参数调用时报告当前预设与表;预设参数经 `set` 切换)。每个子功能仅在其注册表(`ctx.sessionProjections` / `ctx.commands`)被组合时激活。
## 模型体验
-间接地,通过 `dsh-user-approval` 和 `dsh-tool-bash`:二者会渲染由此服务的可调参数事件所选择的审批策略提示词、切换通知和沙箱工具结果;`permissionPresets/preset` 本身只写入日志。
+间接地,通过 `dsh-user-approval` 和 `dsh-tool-bash`:二者会渲染由此服务的可调参数事件所选择的审批策略提示词、切换通知和沙箱工具结果;`permission/preset` 本身只写入日志。
#### KV Cache 影响
@@ -25,4 +25,4 @@
- **只组合两个机制级可调参数**:预设选择沙箱模式和审批策略;agent(智能体)/profile 选择尚未纳入 `PresetSpec`。
- **`custom` 只能推导得出**:调用方可以从不匹配的调节项组合切换出去,但无法通过此服务选中或持久化一个名为 custom 的预设。
- **预设表是进程级配置**:配置在插件生命周期内固定;更改可用预设必须重新加载插件。
-- **已存储的默认值必须保留在 preset 表中**:移除被引用的 preset 会导致权限设置注册失败,直到更新或重置 `settings.yaml` 中的 `permissionPresets` 分节。
+- **已存储的默认值必须保留在 preset 表中**:移除被引用的 preset 会导致权限设置注册失败,直到更新或重置 `settings.yaml` 中的 `permission` 分节。
diff --git a/packages/interaction/permission-presets/package.json b/packages/interaction/permission-presets/package.json
index 59e7f4abeb..79243339f4 100644
--- a/packages/interaction/permission-presets/package.json
+++ b/packages/interaction/permission-presets/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-permission-presets",
"description": "User-facing permission presets (ctx.permissionPresets) for the DeepSeek Harness: one product-level Permissions select bundling the sandbox-mode and approval-policy knobs, written through to their own session events",
- "version": "0.1.0-rc.7",
+ "version": "0.1.0-rc.8",
"publishConfig": {
"access": "public"
},
diff --git a/packages/interaction/permission-presets/src/index.ts b/packages/interaction/permission-presets/src/index.ts
index fee29c2644..333edb808c 100644
--- a/packages/interaction/permission-presets/src/index.ts
+++ b/packages/interaction/permission-presets/src/index.ts
@@ -42,12 +42,15 @@ declare module '@deepseek-ai/cordis' {
declare module '@deepseek-ai/dsh-session/types' {
interface SessionEventMap {
/**
- * Records the selected preset as durable, log-only user intent. The knob
+ * Records the selected preset and whether it came from the session
+ * default, an explicit selection, or legacy-knob inference. The knob
* events follow in the same turn and control execution; this event stays
* out of the model transcript and lets {@link effectivePermissionPreset}
- * preserve a selection when bundles match.
+ * preserve a selection when bundles match. `origin` is optional so logs
+ * written before origin tracking remain readable but are never mistaken
+ * for refreshable defaults.
*/
- 'permission/preset': { preset: string }
+ 'permission/preset': { preset: string; origin?: 'default' | 'selection' | 'inferred' }
}
}
@@ -69,7 +72,7 @@ export interface PresetSpec {
*/
export const CUSTOM_PRESET = 'custom'
-/** Settings namespace carrying the default for future sessions. */
+/** Settings namespace carrying the default for fresh sessions and confirmed reusable blanks. */
export const PERMISSION_SETTINGS_NAMESPACE = settingsNamespace('permission')
/**
@@ -100,6 +103,22 @@ export interface KnobState {
approval: ApprovalPolicy | null
}
+declare module '@deepseek-ai/dsh-session-projection/types' {
+ interface SessionProjectionStateMap {
+ permissions: KnobState
+ }
+}
+
+const knobStateSchema: zod.ZodType = zod.object({
+ preset: zod.string().nullable(),
+ sandbox: zod.union([
+ zod.literal('read-only'),
+ zod.literal('workspace-write'),
+ zod.literal('danger-full-access'),
+ ]).nullable(),
+ approval: zod.union([zod.literal('ask'), zod.literal('never')]).nullable(),
+}).strict()
+
/** State for the empty log: every knob at its composition default. */
const EMPTY_KNOBS: KnobState = { preset: null, sandbox: null, approval: null }
@@ -130,9 +149,18 @@ function foldKnobs(events: readonly SessionEvent[]): KnobState {
return state
}
+/** Last recorded permission selection, including its durable origin. */
+function latestPermissionSelection(events: readonly SessionEvent[]): Extract | undefined {
+ for (let index = events.length - 1; index >= 0; index -= 1) {
+ const event = events[index] as SessionEvent
+ if (event.type === 'permission/preset') return event
+ }
+ return undefined
+}
+
/** User setting resolved when a new session receives its initial permission. */
export interface PermissionSettings {
- /** Preset pinned into a newly created session. */
+ /** Preset pinned into a fresh session or an eligible confirmed blank reuse. */
defaultPreset: string
}
@@ -145,8 +173,9 @@ export interface Config {
*/
presets?: Record
/**
- * Default for new sessions. When omitted, the preset matching the composed
- * sandbox and approval defaults is used.
+ * Default for fresh sessions and eligible confirmed blank reuse. When
+ * omitted, the preset matching the composed sandbox and approval defaults
+ * is used.
*/
defaultPreset?: string
}
@@ -212,8 +241,6 @@ export class PermissionPresetService extends Service {
setSource: (current) => {
this.defaultSettings = current
},
- // The source thunk reads the latest scope snapshot at session creation;
- // no process-level registration needs replacement on change.
onChange: () => {},
})
@@ -243,10 +270,10 @@ export class PermissionPresetService extends Service {
ctx.inject(['sessionProjections'], (projectionCtx) => {
projectionCtx.sessionProjections.register<'permissions', KnobState>({
key: 'permissions',
- schema: selectSchema,
+ stateSchema: knobStateSchema,
init: () => EMPTY_KNOBS,
apply: applyKnobEvent,
- view: state => this.selectFor(state),
+ wire: { viewSchema: selectSchema, view: state => this.selectFor(state) },
stateVersion: 1,
})
})
@@ -270,7 +297,9 @@ export class PermissionPresetService extends Service {
if (!this.names.includes(name)) {
return { kind: 'error', text: `unknown preset "${name}" (available: ${this.names.join(', ')})` }
}
- this.apply(agent.session, name, (policy) =>{ this.ctx.approval.setPolicy(agent, policy) })
+ this.apply(agent.session, name, (policy) => {
+ this.ctx.approval.setPolicy(agent, policy)
+ }, 'selection')
return { kind: 'success', text: `preset ${name}` }
},
})
@@ -286,7 +315,7 @@ export class PermissionPresetService extends Service {
}
/**
- * The preset currently selected as the default for future sessions.
+ * The preset currently selected for fresh sessions and confirmed blank reuse.
* @returns the resolved settings value, or the composition default without
* a mounted settings provider.
*/
@@ -305,6 +334,26 @@ export class PermissionPresetService extends Service {
return this.derive(foldKnobs(events))
}
+ /**
+ * Advance one blank session after the host has confirmed it as the exact
+ * Web New Session reuse target. Only a still-effective
+ * default-origin selection advances; a started session, an explicit pick,
+ * legacy origin-less data, or independently changed knobs remain pinned.
+ * This is the permission-side half of the Web candidate selection and the
+ * host's blankness, membership, cwd, and archive verification.
+ * @param session - the live session selected for Workspace blank reuse.
+ */
+ refreshDefaultForReuse(session: Session): void {
+ const events = session.events
+ if (events.some(event => event.type === 'turn/start')) return
+ const selected = latestPermissionSelection(events)
+ if (selected?.data.origin !== 'default') return
+ if (this.current(events) !== selected.data.preset) return
+ this.apply(session, this.defaultPreset, (policy) => {
+ setApprovalPolicy(session, policy)
+ }, 'default')
+ }
+
/** Resolve the preset for one folded knob state (the shared mathematics of `current` and the projection unit). */
private derive(state: KnobState): string {
const sandbox = state.sandbox ?? this.ctx.shell.sandboxMode
@@ -373,14 +422,21 @@ export class PermissionPresetService extends Service {
* @param name - the preset to switch to; unknown names throw.
*/
set(session: Session, name: string): void {
- this.apply(session, name, (policy) =>{ setApprovalPolicy(session, policy) })
+ this.apply(session, name, (policy) => {
+ setApprovalPolicy(session, policy)
+ }, 'selection')
}
/** Apply one preset with the caller-selected live or initialization policy writer. */
- private apply(session: Session, name: string, setApproval: (policy: ApprovalPolicy) => void): void {
+ private apply(
+ session: Session,
+ name: string,
+ setApproval: (policy: ApprovalPolicy) => void,
+ origin: 'default' | 'selection' | 'inferred',
+ ): void {
const spec = this.resolve(name)
if (this.current(session.events) !== name) {
- session.append('permission/preset', { preset: name })
+ session.append('permission/preset', { preset: name, origin })
}
const events = session.events
if (spec.sandbox !== (effectiveSandboxMode(events) ?? this.ctx.shell.sandboxMode)) {
@@ -406,7 +462,7 @@ export class PermissionPresetService extends Service {
if (selected === undefined && sandbox === undefined && approval === undefined && !seeded) {
const name = this.defaultPreset
const spec = this.resolve(name)
- session.append('permission/preset', { preset: name })
+ session.append('permission/preset', { preset: name, origin: 'default' })
setSandboxMode(session, spec.sandbox)
setApprovalPolicy(session, spec.approval)
return
@@ -419,7 +475,7 @@ export class PermissionPresetService extends Service {
}
const effective = this.derive(state)
if (selected === undefined && effective !== CUSTOM_PRESET) {
- session.append('permission/preset', { preset: effective })
+ session.append('permission/preset', { preset: effective, origin: 'inferred' })
}
if (sandbox === undefined) {
setSandboxMode(session, this.ctx.shell.sandboxMode as SandboxMode)
diff --git a/packages/interaction/permission-presets/tests/permission-presets.spec.ts b/packages/interaction/permission-presets/tests/permission-presets.spec.ts
index 1f0e8760b8..222e609ce0 100644
--- a/packages/interaction/permission-presets/tests/permission-presets.spec.ts
+++ b/packages/interaction/permission-presets/tests/permission-presets.spec.ts
@@ -131,7 +131,7 @@ describe('PermissionPresetService', () => {
const session = freshSession('sess-set')
ctx.permissionPresets.set(session, 'danger-full-access')
expect(session.events.map(e => [e.type, e.data])).toEqual([
- ['permission/preset', { preset: 'danger-full-access' }],
+ ['permission/preset', { preset: 'danger-full-access', origin: 'selection' }],
['sandbox/mode', { mode: 'danger-full-access' }],
['approval/policy', { policy: 'never' }],
])
@@ -154,7 +154,7 @@ describe('PermissionPresetService', () => {
ctx.permissionPresets.set(session, 'danger-full-access')
const tail = session.events.slice(4)
expect(tail.map(e => [e.type, e.data])).toEqual([
- ['permission/preset', { preset: 'danger-full-access' }],
+ ['permission/preset', { preset: 'danger-full-access', origin: 'selection' }],
['sandbox/mode', { mode: 'danger-full-access' }],
])
})
@@ -197,14 +197,16 @@ describe('new-session default', () => {
const ctx = await mountedStore()
const first = ctx.sessions.create(SessionId('first'))
expect(first.events.map(event => [event.type, event.data])).toEqual([
- ['permission/preset', { preset: 'workspace-write' }],
+ ['permission/preset', { preset: 'workspace-write', origin: 'default' }],
['sandbox/mode', { mode: 'workspace-write' }],
['approval/policy', { policy: 'ask' }],
])
+ first.append('turn/start', { turn: 1 })
await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, {
defaultPreset: 'danger-full-access',
})
+ ctx.permissionPresets.refreshDefaultForReuse(first)
expect(ctx.permissionPresets.defaultPreset).toBe('danger-full-access')
const second = ctx.sessions.create(SessionId('second'))
expect(ctx.permissionPresets.current(first.events)).toBe('workspace-write')
@@ -214,6 +216,89 @@ describe('new-session default', () => {
])
})
+ it('advances a confirmed reusable blank session that still carries its default', async () => {
+ const ctx = await mountedStore()
+ const blank = ctx.sessions.create(SessionId('blank-placeholder'))
+ expect(ctx.permissionPresets.current(blank.events)).toBe('workspace-write')
+
+ await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, {
+ defaultPreset: 'danger-full-access',
+ })
+ expect(ctx.permissionPresets.current(blank.events)).toBe('workspace-write')
+ ctx.permissionPresets.refreshDefaultForReuse(blank)
+
+ expect(ctx.permissionPresets.current(blank.events)).toBe('danger-full-access')
+ expect(blank.events.map(event => [event.type, event.data])).toEqual([
+ ['permission/preset', { preset: 'workspace-write', origin: 'default' }],
+ ['sandbox/mode', { mode: 'workspace-write' }],
+ ['approval/policy', { policy: 'ask' }],
+ ['permission/preset', { preset: 'danger-full-access', origin: 'default' }],
+ ['sandbox/mode', { mode: 'danger-full-access' }],
+ ['approval/policy', { policy: 'never' }],
+ ])
+ })
+
+ it('leaves explicit, inferred, legacy, absent, and independently changed selections unchanged', async () => {
+ const ctx = await mountedStore()
+ const picked = ctx.sessions.create(SessionId('blank-explicit-pick'))
+ ctx.permissionPresets.set(picked, 'danger-full-access')
+ const pickedEvents = [...picked.events]
+
+ const restored = ctx.sessions.create(SessionId('blank-restored'), { seed: [] })
+ expect(ctx.permissionPresets.current(restored.events)).toBe('workspace-write')
+ const restoredEvents = [...restored.events]
+
+ const drifted = ctx.sessions.create(SessionId('blank-drifted-knob'))
+ drifted.append('sandbox/mode', { mode: 'read-only' })
+ const driftedEvents = [...drifted.events]
+
+ const legacy = freshSession('blank-originless-selection')
+ legacy.append('permission/preset', { preset: 'workspace-write' })
+ legacy.append('sandbox/mode', { mode: 'workspace-write' })
+ legacy.append('approval/policy', { policy: 'ask' })
+ const legacyEvents = [...legacy.events]
+
+ const absent = freshSession('blank-without-selection')
+
+ await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, {
+ defaultPreset: 'danger-full-access',
+ })
+ await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, {
+ defaultPreset: 'workspace-write',
+ })
+ ctx.permissionPresets.refreshDefaultForReuse(picked)
+ ctx.permissionPresets.refreshDefaultForReuse(restored)
+ ctx.permissionPresets.refreshDefaultForReuse(drifted)
+ ctx.permissionPresets.refreshDefaultForReuse(legacy)
+ ctx.permissionPresets.refreshDefaultForReuse(absent)
+
+ expect(picked.events).toEqual(pickedEvents)
+ expect(restored.events).toEqual(restoredEvents)
+ expect(drifted.events).toEqual(driftedEvents)
+ expect(legacy.events).toEqual(legacyEvents)
+ expect(absent.events).toEqual([])
+ })
+
+ it('refreshes a cold default-origin placeholder after resume', async () => {
+ const ctx = await mountedStore()
+ const source = ctx.sessions.create(SessionId('cold-placeholder-source'))
+ const stored = [...source.events]
+ await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, {
+ defaultPreset: 'danger-full-access',
+ })
+ const resumed = ctx.sessions.create(SessionId('cold-placeholder-resumed'), { seed: stored })
+ expect(ctx.permissionPresets.current(resumed.events)).toBe('workspace-write')
+
+ ctx.permissionPresets.refreshDefaultForReuse(resumed)
+
+ expect(ctx.permissionPresets.current(resumed.events)).toBe('danger-full-access')
+ expect(resumed.events.slice(-3).map(event => [event.type, event.data])).toEqual([
+ ['permission/preset', { preset: 'danger-full-access', origin: 'default' }],
+ ['sandbox/mode', { mode: 'danger-full-access' }],
+ ['approval/policy', { policy: 'never' }],
+ ])
+ })
+
it('preserves a seeded legacy session instead of applying the latest user default', async () => {
const ctx = await mountedStore()
await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, {
diff --git a/packages/interaction/tool-ask-user/README.i18n.yaml b/packages/interaction/tool-ask-user/README.i18n.yaml
index ed6cd6a35f..dd69a79586 100644
--- a/packages/interaction/tool-ask-user/README.i18n.yaml
+++ b/packages/interaction/tool-ask-user/README.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/interaction/tool-ask-user/README.md
README.md: bf2ba369fca36c462c6155ed2354ab3d0e930dd0
-README.zh.md: 671bea0132093b890de4ce27095fa60dac396276
+README.zh.md: 067235d84d70d2f3278fd39f5c01f130373d3ac2
diff --git a/packages/interaction/tool-ask-user/README.zh.md b/packages/interaction/tool-ask-user/README.zh.md
index 671bea0132..067235d84d 100644
--- a/packages/interaction/tool-ask-user/README.zh.md
+++ b/packages/interaction/tool-ask-user/README.zh.md
@@ -27,7 +27,7 @@
#### 模型看到的内容
-模型会看到生成的 [`ask_user_question` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-ask-user),其中包含问题 id、提示语、标题、选项和多选标志。
+模型会看到生成的 [`ask_user_question` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-ask-user),其中包含问题 id、提示语、标题、选项和多选标志。
#### Token 影响
diff --git a/packages/interaction/tool-ask-user/package.json b/packages/interaction/tool-ask-user/package.json
index 0e11850943..8629460671 100644
--- a/packages/interaction/tool-ask-user/package.json
+++ b/packages/interaction/tool-ask-user/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-tool-ask-user",
"description": "Model-facing ask_user_question tool over the ctx.userQuestions seam",
- "version": "0.1.0-rc.7",
+ "version": "0.1.0-rc.8",
"publishConfig": {
"access": "public"
},
diff --git a/packages/interaction/user-approval/README.i18n.yaml b/packages/interaction/user-approval/README.i18n.yaml
index 07843b5f58..ba340c5273 100644
--- a/packages/interaction/user-approval/README.i18n.yaml
+++ b/packages/interaction/user-approval/README.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/interaction/user-approval/README.md
README.md: 0cf5d458863194e29f8c84168a6f089baabbf3d2
-README.zh.md: ccd8641e30d8a78611eabf602fa46d2705f24487
+README.zh.md: a93f9c17c89ea50622e354eb7729547660e877e2
diff --git a/packages/interaction/user-approval/README.zh.md b/packages/interaction/user-approval/README.zh.md
index ccd8641e30..a93f9c17c8 100644
--- a/packages/interaction/user-approval/README.zh.md
+++ b/packages/interaction/user-approval/README.zh.md
@@ -2,7 +2,7 @@
[English](README.md) | 中文
-与通道无关的一次性审批 seam。`ctx.approval.request(req)` 返回 `allowed-once`、`rejected`、`cancelled` 或 `unavailable`;应答者缺失或失败时会以拒绝方式关闭,授权也只适用于所请求的操作。确切事件签名见 [approval.md](../../../docs/subsystems/approval.md#cordis-surface) 的生成区块。
+与通道无关的一次性审批 seam。`ctx.approval.request(req)` 返回 `allowed-once`、`rejected`、`cancelled` 或 `unavailable`;应答者缺失或失败时会以拒绝方式关闭,授权也只适用于所请求的操作。确切事件签名见 [approval.md](../../../docs/subsystems/approval.zh.md#cordis-surface) 的生成区块。
每个请求都必须属于一个尚未结束的 agent(智能体)轮次。服务会追加一对 `approval/asked` 与 `approval/decided` 审计记录,而模型只会看到由此产生且已写入日志的工具结果。已中止的请求会解析为 `cancelled`;如果审计记录的追加在提交前失败,Promise 会被拒绝,而不会返回一项未记录的决定。
@@ -10,7 +10,7 @@
`ApprovalPolicy` 为 `'ask'` 或 `'never'`。实际值取最后一条 `approval/policy` 事件,并回退到配置;`setApprovalPolicy()` 是写入路径。`'never'` 会在交互式分发之前拒绝请求。两种策略都会将各自完整的当前含义贡献给缓存安全的运行时上下文快照。
-工具流水线通过此 seam 路由 `ask` 决定,并在该 seam 缺失时以拒绝方式关闭;沙箱 bash 工具也会将它用于升权重试。ACP 自动化桥接层根据客户端的机器策略,回答其自有 agent 的调用。审计事件仍只写入日志,因此模型只会看到发起请求的消费方所返回的结果。详见[审批 seam Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-approval-seam.md)和[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。
+工具流水线通过此 seam 路由 `ask` 决定,并在该 seam 缺失时以拒绝方式关闭;沙箱 bash 工具也会将它用于升权重试。ACP 自动化桥接层根据客户端的机器策略,回答其自有 agent 的调用。审计事件仍只写入日志,因此模型只会看到发起请求的消费方所返回的结果。详见[审批 seam Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md)和[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md)。
## 模型体验
diff --git a/packages/interaction/user-approval/package.json b/packages/interaction/user-approval/package.json
index 06f72c1e2f..86dccb7ef7 100644
--- a/packages/interaction/user-approval/package.json
+++ b/packages/interaction/user-approval/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-user-approval",
"description": "User-approval seam (ctx.approval) for the DeepSeek Harness: one-shot permission decisions dispatched to composed answerers over the approval/request waterfall, fail-closed by default",
- "version": "0.1.0-rc.7",
+ "version": "0.1.0-rc.8",
"publishConfig": {
"access": "public"
},
diff --git a/packages/interaction/user-questions/package.json b/packages/interaction/user-questions/package.json
index 30709a00f3..ae3f841363 100644
--- a/packages/interaction/user-questions/package.json
+++ b/packages/interaction/user-questions/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-user-questions",
"description": "Abstract user-questions seam (ctx.userQuestions) for asking the human during agent runs",
- "version": "0.1.0-rc.7",
+ "version": "0.1.0-rc.8",
"publishConfig": {
"access": "public"
},
diff --git a/packages/jobs/README.i18n.yaml b/packages/jobs/README.i18n.yaml
index 6c032dcbf0..121481d6ab 100644
--- a/packages/jobs/README.i18n.yaml
+++ b/packages/jobs/README.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/jobs/README.md
README.md: daad96ae561f8f943759332b6b1ea1e20fcaf40e
-README.zh.md: 4e733b1da09656c9d779c43a0ee77703e1c10bfa
+README.zh.md: 586f195f2603cd1b8e4cd5e23174841cb1a39910
diff --git a/packages/jobs/README.zh.md b/packages/jobs/README.zh.md
index 4e733b1da0..586f195f26 100644
--- a/packages/jobs/README.zh.md
+++ b/packages/jobs/README.zh.md
@@ -6,10 +6,10 @@
| 包 | 职责 | ctx 键 |
|---|---|---|
-| [`jobs/`](jobs/README.md) | 定义任务注册表和生命周期约定 | `ctx.jobs` |
-| [`jobs-local/`](jobs-local/README.md) | 实现进程本地任务注册表 | 注册到 `ctx.jobs` |
-| [`tool-jobs/`](tool-jobs/README.md) | 向模型公开任务控制和完成通知 | 注册到 `ctx.tools` |
+| [`jobs/`](jobs/README.zh.md) | 定义任务注册表和生命周期约定 | `ctx.jobs` |
+| [`jobs-local/`](jobs-local/README.zh.md) | 实现进程本地任务注册表 | 注册到 `ctx.jobs` |
+| [`tool-jobs/`](tool-jobs/README.zh.md) | 向模型公开任务控制和完成通知 | 注册到 `ctx.tools` |
-参见[后台任务运行时](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md)和[任务注册表](../../.agents/notes/implemented/architecture/2026-07-26-job-registry-seam.md)决策。
+参见[后台任务运行时](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md)和[任务注册表](../../.agents/notes/implemented/architecture/2026-07-26-job-registry-seam.zh.md)决策。
-子系统参考文档——id 方案、所有者隔离约定、快照——见 [docs/subsystems/jobs.md](../../docs/subsystems/jobs.md);设计见[后台任务运行时](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md)与[任务注册表约定](../../.agents/notes/implemented/architecture/2026-07-26-job-registry-seam.md)两篇 Agent Note。
+子系统参考文档——id 方案、所有者隔离约定、快照——见 [docs/subsystems/jobs.md](../../docs/subsystems/jobs.zh.md);设计见[后台任务运行时](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md)与[任务注册表约定](../../.agents/notes/implemented/architecture/2026-07-26-job-registry-seam.zh.md)两篇 Agent Note。
diff --git a/packages/jobs/jobs-local/README.i18n.yaml b/packages/jobs/jobs-local/README.i18n.yaml
index 7d7f6d2c9b..8aa1a4ea4b 100644
--- a/packages/jobs/jobs-local/README.i18n.yaml
+++ b/packages/jobs/jobs-local/README.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/jobs/jobs-local/README.md
README.md: e55d9d1747e0dbb12d22f2528527312334f352e6
-README.zh.md: 78979d9518b1b588c689ce5184b33d79177a1ec0
+README.zh.md: 86fd3e8b9db9d0073ce5e6999f5a1fbce12059d2
diff --git a/packages/jobs/jobs-local/README.zh.md b/packages/jobs/jobs-local/README.zh.md
index 78979d9518..86fd3e8b9d 100644
--- a/packages/jobs/jobs-local/README.zh.md
+++ b/packages/jobs/jobs-local/README.zh.md
@@ -2,7 +2,7 @@
[English](README.md) | 中文
-[`@deepseek-ai/dsh-jobs`](../jobs/README.md) 注册表约定的进程本地实现:`LocalJobRegistry` 把每条记录保存在内存中,按 kind 签发 `-N` id,并且只交出全新快照,从不交出实时状态。作为插件加载后即注册为 `ctx.jobs`。
+[`@deepseek-ai/dsh-jobs`](../jobs/README.zh.md) 注册表约定的进程本地实现:`LocalJobRegistry` 把每条记录保存在内存中,按 kind 签发 `-N` id,并且只交出全新快照,从不交出实时状态。作为插件加载后即注册为 `ctx.jobs`。
## 准入
@@ -22,7 +22,7 @@
## 模型体验
-通过生产方插件和 [`dsh-tool-jobs`](../tool-jobs/README.md) 间接影响;它们会呈现 job id、输出、状态、取消和完成通知。
+通过生产方插件和 [`dsh-tool-jobs`](../tool-jobs/README.zh.md) 间接影响;它们会呈现 job id、输出、状态、取消和完成通知。
#### KV Cache 影响
diff --git a/packages/jobs/jobs-local/package.json b/packages/jobs/jobs-local/package.json
index a615cded8b..e33cf78715 100644
--- a/packages/jobs/jobs-local/package.json
+++ b/packages/jobs/jobs-local/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-jobs-local",
"description": "Process-local implementation of the DeepSeek Harness background job registry seam",
- "version": "0.1.0-rc.7",
+ "version": "0.1.0-rc.8",
"publishConfig": {
"access": "public"
},
diff --git a/packages/jobs/jobs/README.i18n.yaml b/packages/jobs/jobs/README.i18n.yaml
index 0473d974f8..8129e170ed 100644
--- a/packages/jobs/jobs/README.i18n.yaml
+++ b/packages/jobs/jobs/README.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/jobs/jobs/README.md
README.md: f4469286603f687be5789bd016ab462dba26e664
-README.zh.md: 30d8e4cb1e298bb4a3a0a3e65bb0858b2458a277
+README.zh.md: 6fd97aa3fe4cbbd555c2475f4132ec00c6a97390
diff --git a/packages/jobs/jobs/README.zh.md b/packages/jobs/jobs/README.zh.md
index 30d8e4cb1e..6fd97aa3fe 100644
--- a/packages/jobs/jobs/README.zh.md
+++ b/packages/jobs/jobs/README.zh.md
@@ -2,7 +2,7 @@
[English](README.md) | 中文
-后台任务注册表约定(`ctx.jobs`)。抽象的 `JobRegistry` 及其词汇类型在同一份约定下为长时间运行的生产方提供共享 id、owner 隔离、读取、取消、等待、通知和清理;进程局部注册表位于 [`dsh-jobs-local`](../jobs-local/README.md)。生产方插件使用其不透明 id namespace 扩展 `JobKindMap`。
+后台任务注册表约定(`ctx.jobs`)。抽象的 `JobRegistry` 及其词汇类型在同一份约定下为长时间运行的生产方提供共享 id、owner 隔离、读取、取消、等待、通知和清理;进程局部注册表位于 [`dsh-jobs-local`](../jobs-local/README.zh.md)。生产方插件使用其不透明 id namespace 扩展 `JobKindMap`。
## 服务约定
@@ -23,11 +23,11 @@
实现还必须兑现约定的生命周期语义:注册的存续期长于生产方 fiber 与控制器 fiber,owner 释放和服务释放会取消仍在运行的工作并等待守约的生产方,结算遵循首次结果优先(一条终止记录、一轮异常受到隔离的监听器通知,然后释放等待方)。
-参见[任务类型目录](../../../docs/subsystems/jobs.md)、[运行时 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md)和 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-job-registry-seam.md)。
+参见[任务类型目录](../../../docs/subsystems/jobs.zh.md)、[运行时 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md)和 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-job-registry-seam.zh.md)。
## 模型体验
-通过生产方插件和 [`dsh-tool-jobs`](../tool-jobs/README.md) 间接影响;它们会渲染 job id、输出、状态、取消和完成通知。
+通过生产方插件和 [`dsh-tool-jobs`](../tool-jobs/README.zh.md) 间接影响;它们会渲染 job id、输出、状态、取消和完成通知。
#### KV Cache 影响
diff --git a/packages/jobs/jobs/package.json b/packages/jobs/jobs/package.json
index 6734d1ea37..333ca7deac 100644
--- a/packages/jobs/jobs/package.json
+++ b/packages/jobs/jobs/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-jobs",
"description": "Background job registry (ctx.jobs) for the DeepSeek Harness — shared ids, owner isolation, polling, cancellation, and completion listeners for long-running tool work",
- "version": "0.1.0-rc.7",
+ "version": "0.1.0-rc.8",
"publishConfig": {
"access": "public"
},
diff --git a/packages/jobs/tool-jobs/README.i18n.yaml b/packages/jobs/tool-jobs/README.i18n.yaml
index e1a51a2cbe..6b5cc133dc 100644
--- a/packages/jobs/tool-jobs/README.i18n.yaml
+++ b/packages/jobs/tool-jobs/README.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/jobs/tool-jobs/README.md
README.md: 8d6a00651f69258d764904ac99d000c2055982dc
-README.zh.md: 15903b6f263fe9d225c48bf567c46f587d23cb00
+README.zh.md: bd1045ecf6f2b7c9ed1f8a8cdfe4f965af418a37
diff --git a/packages/jobs/tool-jobs/README.zh.md b/packages/jobs/tool-jobs/README.zh.md
index 15903b6f26..bd1045ecf6 100644
--- a/packages/jobs/tool-jobs/README.zh.md
+++ b/packages/jobs/tool-jobs/README.zh.md
@@ -63,7 +63,7 @@ Track every background job id you start. You are notified in-session when a job
#### 模型看到的内容
-该工具集可见时,会看到生成的 [`job_output`、`job_list` 和 `job_kill` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-jobs)。
+该工具集可见时,会看到生成的 [`job_output`、`job_list` 和 `job_kill` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-jobs)。
#### Token 影响
diff --git a/packages/jobs/tool-jobs/package.json b/packages/jobs/tool-jobs/package.json
index 57f585ff50..b2efe581cd 100644
--- a/packages/jobs/tool-jobs/package.json
+++ b/packages/jobs/tool-jobs/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-tool-jobs",
"description": "Model-facing background job control tools (job_output, job_list, job_kill) over the ctx.jobs registry",
- "version": "0.1.0-rc.7",
+ "version": "0.1.0-rc.8",
"publishConfig": {
"access": "public"
},
diff --git a/packages/llm/README.i18n.yaml b/packages/llm/README.i18n.yaml
index a655518047..8dbce74a9d 100644
--- a/packages/llm/README.i18n.yaml
+++ b/packages/llm/README.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/llm/README.md
README.md: 15f90024abf3256560d67099af79be7d4e0ee310
-README.zh.md: 6be4123e2e5de5c41937c4b966580278c78e5a42
+README.zh.md: 31ccfd0c8d979f1dac1ec2c0d6a377c9277c3271
diff --git a/packages/llm/README.zh.md b/packages/llm/README.zh.md
index 6be4123e2e..31ccfd0c8d 100644
--- a/packages/llm/README.zh.md
+++ b/packages/llm/README.zh.md
@@ -6,12 +6,12 @@ LLM(大语言模型)seam 及其提供方适配器。`llm` 包同时承担 Se
| 包 | 职责 | ctx key |
|---|---|---|
-| [`llm/`](llm/README.md) | LLM 服务和共享流式词汇 | `ctx.llm` |
-| [`token-meter/`](token-meter/README.md) | 可感知回放的 token 测量 | `ctx.tokenMeter` |
-| [`llm-retry/`](llm-retry/README.md) | 提供方作用域的重试策略 | 监听 `agent/request-error` |
-| [`llm-deepseek/`](llm-deepseek/README.md) | 直接 DeepSeek 适配器 | 注册到 `ctx.llm` |
-| [`llm-pi-ai/`](llm-pi-ai/README.md) | 多提供方 pi-ai 适配器 | 注册到 `ctx.llm` |
+| [`llm/`](llm/README.zh.md) | LLM 服务和共享流式词汇 | `ctx.llm` |
+| [`token-meter/`](token-meter/README.zh.md) | 可感知回放的 token 测量 | `ctx.tokenMeter` |
+| [`llm-retry/`](llm-retry/README.zh.md) | 提供方作用域的重试策略 | 监听 `agent/request-error` |
+| [`llm-deepseek/`](llm-deepseek/README.zh.md) | 直接 DeepSeek 适配器 | 注册到 `ctx.llm` |
+| [`llm-pi-ai/`](llm-pi-ai/README.zh.md) | 多提供方 pi-ai 适配器 | 注册到 `ctx.llm` |
-适配器在 seam 上注册提供方路由;重试与 token 测量仍是独立消费方。子 README 负责路由、元数据、回放和提供方协议细节;[LLM 架构决策](../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md)说明设计原理。
+适配器在 seam 上注册提供方路由;重试与 token 测量仍是独立消费方。子 README 负责路由、元数据、回放和提供方协议细节;[LLM 架构决策](../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md)说明设计原理。
-子系统参考——消息与内容块、模型请求、`StreamChunk` 协议、适配器约定(adapter contract)——见 [docs/subsystems/llm-streaming.md](../../docs/subsystems/llm-streaming.md)(token 计量:[token-meter.md](../../docs/subsystems/token-meter.md));另见[孪生适配器](../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md)、[回放 token 计量](../../.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md)与[按路由模型上下文](../../.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md) Agent Note。
+子系统参考——消息与内容块、模型请求、`StreamChunk` 协议、适配器约定(adapter contract)——见 [docs/subsystems/llm-streaming.md](../../docs/subsystems/llm-streaming.zh.md)(token 计量:[token-meter.md](../../docs/subsystems/token-meter.zh.md));另见[孪生适配器](../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md)、[回放 token 计量](../../.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md)与[按路由模型上下文](../../.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.zh.md) Agent Note。
diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml
index 695a8add1d..82da16e4f0 100644
--- a/packages/llm/llm-deepseek/README.i18n.yaml
+++ b/packages/llm/llm-deepseek/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/llm/llm-deepseek/README.md
-README.md: 18303cfe36d80b9fcb76782d8829bcd76c4cbb0d
-README.zh.md: 22321f2d2b026428d16654b0f9304fa72774c523
+README.md: bae9011135a9cbc14467086e4b6ebc6f052ed230
+README.zh.md: 0a5f0224dbebd62766775822260585825579f4a7
diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md
index 18303cfe36..bae9011135 100644
--- a/packages/llm/llm-deepseek/README.md
+++ b/packages/llm/llm-deepseek/README.md
@@ -78,7 +78,7 @@ DeepSeek request identity is separate from app attribution. After credential res
- Streaming only (`stream_options.include_usage` always on). `usage` may arrive attached to the finish chunk or as a trailing usage-only chunk — the translator defers both to `[DONE]`, so `usage` always precedes `finish` and nothing follows `finish`.
- The adapter-owned `off` effort maps to `thinking: {type: 'disabled'}` and never crosses the wire as `reasoning_effort: 'off'`.
- The first thinking-mode chunk carries `reasoning_content: ""` — handled (no spurious reasoning block).
-- **Reasoning passback rule**: on assistant turns that carried tool calls, `reasoning_content` is serialized back in history (required by the API in thinking mode); on tool-call-free turns it is dropped (ignored anyway — saves tokens).
+- **Reasoning passback rule**: every assistant turn that carried reasoning serializes `reasoning_content` back in history. Thinking mode requires it on tool-call turns; DeepSeek ignores it elsewhere, while a gateway re-encoding the conversation for another vendor recovers that turn's upstream thinking signature by hashing the replayed text.
- Image-capable user messages preserve text/image order. Tool-role content remains a string; consecutive tool-result images are grouped into the following user message with `Attached image(s) from tool result:`.
- Cache accounting: `cacheReadTokens` ← `prompt_cache_hit_tokens` / `prompt_tokens_details.cached_tokens`; DeepSeek reports no cache-write metric.
@@ -92,15 +92,15 @@ Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `QUOTA`
#### What the model sees
-The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config without adapter-authored prompt prose. The vision model also receives retained user and tool-result images as base64 data URLs; an over-budget older image is represented by the documented placeholder. On a prior assistant turn with tool calls, its reasoning content is passed back as required; reasoning from tool-call-free turns is omitted.
+The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config without adapter-authored prompt prose. The vision model also receives retained user and tool-result images as base64 data URLs; an over-budget older image is represented by the documented placeholder. Reasoning content from a prior assistant turn is passed back verbatim, whether or not that turn called a tool.
#### Token effect
-Provider tokenization governs exact text and image-token input. Conditional reasoning passback increases tool-round-trip context, while dropping other reasoning and over-budget images avoids paying those tokens again; cache-read usage is reported when available.
+Provider tokenization governs exact text and image-token input. Reasoning passback carries every reasoned turn's chain of thought into later requests, while dropping over-budget images avoids paying those tokens again; cache-read usage is reported when available.
#### KV Cache effect
-An unchanged assembled prefix, including deterministically encoded retained images and placeholders, is eligible for DeepSeek cache reuse, which this adapter reports in usage. A model-route change or any upstream prompt, schema, prefix, history, or image-budget change may prevent reuse from the first changed token; reasoning passback appends during tool round trips.
+An unchanged assembled prefix, including deterministically encoded retained images and placeholders, is eligible for DeepSeek cache reuse, which this adapter reports in usage. A model-route change or any upstream prompt, schema, prefix, history, or image-budget change may prevent reuse from the first changed token; reasoning passback appends on every reasoned turn.
### DeepSeek response
diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md
index 22321f2d2b..0a5f0224db 100644
--- a/packages/llm/llm-deepseek/README.zh.md
+++ b/packages/llm/llm-deepseek/README.zh.md
@@ -69,16 +69,16 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器:
## 应用归因
-每个请求都携带 dsh-llm `attributionHeaders()` 的共享归因标头,即用于识别 harness 的必需 `User-Agent` 基线(见 [dsh-llm § 应用归因](../llm/README.md#app-attribution-attributionts))。在该适配器约定(adapter contract)下,直接 DeepSeek 请求与 OpenAI 兼容 gateway 请求都不会获得提供方特定应用归因标头;OpenRouter 应用归因暂缓到未来的显式 OpenRouter 适配器或模式。`GenerateOptions.purpose` 为 `compaction` 的请求(dsh-compaction-basic 的辅助摘要调用)还会携带 `x-deepseek-harness-compact: 1`,让宿主可以将压缩流量与会话请求分开。
+每个请求都携带 dsh-llm `attributionHeaders()` 的共享归因标头,即用于识别 harness 的必需 `User-Agent` 基线(见 [dsh-llm § 应用归因](../llm/README.zh.md#app-attribution-attributionts))。在该适配器约定(adapter contract)下,直接 DeepSeek 请求与 OpenAI 兼容 gateway 请求都不会获得提供方特定应用归因标头;OpenRouter 应用归因暂缓到未来的显式 OpenRouter 适配器或模式。`GenerateOptions.purpose` 为 `compaction` 的请求(dsh-compaction-basic 的辅助摘要调用)还会携带 `x-deepseek-harness-compact: 1`,让宿主可以将压缩流量与会话请求分开。
-DeepSeek 请求身份独立于应用归因。凭据解析成功后,每个提供方请求都会通过 `x-deepseek-harness-user-id` 携带来自 [`@deepseek-ai/dsh-anonymous-user-id`](../../identity/anonymous-user-id/README.md) 的稳定匿名 id;携带 `GenerateOptions.sessionId` 的请求还会通过 `x-deepseek-harness-session-id` 发送该确切值,缺少会话的直接调用则省略会话标头。两个标头都会发送至解析后的 `baseURL`(包括已配置的 gateway),且不会进入请求正文或模型可见内容。
+DeepSeek 请求身份独立于应用归因。凭据解析成功后,每个提供方请求都会通过 `x-deepseek-harness-user-id` 携带来自 [`@deepseek-ai/dsh-anonymous-user-id`](../../identity/anonymous-user-id/README.zh.md) 的稳定匿名 id;携带 `GenerateOptions.sessionId` 的请求还会通过 `x-deepseek-harness-session-id` 发送该确切值,缺少会话的直接调用则省略会话标头。两个标头都会发送至解析后的 `baseURL`(包括已配置的 gateway),且不会进入请求正文或模型可见内容。
## 协议格式说明
- 只支持流式输出(`stream_options.include_usage` 始终开启)。`usage` 可能附着在 finish 分片上,也可能作为尾随的纯 usage 分片到达;转换器会将两者都延迟到 `[DONE]`,因此 `usage` 始终位于 `finish` 之前,`finish` 之后不会出现任何内容。
- 适配器持有的 `off` 推理强度映射为 `thinking: {type: 'disabled'}`,绝不会以 `reasoning_effort: 'off'` 通过协议发送。
- 第一个思考模式分片携带 `reasoning_content: ""`,系统会处理它(不会产生多余 reasoning 块)。
-- **推理回传规则**:对携带工具调用的 assistant 轮次,会将 `reasoning_content` 序列化回历史(思考模式 API 必需);对不含工具调用的轮次,它会被丢弃(不会使用,可节省 token)。
+- **推理回传规则**:每个携带推理内容的 assistant 轮次都会将 `reasoning_content` 序列化回历史。思考模式在工具调用轮次上必需它;DeepSeek 在其他轮次上会忽略它,而将该对话重新编码转发给其他厂商的网关,要靠对回传原文取哈希来恢复该轮次上游的思考签名。
- 支持图片的 user 消息会保留文本/图片顺序。Tool role 内容仍为字符串;连续工具结果中的图片会用 `Attached image(s) from tool result:` 汇总到随后一条 user 消息。
- Cache 计量:`cacheReadTokens` ← `prompt_cache_hit_tokens` / `prompt_tokens_details.cached_tokens`;DeepSeek 不报告 cache-write 指标。
@@ -92,15 +92,15 @@ DeepSeek 请求身份独立于应用归因。凭据解析成功后,每个提
#### 模型看到的内容
-所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、stop sequence 和调用配置,不含适配器撰写的提示词文本。视觉模型还会通过 base64 data URL 收到保留的 user 与工具结果图片;超出上限的较旧图片由已记录的占位文本表示。当之前的 assistant 轮次包含工具调用时,会按要求回传其推理内容;不含工具调用的轮次会省略推理。
+所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、stop sequence 和调用配置,不含适配器撰写的提示词文本。视觉模型还会通过 base64 data URL 收到保留的 user 与工具结果图片;超出上限的较旧图片由已记录的占位文本表示。之前 assistant 轮次的推理内容会原文回传,无论该轮次是否调用了工具。
#### Token 影响
-精确文本与图片 token 输入取决于提供方 tokenization。有条件推理回传会增加工具往返上下文,丢弃其他推理和超出上限的图片则避免再次支付这些 token;可用时会报告 cache-read 用量。
+精确文本与图片 token 输入取决于提供方 tokenization。推理回传会把每个含推理轮次的思维链带入后续请求,丢弃超出上限的图片则避免再次支付这些 token;可用时会报告 cache-read 用量。
#### KV Cache 影响
-未更改的已组装前缀,包括确定性编码的保留图片与占位文本,可使用 DeepSeek cache 复用,适配器会在 usage 中报告它。模型路由变更,或任何上游提示词、schema、前缀、历史或图片上限变更,都可能使从首个发生变化的 token 起的复用失效;推理回传会在工具往返期间追加。
+未更改的已组装前缀,包括确定性编码的保留图片与占位文本,可使用 DeepSeek cache 复用,适配器会在 usage 中报告它。模型路由变更,或任何上游提示词、schema、前缀、历史或图片上限变更,都可能使从首个发生变化的 token 起的复用失效;推理回传会在每个含推理的轮次上追加。
### DeepSeek 响应
diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json
index 52d022806a..766ba80fd1 100644
--- a/packages/llm/llm-deepseek/package.json
+++ b/packages/llm/llm-deepseek/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-llm-deepseek",
"description": "DeepSeek chat-completions adapter for the DeepSeek Harness LLM seam",
- "version": "0.1.0-rc.7",
+ "version": "0.1.0-rc.8",
"publishConfig": {
"access": "public"
},
diff --git a/packages/llm/llm-deepseek/src/serialize.ts b/packages/llm/llm-deepseek/src/serialize.ts
index da643aa599..498b3fb2f7 100644
--- a/packages/llm/llm-deepseek/src/serialize.ts
+++ b/packages/llm/llm-deepseek/src/serialize.ts
@@ -182,10 +182,12 @@ function serializeAssistant(message: Message): WireMessage {
// the message sits durably in the session log, a null here bricks every
// later turn of that session.
content: text,
- // Official passback rule (guides/thinking_mode.mdx): reasoning_content
- // must return on tool-call turns; it is ignored on plain turns, so we
- // drop it there to save tokens.
- ...toolCalls.length > 0 && reasoning.length > 0 ? { reasoning_content: reasoning } : {},
+ // CoT passback on every reasoning-carrying turn. The official rule
+ // (guides/thinking_mode.mdx) requires it on tool-call turns and ignores it
+ // elsewhere; a gateway re-encoding the conversation for another vendor
+ // recovers that turn's upstream thinking signature by hashing this exact
+ // text, which a tool-call-free turn carries nowhere else.
+ ...reasoning.length > 0 ? { reasoning_content: reasoning } : {},
...toolCalls.length > 0 ? { tool_calls: toolCalls } : {},
}
}
diff --git a/packages/llm/llm-deepseek/src/types.ts b/packages/llm/llm-deepseek/src/types.ts
index 1781ed8646..93c7f48a36 100644
--- a/packages/llm/llm-deepseek/src/types.ts
+++ b/packages/llm/llm-deepseek/src/types.ts
@@ -79,9 +79,11 @@ export interface WireAssistantMessage {
role: 'assistant'
content: string | null
/**
- * CoT passback. REQUIRED on assistant turns that carried tool calls
- * (thinking mode); ignored on tool-call-free turns (we omit it there to
- * save tokens). See guides/thinking_mode.mdx § Tool Calls.
+ * CoT passback, present on every turn whose assistant content carried
+ * reasoning. REQUIRED on tool-call turns in thinking mode (see
+ * guides/thinking_mode.mdx § Tool Calls); DeepSeek ignores it elsewhere,
+ * while a gateway re-encoding for another vendor recovers that turn's
+ * thinking signature by hashing it.
*/
reasoning_content?: string
tool_calls?: WireToolCall[]
diff --git a/packages/llm/llm-deepseek/tests/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/adapter.e2e.ts
index 973fd63f4b..506bfc657d 100644
--- a/packages/llm/llm-deepseek/tests/adapter.e2e.ts
+++ b/packages/llm/llm-deepseek/tests/adapter.e2e.ts
@@ -138,7 +138,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', ()
try {
// JSON.stringify quotes the value: YAML is a JSON superset, so a real
// key survives whatever characters it happens to carry.
- await writeFile(join(dir, '.credentials.yaml'), `DEEPSEEK_API_KEY: ${JSON.stringify(key)}\n`, { mode: 0o600 })
+ await writeFile(join(dir, '.credentials.yaml'), `version: 1\nrefs:\n DEEPSEEK_API_KEY: ${JSON.stringify(key)}\n`, { mode: 0o600 })
// Scrub the ambient variable so only the credential seam can supply the
// key: this request proves the per-request resolution path end to end.
vi.stubEnv('DEEPSEEK_API_KEY', '')
diff --git a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts
index b397efdba7..ac2043170a 100644
--- a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts
+++ b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts
@@ -100,7 +100,7 @@ describe('request-level dynamic configuration', () => {
it('routes the next request with the freshly resolved base URL and credential', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', '')
const dir = await home()
- await writeFile(join(dir, '.credentials.yaml'), 'DEEPSEEK_API_KEY: first-key\n', { mode: 0o600 })
+ await writeFile(join(dir, '.credentials.yaml'), 'version: 1\nrefs:\n DEEPSEEK_API_KEY: first-key\n', { mode: 0o600 })
const serverA = await mockServer([{ kind: 'sse', events: textEvents }])
const serverB = await mockServer([{ kind: 'sse', events: textEvents }])
const { ctx } = await boot(dir, { baseURL: serverA.url })
@@ -255,7 +255,7 @@ describe('request-level dynamic configuration', () => {
it('falls back to the composition entry when settings detach', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', '')
const dir = await home()
- await writeFile(join(dir, '.credentials.yaml'), 'DEEPSEEK_API_KEY: steady-key\n', { mode: 0o600 })
+ await writeFile(join(dir, '.credentials.yaml'), 'version: 1\nrefs:\n DEEPSEEK_API_KEY: steady-key\n', { mode: 0o600 })
const serverA = await mockServer([{ kind: 'sse', events: textEvents }])
const serverB = await mockServer([{ kind: 'sse', events: textEvents }])
const { ctx, settingsFiber } = await boot(dir, { baseURL: serverA.url })
diff --git a/packages/llm/llm-deepseek/tests/loader-composition.spec.ts b/packages/llm/llm-deepseek/tests/loader-composition.spec.ts
index 1398d36110..ec83345b61 100644
--- a/packages/llm/llm-deepseek/tests/loader-composition.spec.ts
+++ b/packages/llm/llm-deepseek/tests/loader-composition.spec.ts
@@ -53,7 +53,7 @@ async function loadComposition(
const credentialsPath = join(root, '.credentials.yaml')
if (options.withDynamic && fresh) {
await writeFile(settingsPath, '# personal settings\n')
- await writeFile(credentialsPath, 'DEEPSEEK_API_KEY: boot-key\n', { mode: 0o600 })
+ await writeFile(credentialsPath, 'version: 1\nrefs:\n DEEPSEEK_API_KEY: boot-key\n', { mode: 0o600 })
}
const configPath = join(root, 'cordis.yml')
@@ -124,7 +124,7 @@ describe('llm-deepseek real dynamic composition', () => {
await vi.waitFor(() => {
expect((ctx.get('settings')!.get(NS) as { baseURL?: string }).baseURL).toBe(serverB.url)
}, { timeout: 5000 })
- await writeFile(credentialsPath, 'DEEPSEEK_API_KEY: rotated-key\n', { mode: 0o600 })
+ await writeFile(credentialsPath, 'version: 1\nrefs:\n DEEPSEEK_API_KEY: rotated-key\n', { mode: 0o600 })
await vi.waitFor(async () => {
expect(await ctx.get('credentials')!.resolve(KEY_REF)).toEqual({ value: 'rotated-key', source: 'file' })
}, { timeout: 5000 })
diff --git a/packages/llm/llm-deepseek/tests/serialize.spec.ts b/packages/llm/llm-deepseek/tests/serialize.spec.ts
index d503a8b5c7..742efc863d 100644
--- a/packages/llm/llm-deepseek/tests/serialize.spec.ts
+++ b/packages/llm/llm-deepseek/tests/serialize.spec.ts
@@ -54,7 +54,7 @@ describe('serializeMessages', () => {
expect(wire).toEqual([{ role: 'system', content: 'be brief' }])
})
- it('maps plain assistant text without reasoning_content', () => {
+ it('passes reasoning_content back on tool-call-free turns', () => {
const wire = serializeMessages([
createMessage({
role: 'assistant',
@@ -65,8 +65,10 @@ describe('serializeMessages', () => {
source: { kind: 'plugin', plugin: 'test' },
}),
])
- // Tool-call-free turn: reasoning is dropped (ignored by the API anyway).
- expect(wire).toEqual([{ role: 'assistant', content: 'answer' }])
+ // A gateway that re-encodes the conversation for another vendor recovers
+ // the upstream thinking signature by hashing this exact text, and a turn
+ // that called no tool carries it nowhere else.
+ expect(wire).toEqual([{ role: 'assistant', content: 'answer', reasoning_content: 'thinking…' }])
})
it('passes reasoning_content back on tool-call turns (official passback rule)', () => {
@@ -589,16 +591,17 @@ describe('review fixes: assistant content shapes', () => {
expect(wire).toEqual([{ role: 'assistant', content: '' }])
})
- it('serializes a reasoning-ONLY assistant message as "" content with the reasoning dropped', () => {
+ it('serializes a reasoning-ONLY assistant message as "" content beside its reasoning', () => {
// The model can answer entirely in the reasoning channel (a v4-flash
- // greeting did, live). The passback rule keeps reasoning_content off
- // plain turns, and content must still be SET — a null here poisoned the
- // session log and bricked every later turn of that session.
+ // greeting did, live). Content must still be SET — a null here poisoned
+ // the session log and bricked every later turn of that session.
const wire = serializeMessages([createMessage({
role: 'assistant', content: [{ type: 'reasoning', text: '你好!有什么我可以帮你的吗?' }],
source: { kind: 'plugin', plugin: 'test' },
})])
- expect(wire).toEqual([{ role: 'assistant', content: '' }])
+ expect(wire).toEqual([{
+ role: 'assistant', content: '', reasoning_content: '你好!有什么我可以帮你的吗?',
+ }])
})
it('serializes tool-call turns with empty string content, not null', () => {
diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml
index 694da1fb3c..034382822f 100644
--- a/packages/llm/llm-pi-ai/README.i18n.yaml
+++ b/packages/llm/llm-pi-ai/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/llm/llm-pi-ai/README.md
-README.md: f696b6bee50b844bfbc6bab7f9def0e785d450c9
-README.zh.md: cace9d1fdfd85a559674b121b79b3dbb86b4337b
+README.md: 19dbcfa90dbefbe322800c83da6d75c70c849f05
+README.zh.md: 334c6c3166f35f5dc7d7659b9de7cff404c6b56d
diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md
index f696b6bee5..19dbcfa90d 100644
--- a/packages/llm/llm-pi-ai/README.md
+++ b/packages/llm/llm-pi-ai/README.md
@@ -51,9 +51,12 @@ Configure credentials, the model catalog, and deployment-specific transport sett
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.
+ # Request shape for an endpoint whose URL pi-ai cannot recognize; it
+ # would otherwise be addressed as though it were OpenAI itself.
compat:
thinkingFormat: deepseek
+ supportsDeveloperRole: false
+ maxTokensField: max_tokens
models:
- id: acme-large
name: Acme Large
@@ -85,9 +88,13 @@ A profile's `models` list *replaces* the route's installed catalog rather than e
The declaration translates to pi-ai's `Model.reasoning` + `thinkingLevelMap` with every level decided explicitly — undeclared levels are pinned unsupported rather than left to pi-ai's own defaulting, which is asymmetric (an absent key means "supported" for the five base levels but "unsupported" for `xhigh`/`max`) and which a profile author should not need to know. `off` is the one three-state key: left out, selectors offer no Off and an explicit Off request is refused — a request naming no effort still goes out without the parameter, so what the provider then does is its own default; declared with no value (`off:`), Off is offered and selecting it sends nothing — for the `deepseek` dialect an explicit `thinking: {type: "disabled"}` — which also covers a request naming no effort at all; declared with a value (`off: none`), that value goes on the wire as the effort parameter. There is no spelling for restoring a catalog map key to "unset": the declaration is the whole offer, so restate the catalog levels you keep.
-### Reasoning-dispatch compat switches
+### Wire-compatibility switches
-How a thinking level travels — `reasoning_effort` alone, DeepSeek's `thinking: {type}` plus effort, z.ai's `thinking` object, and so on — is pi-ai's `compat.thinkingFormat`, which pi-ai guesses from the endpoint URL; a private gateway's URL says nothing, so a DeepSeek-dialect gateway would be spoken to in the OpenAI dialect with no way to correct it. `compat.thinkingFormat` and `compat.supportsReasoningEffort` are therefore configurable on the route (its models' default) and per model (winning per field), resolving model → route → installed catalog entry → pi-ai's URL-derived guess; setting a route-level switch shadows the catalog entry's value for every model on the route, and there is no spelling for handing a field back to the catalog short of restating its value. `thinkingFormat` accepts pi-ai's dispatchable formats except the two `chat-template` variants, which need `chatTemplateKwargs` this configuration does not expose. Both switches exist only on `openai-completions` — the other protocols carry their reasoning shape in the protocol itself — so a model-level switch elsewhere fails resolution, a route-level one skips models of other protocols, and a route with no `openai-completions` model at all is refused. The rest of pi-ai's compat surface (`supportsStore`, `maxTokensField`, …) stays auto-detected and is deliberately not configurable here.
+pi-ai shapes each request from the provider id and baseURL: which role carries the system prompt, which field caps output, how a thinking level travels. A private gateway's URL says nothing, and for an endpoint pi-ai does not recognize the detection answers as though it were OpenAI itself — a reasoning model's system prompt goes out as `developer`, the output cap as `max_completion_tokens`, the thinking level as a bare `reasoning_effort` — and most OpenAI-compatible gateways reject at least one of those. `compat` is therefore configurable on the route (its models' default) and per model (winning per field), resolving model → route → installed catalog entry → pi-ai's own detection; a route-level switch shadows the catalog entry's value for every model that reads it, and there is no spelling for handing a field back to the catalog short of restating its value.
+
+Each switch belongs to the protocols whose pi-ai compat type declares it, and grouping follows the compat *type* rather than the protocol name: the three Responses protocols (`openai-responses`, `azure-openai-responses`, `openai-codex-responses`) share one compat type, so a switch settable on one is settable on all three. `supportsDeveloperRole` is settable on `openai-completions` and on those three; `thinkingFormat` only on `openai-completions`; `supportsTemperature` only on `anthropic-messages`; `supportsStrictMode` also reaches `bedrock-converse-stream`. A model-level switch its protocol does not take fails resolution naming what that protocol does offer; a route-level one lands on the models that read it and skips the rest, and is refused only when no model on the route could read it at all.
+
+Three kinds of key are refused rather than dropped: one no protocol declares (a misspelling), one pi-ai's installed catalog owns for a named vendor (`openRouterRouting`, `zaiToolStream`, `deferredToolsMode`, `sessionAffinityFormat`, `supportsOpenAIGrammarTools`, `supportsToolSearch`, `supportsExplicitPromptCacheMode`, `supportsToolReferences`, `vercelGatewayRouting`, `sendSessionAffinityHeaders`) — a route needing a vendor's own switch is a catalog route that should be named as such — and one written with no value at all (`supportsDeveloperRole:`), which schemastery passes through as null and which would otherwise replace the installed catalog's value with nothing. The offered set is pinned to pi-ai's four compat types by drift gates, the protocols carrying them are derived from `Model.compat` itself, and each field's type is derived from upstream rather than restated, so an upgrade that adds a field, gives a further protocol a compat type, or widens a value union fails the build until someone classifies it.
A model neither the entry nor the installed catalog sizes takes the route's `defaultContextWindow` (262,144) and `defaultMaxTokens` (32,768), so a listing that discloses nothing but ids still yields a serviceable route. Both fallbacks are guesses by construction, which is why they are route fields a deployment whose gateway serves smaller models corrects once rather than constants buried in the adapter; the fallback sizes the model and never becomes a per-request cap.
@@ -133,7 +140,7 @@ Most listings disclose an id and nothing else; `context_window`/`context_length`
Each resolution produces one **immutable** snapshot — the profiles plus a `createModels()` collection holding the `Provider` each route built — and every operation captures a whole snapshot before its first `await`. A configuration change builds a *new* collection rather than mutating the one in use: `Models.streamSimple()` resolves its provider lazily, when the stream is first consumed, which is after the credential await, so a mutated collection would let a request that started under one configuration finish under another or fail on a provider that no longer exists. This is what makes the seam's per-step call freeze (`llm.prepareCall()`) hold end to end — switching models mid-reply takes effect on the next step, never inside the one in flight. Requests reach their provider through `Models.streamSimple()`. A catalog route that keeps its catalog protocol **reuses** the installed provider with its model list replaced, because that provider owns API implementations this package cannot reconstruct — Bedrock loads its Smithy module through a separate entry point — so rebuilding it from parts would silently narrow which providers work. Every other route is built by `createProvider()` over the protocol table behind `supportedProtocols()`, whose entries are the same factories pi-ai's own provider factories use.
-Credentials never enter that collection. The harness resolves a route's key through its own seam before the request reaches pi-ai and passes it as the request's `apiKey` option, which pi-ai treats as the highest-priority auth override; `Models` therefore holds no credential store, and the harness keeps its fail-loud reference semantics. A route naming no credential resolves as configured-but-keyless and leaves the requirement to the protocol, which is where it actually lives.
+A route's `apiKeyEnv` key still resolves through the harness seam before the request reaches pi-ai and rides as the request's `apiKey` option, which pi-ai treats as the highest-priority auth override — the fail-loud reference semantics stay the harness's. Beneath that override the collection carries this plugin's credential store and ambient auth context: a stored sign-in (an OAuth grant, or a key typed into pi-ai's own login prompt) authenticates its route through them and refreshes itself under the store's cross-process lock. The store addresses records as `llm-pi-ai/`, and a hand-declared route key outside that record grammar (uppercase, dots, underscores) reads as holding nothing stored rather than as an addressing error; such a route cannot be signed into — a record write for it refuses with `LlmError('UNSTORABLE_PROVIDER_ID')` — and authenticates through `apiKeyEnv` or ambient provider settings. A route naming no credential at all resolves as configured-but-keyless and leaves the requirement to the protocol, which is where it actually lives.
The selected model descriptor supplies the protocol implementation. This includes native API differences such as OpenAI models whose descriptor uses the Responses API rather than Chat Completions; the harness adapter does not hardcode endpoint selection by model name.
@@ -190,8 +197,8 @@ Recorded response content appends to the next request and does not invalidate it
## Known Limitations and Deferred Work
- **`maxRequestImageBytes` counts base64 image payload only** — text, tools, and JSON structure ride outside the bound, so it must sit below the gateway's request-body cap with headroom. Offload is decided at request conversion as a pure function of history and configuration and is not recorded as a session event; per-route capability metadata (image count, per-image size, total request size) driving admission and assembly together is deferred design work.
-- **A provider that authenticates through OAuth alone is not offered** — pi-ai resolves OAuth from a *stored* OAuth credential, and this adapter builds its `Models` collection with no credential store and runs no login flow, so every request on such a route fails `Provider is not configured` before it goes out. The configurable-provider directory withholds them; `openai-codex` is the only one the installed catalog ships. A route a settings document already names keeps its entry so a configuration surface can edit or delete it, and `apiKeyEnv` still authenticates it with that key — which for Codex is a token that expires with nothing here to refresh it.
-- **Provider-native discovery reads the process environment only** — a route naming no credential defers to the catalog provider's own resolution, which interrogates environment variables (`AZURE_OPENAI_API_KEY`, `AWS_PROFILE`, `AWS_ACCESS_KEY_ID`, and each provider's own set). It reads no local credential directory, so `~/.aws/credentials` without an exported `AWS_PROFILE` resolves as unconfigured, and a value held by the harness credential seam is invisible to it unless the process environment carries it too.
+- **A sign-in lives only in the process that started it** — an authorization attempt is not durable, so reloading the page mid-login abandons it and the human starts over. Signing out is `deleteRecord` on the stored record, which forgets it locally without telling the issuer.
+- **Provider-native discovery answers through this plugin's ambient context** — a route naming no credential defers to the catalog provider's own resolution, which asks for environment values (`AZURE_OPENAI_API_KEY`, `AWS_PROFILE`, and each provider's own set) and for local credential files. Both questions are answered here: the credential seam is consulted before the process environment, and file existence is checked against the host process's filesystem with `~` expanded. What it cannot do is *read* a credential file's contents — a provider that parses `~/.aws/credentials` itself does so directly, outside the seam.
- **Settings can add or override routes, not remove composition routes** — the user layer merges over the composition `base`, so deleting a `cordis.yml`-provided provider is a composition change; `replace` on the namespace only resets the user layer.
- **The layered merge has no delete for dict keys** — the settings seam merges the composition `base` and the user layer per key, recursively, so a `reasoningEfforts` level, `modelOverrides` entry, or `compat` field the base declares cannot be removed by the user layer, only overridden — and for `reasoningEfforts` absence *is* the meaning ("not offered"), so a base-declared level stays offered. This only triggers when a `cordis.yml` entry config declares per-model reasoning fields for the same model the user layer edits; the supported posture is to leave those to the settings document (the shipped composition mounts the adapter dormant), and a `models` list is an array replacing wholesale, which is the in-band escape.
- **`headers` can carry a credential the redactor never sees** — the profile's `headers` dict is plain strings, so `Authorization` or `api-key` set there is returned verbatim by a redacted `describe()` and rendered by any configuration UI. Store credentials as `apiKeyEnv` references; making the dict write-only is deferred with the rest of the [wire-boundary work](../llm/README.md#known-limitations-and-deferred-work).
diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md
index cace9d1fdf..334c6c3166 100644
--- a/packages/llm/llm-pi-ai/README.zh.md
+++ b/packages/llm/llm-pi-ai/README.zh.md
@@ -51,9 +51,12 @@
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.
+ # Request shape for an endpoint whose URL pi-ai cannot recognize; it
+ # would otherwise be addressed as though it were OpenAI itself.
compat:
thinkingFormat: deepseek
+ supportsDeveloperRole: false
+ maxTokensField: max_tokens
models:
- id: acme-large
name: Acme Large
@@ -85,9 +88,13 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩
该声明会转换为 pi-ai 的 `Model.reasoning` + `thinkingLevelMap`,其中每个档位都被显式决定——未声明的档位一律固定为不支持,而不是留给 pi-ai 自己的默认规则:那套规则并不对称(键缺席对五个基础档位意味着「支持」,对 `xhigh`/`max` 却意味着「不支持」),也本不该要求 profile 作者了解。`off` 是唯一的三态键:不写它,选择器不提供 Off,显式请求 Off 会被拒绝——不点名任何档位的请求仍会在不带该参数的情况下发出,提供方随后做什么是它自己的默认行为;声明而不给值(`off:`),则会提供 Off,选中它时什么也不发送——对 `deepseek` 方言则是一个显式的 `thinking: {type: "disabled"}`——这同时覆盖完全不点名任何档位的请求;声明并给值(`off: none`),该值就会作为档位参数在协议中发送。没有任何写法能把 catalog 映射中的键恢复为「未设置」:这份声明就是对外提供的全部,因此把你要保留的 catalog 档位重述出来。
-### 推理分派的 compat 开关
+### 协议兼容开关
-思考级别如何在协议中传输——单独一个 `reasoning_effort`、DeepSeek 的 `thinking: {type}` 加上档位、z.ai 的 `thinking` 对象,诸如此类——就是 pi-ai 的 `compat.thinkingFormat`,pi-ai 会从端点 URL 猜测它;私有网关的 URL 什么也说明不了,于是说 DeepSeek 方言的网关只会收到 OpenAI 方言的请求,且无从更正。因此 `compat.thinkingFormat` 与 `compat.supportsReasoningEffort` 既可配置在路由上(作为其模型的默认值),也可按模型配置(逐字段胜出),解析顺序为模型 → 路由 → 已安装 catalog 条目 → pi-ai 按 URL 得出的猜测;设置路由级开关会为路由上的每个模型遮蔽 catalog 条目的值,而且除了重述其值,没有任何写法能把某个字段交还给 catalog。`thinkingFormat` 接受 pi-ai 可分派的各种格式,但不含两个 `chat-template` 变体:它们需要的 `chatTemplateKwargs` 本配置并不暴露。两个开关都只存在于 `openai-completions` 上——其余协议的推理形状由协议本身承载——因此在其他协议的模型上设置模型级开关会使解析失败,路由级开关会跳过其他协议的模型,而完全没有 `openai-completions` 模型的路由则会被拒绝。pi-ai compat 面的其余部分(`supportsStore`、`maxTokensField`……)保持自动检测,特意不在此处开放配置。
+pi-ai 依据提供方 id 与 baseURL 决定每个请求的形状:系统提示词由哪个角色承载、输出上限写在哪个字段、思考级别如何传输。私有网关的 URL 什么也说明不了,而对于 pi-ai 无法识别的端点,其检测会当作 OpenAI 本身来回答——推理模型的系统提示词以 `developer` 发出、输出上限写作 `max_completion_tokens`、思考级别只发一个裸的 `reasoning_effort`——而多数 OpenAI 兼容网关至少会拒绝其中之一。因此 `compat` 既可配置在路由上(作为其模型的默认值),也可按模型配置(逐字段胜出),解析顺序为模型 → 路由 → 已安装 catalog 条目 → pi-ai 自身的检测;路由级开关会为每个读取它的模型遮蔽 catalog 条目的值,而且除了重述其值,没有任何写法能把某个字段交还给 catalog。
+
+每个开关归属于其 pi-ai compat 类型声明了它的那些协议,且归组依据是 compat **类型**而非协议名:三个 Responses 协议(`openai-responses`、`azure-openai-responses`、`openai-codex-responses`)共用同一个 compat 类型,因此可设在其中之一的开关,三者皆可设。`supportsDeveloperRole` 可设在 `openai-completions` 与这三者上;`thinkingFormat` 只能设在 `openai-completions`;`supportsTemperature` 只能设在 `anthropic-messages`;`supportsStrictMode` 还可达 `bedrock-converse-stream`。模型级开关若其协议并不接受,解析失败并点名该协议实际提供哪些开关;路由级开关则落在读取它的模型上、跳过其余模型,只有当路由上没有任何模型能读取它时才被拒绝。
+
+三类键会被拒绝而非丢弃:没有任何协议声明的键(笔误);pi-ai 已安装 catalog 为具名厂商掌管的键(`openRouterRouting`、`zaiToolStream`、`deferredToolsMode`、`sessionAffinityFormat`、`supportsOpenAIGrammarTools`、`supportsToolSearch`、`supportsExplicitPromptCacheMode`、`supportsToolReferences`、`vercelGatewayRouting`、`sendSessionAffinityHeaders`)——需要某厂商专属开关的路由,本就是一条应当以该厂商命名的 catalog 路由;以及完全没有写值的键(`supportsDeveloperRole:`),schemastery 会把它放行为 null,若照单收下就会用空值替换已安装 catalog 的值。开放集由漂移门禁钉在 pi-ai 的四个 compat 类型上,承载它们的协议集派生自 `Model.compat` 本身,每个字段的类型也派生自上游而非重述,因此上游新增字段、给别的协议加上 compat 类型、或拓宽某个值并集,都会使构建失败,直到有人为它做出分类。
条目与已安装 catalog 都没有给出尺寸的模型,会采用该路由的 `defaultContextWindow`(262,144)与 `defaultMaxTokens`(32,768),因此一份只公布 id 的列表同样能产出可服务的路由。两个回退值本质上都是猜测,这正是它们作为路由字段、供网关服务更小模型的部署一次性更正的原因,而不是埋在适配器里的常量;回退值只用于给模型定尺寸,绝不会变成单次请求上限。
@@ -134,7 +141,7 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩
每次解析产出一份**不可变**快照——profiles 加上一个持有各路由所建 `Provider` 的 `createModels()` 集合——每个操作都在自己第一个 `await` 之前整体捕获一份快照。配置变化会构造**新**集合,而不是改动正在被使用的那个:`Models.streamSimple()` 是惰性的,它在流首次被消费时才解析 provider,而那已在 credential await 之后,因此改动共享集合会让一个在旧配置下开始的请求在新配置下结束,或者撞上一个已不存在的 provider。这正是 seam 的每步调用冻结(`llm.prepareCall()`)能贯通到底的原因——回复途中切换模型会在下一步生效,绝不会影响在途的那一步。请求经 `Models.streamSimple()` 抵达提供方。保持 catalog 协议不变的 catalog 路由会**复用**已安装提供方,只替换其模型列表,因为该提供方持有本包无法重建的 API 实现——Bedrock 经由独立入口加载其 Smithy 模块——从零件重建会静默收窄可用提供方的范围。其余路由都由 `createProvider()` 基于 `supportedProtocols()` 背后的协议表构造,表中条目正是 pi-ai 自己的提供方工厂所用的同一批 factory。
-凭据绝不进入该集合。harness 在请求抵达 pi-ai 之前经自身 seam 解析路由密钥,并作为请求的 `apiKey` 选项传入,而 pi-ai 将其视为优先级最高的 auth 覆盖;因此 `Models` 不持有任何凭据存储,harness 也保住了自己明确失败的引用语义。没有点名任何凭据的路由会解析为「已配置但无密钥」,把该要求留给协议——那才是它真正所在的位置。
+路由的 `apiKeyEnv` 密钥仍在请求抵达 pi-ai 之前经 harness 自身 seam 解析,并作为请求的 `apiKey` 选项传入——pi-ai 将其视为优先级最高的 auth 覆盖,harness 因此保住自己明确失败的引用语义。在该覆盖之下,集合携带本插件的凭据存储与 ambient auth context:已存储的登录(OAuth grant,或在 pi-ai 自己的登录提示里键入的密钥)经由它们为路由完成认证,并在存储的跨进程锁下自行刷新。存储以 `llm-pi-ai/` 为记录地址;手写路由键若落在记录文法之外(大写、点、下划线),读取时视为「没有存储任何东西」而不是寻址错误——这样的路由无法登录(对它的记录写入会以 `LlmError('UNSTORABLE_PROVIDER_ID')` 拒绝),只能经 `apiKeyEnv` 或提供方 ambient 设置认证。完全没有点名任何凭据的路由会解析为「已配置但无密钥」,把该要求留给协议——那才是它真正所在的位置。
所选模型 descriptor 提供协议实现。这包括原生 API 差异,例如 descriptor 使用 Responses API 而非 Chat Completions 的 OpenAI 模型;harness 适配器不会按模型名称硬编码端点选择。
@@ -152,7 +159,7 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩
## 应用归因
-每个请求都携带 dsh-llm `attributionHeaders()` 的共享归因标头,并通过 pi-ai `headers` 流选项合并。不会合成提供方特定应用归因标头。详见 [dsh-llm § 应用归因](../llm/README.md#app-attribution-attributionts)。
+每个请求都携带 dsh-llm `attributionHeaders()` 的共享归因标头,并通过 pi-ai `headers` 流选项合并。不会合成提供方特定应用归因标头。详见 [dsh-llm § 应用归因](../llm/README.zh.md#app-attribution-attributionts)。
## 依赖体量
@@ -191,11 +198,11 @@ pi-ai 事件会变为 harness 推理、文本、工具调用、usage 与 finish
## 已知限制与暂缓事项
- **`maxRequestImageBytes` 只统计 base64 图片载荷**:文本、工具与 JSON 结构不计入上限,因此该值必须低于网关请求体上限并留出余量。offload 在请求转换时决定,是历史与配置的纯函数,不记录为会话事件;由按路由能力元数据(图片数量、单图大小、请求总大小)同时驱动准入与组装的完整设计属于暂缓工作。
-- **仅以 OAuth 认证的提供方不予提供**:pi-ai 的 OAuth 只从*已存储*的 OAuth 凭据解析,而本适配器构造 `Models` 集合时不注入凭据存储、也不运行登录流程,因此这类路由的每个请求都会在发出之前以 `Provider is not configured` 失败。可配置提供方目录因此不列出它们;已安装 catalog 中只有 `openai-codex` 属于此类。settings 文档已经写过的路由仍保留目录条目,配置界面据此可以编辑或删除;`apiKeyEnv` 也仍能用该密钥完成认证——对 Codex 而言那是一个会过期、且这里没有任何环节会去刷新的 token。
-- **提供方自带的凭据发现只读进程环境**:不指定凭据的路由交由 catalog 提供方自行解析,而它探测的是环境变量(`AZURE_OPENAI_API_KEY`、`AWS_PROFILE`、`AWS_ACCESS_KEY_ID` 以及各提供方自己的那一组)。它不读任何本地凭据目录,因此只有 `~/.aws/credentials` 而未导出 `AWS_PROFILE` 会被解析为未配置;由 harness 凭据 seam 保管的值,除非进程环境里也有,否则对它不可见。
+- **一次登录只存活于发起它的进程中**:授权尝试不可持久,登录途中刷新页面会丢弃它,人需要重来。登出即对已存储记录执行 `deleteRecord`,它只在本地遗忘而不通知签发方。
+- **提供方自带的凭据发现经由本插件的 ambient context 作答**:不指定凭据的路由交由 catalog 提供方自行解析,它会询问环境值(`AZURE_OPENAI_API_KEY`、`AWS_PROFILE` 以及各提供方自己的那一组)与本地凭据文件是否存在。两类问题都在这里作答:先查凭据 seam 再查进程环境,文件存在性则按宿主进程的文件系统判断并展开 `~`。它做不到的是*读取*凭据文件的内容——自行解析 `~/.aws/credentials` 的提供方是直接读盘的,不经过 seam。
- **settings 能新增或覆盖路由,但不能移除组合路由**:用户层合并在组合 `base` 之上,因此删除 `cordis.yml` 提供的提供方属于组合变更;对该 namespace 执行 `replace` 只会重置用户层。
- **分层合并对字典键没有删除语义**:settings seam 把组合 `base` 与用户层按键递归合并,因此 base 声明的某个 `reasoningEfforts` 档位、`modelOverrides` 条目或 `compat` 字段,用户层只能覆盖、无法移除——而 `reasoningEfforts` 里缺席本身*就是*语义(「不提供」),于是 base 声明过的档位会一直被提供。只有 `cordis.yml` entry config 为用户层正在编辑的同一模型声明了按模型推理字段才会触发;受支持的姿态是把这些字段留给 settings 文档(shipped 组合以 dormant 方式挂载该适配器),且 `models` 列表是数组、整体替换,这是带内的解决办法。
-- **`headers` 可能承载一条脱敏器看不见的凭据**:profile 的 `headers` 是纯字符串字典,因此设在其中的 `Authorization` 或 `api-key` 会被脱敏后的 `describe()` 原样返回,并被任何配置 UI 渲染出来。请把凭据存为 `apiKeyEnv` 引用;把该字典整体改为只写与其余[协议边界工作](../llm/README.md#known-limitations-and-deferred-work)一并暂缓。
+- **`headers` 可能承载一条脱敏器看不见的凭据**:profile 的 `headers` 是纯字符串字典,因此设在其中的 `Authorization` 或 `api-key` 会被脱敏后的 `describe()` 原样返回,并被任何配置 UI 渲染出来。请把凭据存为 `apiKeyEnv` 引用;把该字典整体改为只写与其余[协议边界工作](../llm/README.zh.md#known-limitations-and-deferred-work)一并暂缓。
- **路由的 catalog 不会自我刷新**:catalog 就是 `settings.yaml` 所写的内容,因此模型列表的新鲜度只到最近一次编辑为止。这里没有任何环节会去问提供方它服务哪些模型;路由要多一个模型,得有人写进去。
- **每条路由只有一种协议格式**:`api` 作用于整条路由,因此混合协议的 catalog 路由(跨 Responses 与 Chat Completions 的 OpenAI 式 catalog)无法承载另一种协议的模型,向这类路由添加它未描述的模型必须点名 `api` 并把全部模型一起迁过去。把该提供方拆成两个路由键是变通办法。
- **模态声明不经验证,且多声明的后果超出本轮**:没有任何环节会去询问端点接受什么,因此声明了网关并不提供的 `image` 的模型不会在这里被拦下,而是由提供方在轮次中途拒绝。prompt 准入在构造请求之前就把用户消息持久化提交,于是被拒绝的图片留在会话日志里:该模型会不断重发它,而模型选择拒绝切换到任何纯文本模型。恢复途径是换一个确实支持图片的模型、fork 到图片之前,或开启新会话;发送失败时把尚未消费的图片消息从日志中回滚出去这件事已暂缓。
diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json
index 3575d6672f..ab286f006f 100644
--- a/packages/llm/llm-pi-ai/package.json
+++ b/packages/llm/llm-pi-ai/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-llm-pi-ai",
"description": "pi-ai-backed DeepSeek adapter for the DeepSeek Harness LLM seam (design-verification twin of dsh-llm-deepseek)",
- "version": "0.1.0-rc.7",
+ "version": "0.1.0-rc.8",
"publishConfig": {
"access": "public"
},
@@ -33,6 +33,7 @@
"license": "MIT",
"peerDependencies": {
"@deepseek-ai/dsh-attachment": "workspace:^",
+ "@deepseek-ai/dsh-authorization": "workspace:^",
"@deepseek-ai/dsh-credentials": "workspace:^",
"@deepseek-ai/dsh-launch-environment": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
@@ -47,6 +48,7 @@
},
"devDependencies": {
"@deepseek-ai/dsh-attachment": "workspace:^",
+ "@deepseek-ai/dsh-authorization": "workspace:^",
"@deepseek-ai/dsh-credentials": "workspace:^",
"@deepseek-ai/dsh-launch-environment": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts
index 5ecec593da..3c7ecd4a91 100644
--- a/packages/llm/llm-pi-ai/src/adapter.ts
+++ b/packages/llm/llm-pi-ai/src/adapter.ts
@@ -13,10 +13,15 @@
* way down: switching models mid-reply takes effect on the next step, never
* inside the one in flight.
*
- * Credentials stay outside that collection. The harness resolves a route's key
- * through its own seam and passes it as the request's `apiKey` option, which
- * pi-ai treats as the highest-priority auth override — so `Models` never holds
- * a credential store and the harness keeps its fail-loud reference semantics.
+ * A route naming a credential reference still resolves it through the harness
+ * seam and passes it as the request's `apiKey` option, which pi-ai treats as
+ * the highest-priority auth override — that is what keeps the fail-loud
+ * reference semantics. Everything that override does not cover reaches pi-ai
+ * through the collection's own auth: the credential store holds the records a
+ * login wrote and a refresh rotates, and the auth context answers the ambient
+ * questions a provider asks while resolving. Both are stable across snapshots,
+ * so a configuration change rebuilds the collection without forgetting who is
+ * signed in.
*
* @module dsh-llm-pi-ai/adapter
*/
@@ -24,6 +29,8 @@
import { createModels, getSupportedThinkingLevels } from '@earendil-works/pi-ai'
import type {
Api,
+ AuthContext,
+ CredentialStore,
Model,
Models,
ModelThinkingLevel,
@@ -74,6 +81,15 @@ export interface PiAiAdapterOptions {
* `MISSING_CREDENTIAL` rather than falling back.
*/
resolveApiKey: (provider: string, profile: ResolvedPiAiProviderProfile) => Promise
+ /**
+ * How every collection this adapter builds resolves auth the request-level
+ * `apiKey` override does not cover. Required rather than optional: a
+ * collection built without them gets pi-ai's in-memory default store, which
+ * is empty at every boot and discarded on every configuration change, so a
+ * route whose only method is a login would report itself unconfigured on
+ * every request no matter how often the human signed in.
+ */
+ auth: PiAiAuthInjection
/** Resolve the optional durable attachment service at request time. */
resolveAttachments?: () => AttachmentStore | undefined
/**
@@ -83,6 +99,14 @@ export interface PiAiAdapterOptions {
onReplayDegrade?: (detail: { provider: string; model: string; reason: string }) => void
}
+/** The two auth injectables a pi-ai collection is built with. */
+export interface PiAiAuthInjection {
+ /** Durable storage for credentials pi-ai itself writes: logins, and the refreshes it runs under its own lock. */
+ credentials: CredentialStore
+ /** Ambient lookups a provider performs while resolving its own auth. */
+ authContext: AuthContext
+}
+
/** Copy profile stream knobs into pi-ai's common option vocabulary. */
function profileOptions(
profile: ResolvedPiAiProviderProfile,
@@ -204,7 +228,7 @@ export class PiAiAdapter extends LlmAdapter {
private current(): PiAiSnapshot {
const profiles = this.config.profiles()
if (this.snapshot?.profiles === profiles) return this.snapshot
- const models: MutableModels = createModels()
+ const models: MutableModels = createModels(this.config.auth)
for (const profile of profiles.values()) models.setProvider(profile.piProvider)
this.snapshot = { profiles, models }
return this.snapshot
diff --git a/packages/llm/llm-pi-ai/src/auth.ts b/packages/llm/llm-pi-ai/src/auth.ts
new file mode 100644
index 0000000000..f729a77f85
--- /dev/null
+++ b/packages/llm/llm-pi-ai/src/auth.ts
@@ -0,0 +1,207 @@
+/**
+ * The three adapters between pi-ai's auth model and the harness credential
+ * plane. Every pi-ai-specific concept stays on this side of them: the harness
+ * seams they consume — `ctx.credentials` records and `ctx.authorization` flows —
+ * name nothing from this library, so another adapter family can arrive with a
+ * different auth model and share the same two seams.
+ *
+ * @module dsh-llm-pi-ai/auth
+ */
+
+import { homedir } from 'node:os'
+import { access } from 'node:fs/promises'
+import { resolve as resolvePath } from 'node:path'
+import type { AuthContext, Credential, CredentialInfo, CredentialStore } from '@earendil-works/pi-ai'
+import type { Context } from '@deepseek-ai/cordis'
+import {
+ credentialKey, credentialKeyId, credentialKeyScope, credentialRef, isCredentialKeySegment, isCredentialRefName,
+} from '@deepseek-ai/dsh-credentials'
+import type { CredentialKey, CredentialProvider, CredentialRecord } from '@deepseek-ai/dsh-credentials'
+import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment'
+import { LlmError } from '@deepseek-ai/dsh-llm'
+
+/**
+ * The record scope every credential this adapter family stores is written
+ * under. It is the plugin's registered name, which is what tells a later
+ * reader — a configuration UI, or a second adapter family serving the same
+ * provider name — that this plugin owns the format inside the record.
+ */
+export const RECORD_SCOPE = 'llm-pi-ai'
+
+/**
+ * The record address for one pi-ai provider id.
+ * @param providerId - pi-ai's own provider id, which is also the harness route key.
+ * @returns the scoped credential key this adapter family reads and writes.
+ */
+export function recordKeyFor(providerId: string): CredentialKey {
+ return credentialKey(RECORD_SCOPE, providerId)
+}
+
+/**
+ * Translate a stored record into the credential pi-ai expects.
+ *
+ * An `api-key` record is structural on both sides, so it is rebuilt field by
+ * field. A `grant` payload is pi-ai's own OAuth credential, stored verbatim:
+ * the seam treats it as opaque JSON precisely so a library that owns a token
+ * format keeps owning it, refresh fields and all.
+ * @param record - the stored record, or undefined when nothing is stored.
+ * @returns the pi-ai credential, or undefined for an absent record.
+ */
+function toPiCredential(record: CredentialRecord | undefined): Credential | undefined {
+ if (record === undefined) return undefined
+ if (record.kind === 'api-key') {
+ return {
+ type: 'api_key',
+ ...record.key === undefined ? {} : { key: record.key },
+ ...record.env === undefined ? {} : { env: { ...record.env } },
+ }
+ }
+ return record.payload as Credential
+}
+
+/**
+ * Translate a pi-ai credential into the record to store.
+ * @param credential - what a login or refresh produced.
+ * @returns the record to commit, in the union the credential seam stores.
+ */
+function toRecord(credential: Credential): CredentialRecord {
+ if (credential.type === 'api_key') {
+ return {
+ kind: 'api-key',
+ ...credential.key === undefined ? {} : { key: credential.key },
+ ...credential.env === undefined ? {} : { env: { ...credential.env } },
+ }
+ }
+ return { kind: 'grant', payload: credential }
+}
+
+/**
+ * The credential service, or the failure that names what is missing. Reads
+ * answer "nothing stored" without a service, because a composition with no
+ * credential plane genuinely holds no credential; writes refuse, because a
+ * login whose grant silently evaporated would report success and then fail
+ * every request.
+ * @param ctx - the plugin context.
+ * @returns the live service.
+ * @throws {LlmError} code `NO_CREDENTIAL_STORE` when none is mounted.
+ */
+function writableStore(ctx: Context): CredentialProvider {
+ const credentials = ctx.get('credentials')
+ if (credentials === undefined) {
+ throw new LlmError(
+ 'llm-pi-ai: this composition mounts no credentials service, so there is nowhere to store the'
+ + ' credential a sign-in produces; mount one (dsh-credentials-local) to sign in',
+ 'NO_CREDENTIAL_STORE',
+ )
+ }
+ return credentials
+}
+
+/**
+ * A pi-ai `CredentialStore` over the harness credential records.
+ *
+ * pi-ai runs OAuth refresh *inside* `modify()`, so this store's exclusion has
+ * to cover a network round trip rather than a file rename — which is why the
+ * record write path takes a wait limit of its own rather than the short one a
+ * local write would need.
+ *
+ * pi-ai asks this store about every provider in the collection, hand-declared
+ * routes included, and a route key is an arbitrary settings dict key while a
+ * record id is not. An id outside the record grammar can never have stored a
+ * record, so reads answer "nothing stored" and a delete has nothing to remove;
+ * only `modify` refuses it, because a write that cannot land must not report
+ * that it did.
+ * @param ctx - the plugin context carrying the optional `ctx.credentials`.
+ * @returns the store to hand `createModels()`.
+ */
+export function credentialStoreFrom(ctx: Context): CredentialStore {
+ return {
+ async read(providerId) {
+ const credentials = ctx.get('credentials')
+ if (credentials === undefined) return undefined
+ if (!isCredentialKeySegment(providerId)) return undefined
+ return toPiCredential(await credentials.readRecord(recordKeyFor(providerId)))
+ },
+ async list(): Promise {
+ const stored = await ctx.get('credentials')?.listRecords() ?? []
+ const mine: CredentialInfo[] = []
+ for (const entry of stored) {
+ // Records another plugin owns are not this collection's to report:
+ // their payloads are written in a format pi-ai never agreed to.
+ if (credentialKeyScope(entry.key) !== RECORD_SCOPE) continue
+ mine.push({
+ providerId: credentialKeyId(entry.key),
+ type: entry.kind === 'api-key' ? 'api_key' : 'oauth',
+ })
+ }
+ return mine
+ },
+ async modify(providerId, mutate) {
+ if (!isCredentialKeySegment(providerId)) {
+ throw new LlmError(
+ `llm-pi-ai: provider id "${providerId}" cannot address a stored credential record (a record id is a`
+ + ' lowercase hyphenated identifier); authenticate this route through apiKeyEnv instead of a stored'
+ + ' credential',
+ 'UNSTORABLE_PROVIDER_ID',
+ )
+ }
+ const stored = await writableStore(ctx).modifyRecord(recordKeyFor(providerId), async (current) => {
+ const next = await mutate(toPiCredential(current))
+ return next === undefined ? undefined : toRecord(next)
+ })
+ return toPiCredential(stored)
+ },
+ // `async` so a missing service reaches the caller as a rejection: pi-ai's
+ // store contract is promise-returning, and a synchronous throw would
+ // escape the `ModelsError` wrapper every other storage failure gets.
+ async delete(providerId) {
+ if (!isCredentialKeySegment(providerId)) return
+ await writableStore(ctx).deleteRecord(recordKeyFor(providerId))
+ },
+ }
+}
+
+/**
+ * A pi-ai `AuthContext` over the harness credential plane and the host
+ * filesystem.
+ *
+ * `env()` answers from the credential seam first, so a value a deployment
+ * stored through the harness is found by a provider's own ambient discovery —
+ * without this, that discovery reads only the process environment and a stored
+ * `AWS_ACCESS_KEY_ID` is invisible to it. `fileExists()` answers about the host
+ * process's own filesystem rather than the workspace `ctx.fs` seam, because the
+ * paths it is asked about (`~/.aws/credentials`, application-default
+ * credentials) are facts about where this process runs, not about the project
+ * under edit.
+ * @param ctx - the plugin context carrying the optional `ctx.credentials`.
+ * @returns the auth context to hand `createModels()`.
+ */
+export function authContextFrom(ctx: Context): AuthContext {
+ return {
+ async env(name) {
+ // pi-ai asks about arbitrary provider-declared names; one that is not a
+ // POSIX identifier can never have been stored as a reference, and asking
+ // the seam would throw instead of answering "not set".
+ if (isCredentialRefName(name)) {
+ const credentials = ctx.get('credentials')
+ const hit = await credentials?.resolve(credentialRef(name))
+ if (hit !== undefined) return hit.value
+ }
+ return launchEnvironmentOf(ctx).get(name)?.value
+ },
+ async fileExists(path) {
+ const expanded = path.startsWith('~/') || path === '~'
+ ? resolvePath(homedir(), path.slice(1).replace(/^\//, ''))
+ : path
+ try {
+ await access(expanded)
+ return true
+ } catch {
+ // Absent, unreadable, or a broken symlink — every one of which means
+ // this ambient credential source cannot be used, which is the only
+ // distinction the caller makes.
+ return false
+ }
+ },
+ }
+}
diff --git a/packages/llm/llm-pi-ai/src/catalog.ts b/packages/llm/llm-pi-ai/src/catalog.ts
index 7a4ff7e8a2..1e9b126dd5 100644
--- a/packages/llm/llm-pi-ai/src/catalog.ts
+++ b/packages/llm/llm-pi-ai/src/catalog.ts
@@ -15,11 +15,16 @@
import { builtinProviders, getBuiltinModels, getBuiltinProviders } from '@earendil-works/pi-ai/providers/all'
import type { BuiltinProvider } from '@earendil-works/pi-ai/providers/all'
import type {
+ AnthropicMessagesCompat,
Api,
+ BedrockCompat,
+ ChatTemplateKwargValue,
+ KnownApi,
Model,
ModelCost,
ModelThinkingLevel,
OpenAICompletionsCompat,
+ OpenAIResponsesCompat,
Provider,
ThinkingLevelMap,
} from '@earendil-works/pi-ai'
@@ -79,23 +84,16 @@ const THINKING_LEVEL_GATE: Record = {
/** Every pi-ai thinking level a profile may declare, in escalation order. */
export const THINKING_LEVELS = Object.keys(THINKING_LEVEL_GATE) as readonly ModelThinkingLevel[]
-/** 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.
- */
-type WithheldThinkingFormat = 'chat-template' | 'qwen-chat-template'
-
/** One reasoning-dispatch wire format a profile may name. */
-export type PiAiThinkingFormat = Exclude
+export type PiAiThinkingFormat = NonNullable
/**
* The nameable reasoning-dispatch formats, most-reached first. The `Record`
* key type is a drift gate: a pi-ai upgrade that adds a format (0.84 added
- * `baseten`) fails compilation here until the format is classified as offered
- * here or withheld above, so the offer never silently lags the upstream set.
+ * `baseten`) fails compilation here until the new format is named, so the
+ * offer never silently lags the upstream set. The two `chat-template` variants
+ * are nameable because {@link PiAiCompatProfile.chatTemplateKwargs} carries
+ * the kwargs they dispatch through.
*/
const THINKING_FORMAT_GATE: Record = {
'openai': true,
@@ -104,6 +102,8 @@ const THINKING_FORMAT_GATE: Record = {
'together': true,
'zai': true,
'qwen': true,
+ 'chat-template': true,
+ 'qwen-chat-template': true,
'string-thinking': true,
'ant-ling': true,
}
@@ -111,6 +111,41 @@ const THINKING_FORMAT_GATE: Record = {
/** Reasoning-dispatch wire formats a profile may name, most-reached first. */
export const SUPPORTED_THINKING_FORMATS = Object.keys(THINKING_FORMAT_GATE) as readonly PiAiThinkingFormat[]
+/** The output-cap field spellings pi-ai accepts. */
+export type PiAiMaxTokensField = NonNullable
+
+/** Drift gate over {@link PiAiMaxTokensField}; an upstream spelling added here fails compilation until named. */
+const MAX_TOKENS_FIELD_GATE: Record = {
+ max_completion_tokens: true,
+ max_tokens: true,
+}
+
+/** The output-cap field spellings a profile may name. */
+export const MAX_TOKENS_FIELDS = Object.keys(MAX_TOKENS_FIELD_GATE) as readonly PiAiMaxTokensField[]
+
+/** The prompt-cache marker conventions pi-ai accepts. */
+export type PiAiCacheControlFormat = NonNullable
+
+/** Drift gate over {@link PiAiCacheControlFormat}; a new upstream convention fails compilation until named. */
+const CACHE_CONTROL_FORMAT_GATE: Record = {
+ anthropic: true,
+}
+
+/** The prompt-cache marker conventions a profile may name. */
+export const CACHE_CONTROL_FORMATS = Object.keys(CACHE_CONTROL_FORMAT_GATE) as readonly PiAiCacheControlFormat[]
+
+/** The request-state placeholders a `chat_template_kwargs` value may name. */
+export type PiAiChatTemplateVar = Extract['$var']
+
+/** Drift gate over {@link PiAiChatTemplateVar}; a new upstream placeholder fails compilation until named. */
+const CHAT_TEMPLATE_VAR_GATE: Record = {
+ 'thinking.enabled': true,
+ 'thinking.effort': true,
+}
+
+/** The request-state placeholders a profile may name. */
+export const CHAT_TEMPLATE_VARS = Object.keys(CHAT_TEMPLATE_VAR_GATE) as readonly PiAiChatTemplateVar[]
+
let providerIndex: Map | undefined
/**
@@ -141,26 +176,6 @@ export function catalogProviderIds(): readonly string[] {
return getBuiltinProviders()
}
-/**
- * Whether the installed catalog provider for one route declares an api-key
- * method — the only authentication this adapter obtains on its own.
- *
- * A key is what the harness resolves through its own credential seam and hands
- * pi-ai per request. pi-ai's other method, OAuth, resolves from a *stored*
- * OAuth credential alone: `resolveProviderAuth` has no ambient path for it,
- * this adapter builds its `Models` collection with no credential store, and
- * nothing here runs a login flow. So a provider offering OAuth by itself
- * leaves nothing for this adapter to authenticate with, and the posture such a
- * provider invites — no key configured, credentials discovered by the provider
- * — fails every request with `Provider is not configured`.
- * @param provider - provider route key.
- * @returns whether the catalog provider takes an api key; false for a route
- * pi-ai does not ship, which the caller answers for separately.
- */
-export function catalogProviderTakesApiKey(provider: string): boolean {
- return catalogProvider(provider)?.auth.apiKey !== undefined
-}
-
/**
* The installed catalog models for one route, indexed by model id.
* @param provider - provider route key.
@@ -183,19 +198,336 @@ export function catalogModels(provider: string): Map> {
export type PiAiReasoningEfforts = Partial>
/**
- * 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 define their reasoning
- * fields in the protocol itself — so resolution rejects a model-level switch
- * anywhere else, while a route-level default skips past models it cannot fit.
+ * Whether one pi-ai compat field is configurable on a profile.
+ *
+ * `withhold` is the disposition for a field pi-ai's installed catalog already
+ * sets for a named vendor. Reaching for one of those on a hand-declared route
+ * means configuring a provider that should have been named as a catalog route
+ * instead, where the installed entry carries the right value already.
+ */
+type CompatDisposition = 'offer' | 'withhold'
+
+/**
+ * Disposition of every `OpenAICompletionsCompat` field. The `Record` key type
+ * is a drift gate: a pi-ai upgrade that adds a field fails compilation here
+ * until it is classified, so the offer never silently lags the upstream set.
+ */
+const COMPLETIONS_COMPAT_GATE = {
+ supportsStore: 'offer',
+ supportsDeveloperRole: 'offer',
+ supportsReasoningEffort: 'offer',
+ supportsUsageInStreaming: 'offer',
+ maxTokensField: 'offer',
+ requiresToolResultName: 'offer',
+ requiresAssistantAfterToolResult: 'offer',
+ requiresThinkingAsText: 'offer',
+ requiresReasoningContentOnAssistantMessages: 'offer',
+ thinkingFormat: 'offer',
+ chatTemplateKwargs: 'offer',
+ supportsStrictMode: 'offer',
+ cacheControlFormat: 'offer',
+ supportsLongCacheRetention: 'offer',
+ openRouterRouting: 'withhold',
+ vercelGatewayRouting: 'withhold',
+ zaiToolStream: 'withhold',
+ supportsOpenAIGrammarTools: 'withhold',
+ sendSessionAffinityHeaders: 'withhold',
+ deferredToolsMode: 'withhold',
+ sessionAffinityFormat: 'withhold',
+} as const satisfies Record
+
+/** Disposition of every `OpenAIResponsesCompat` field; a drift gate like the one above. */
+const RESPONSES_COMPAT_GATE = {
+ supportsDeveloperRole: 'offer',
+ supportsStrictMode: 'offer',
+ supportsLongCacheRetention: 'offer',
+ sessionAffinityFormat: 'withhold',
+ supportsOpenAIGrammarTools: 'withhold',
+ supportsToolSearch: 'withhold',
+ supportsExplicitPromptCacheMode: 'withhold',
+} as const satisfies Record
+
+/** Disposition of every `AnthropicMessagesCompat` field; a drift gate like the one above. */
+const ANTHROPIC_COMPAT_GATE = {
+ supportsEagerToolInputStreaming: 'offer',
+ supportsLongCacheRetention: 'offer',
+ supportsCacheControlOnTools: 'offer',
+ supportsTemperature: 'offer',
+ forceAdaptiveThinking: 'offer',
+ allowEmptySignature: 'offer',
+ supportsStrictTools: 'offer',
+ sendSessionAffinityHeaders: 'withhold',
+ supportsToolReferences: 'withhold',
+} as const satisfies Record
+
+/** Disposition of every `BedrockCompat` field; a drift gate like the one above. */
+const BEDROCK_COMPAT_GATE = {
+ supportsStrictMode: 'offer',
+} as const satisfies Record
+
+/**
+ * Every wire protocol pi-ai gives a compat type. Derived from `Model.compat`'s
+ * own conditional rather than listed by hand, so a pi-ai release that gives a
+ * further protocol a compat type fails the {@link COMPAT_GATES} entry list
+ * until someone classifies its fields. A protocol pi-ai gives no compat type
+ * resolves away here and takes no configured compat at all.
+ */
+type ApiWithCompat = { [K in KnownApi]: NonNullable['compat']> extends never ? never : K }[KnownApi]
+
+/**
+ * The compat gate of every wire protocol a profile may configure.
+ *
+ * Keyed by protocol, but grouped by pi-ai's compat *type*: the three Responses
+ * protocols share `OpenAIResponsesCompat`, so a switch settable on one is
+ * settable on all three. Keying by protocol alone would refuse
+ * `azure-openai-responses` and `openai-codex-responses` the fields their own
+ * models declare.
+ */
+const COMPAT_GATES: Readonly>>> = {
+ 'openai-completions': COMPLETIONS_COMPAT_GATE,
+ 'openai-responses': RESPONSES_COMPAT_GATE,
+ 'azure-openai-responses': RESPONSES_COMPAT_GATE,
+ 'openai-codex-responses': RESPONSES_COMPAT_GATE,
+ 'anthropic-messages': ANTHROPIC_COMPAT_GATE,
+ 'bedrock-converse-stream': BEDROCK_COMPAT_GATE,
+}
+
+/**
+ * The compat gate of one resolved protocol. A `string` lookup rather than a
+ * keyed read: a route's `api` is configuration, so it may name a protocol
+ * pi-ai gives no compat type — or none at all.
+ * @param api - resolved wire protocol.
+ * @returns that protocol's field gate, or `undefined` when it takes no compat.
+ */
+function compatGate(api: string): Readonly> | undefined {
+ return (COMPAT_GATES as Readonly>>>)[api]
+}
+
+/** The field names one gate offers. */
+type OfferedIn = { [K in keyof G]: G[K] extends 'offer' ? K : never }[keyof G]
+
+/** Every compat field name a profile may set, on whichever protocol takes it. */
+type OfferedCompatField =
+ | OfferedIn
+ | OfferedIn
+ | OfferedIn
+ | OfferedIn
+
+/**
+ * pi-ai wire-compatibility switches, set on the route (its models' default) or
+ * per model (winning over the route, field by field).
+ *
+ * pi-ai decides each of these from the provider id and baseURL when no layer
+ * sets it, and a private gateway's URL says nothing: for an endpoint it does
+ * not recognize the detection answers as though it were OpenAI itself, which
+ * is wrong for most OpenAI-compatible gateways. So every field here is one a
+ * deployment must be able to state because nothing can infer it, while the
+ * fields pi-ai's catalog sets for a named vendor stay withheld.
+ *
+ * A field belongs to the protocols whose upstream compat type declares it: a
+ * model-level switch its protocol does not take fails resolution, and a
+ * route-level one skips past models it cannot fit. "The three Responses
+ * protocols" below means `openai-responses`, `azure-openai-responses`, and
+ * `openai-codex-responses`, which pi-ai gives one shared compat type, so a
+ * switch settable on one is settable on all three.
*/
export interface PiAiCompatProfile {
- /** Reasoning parameter format 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. */
+ /** Whether the endpoint accepts `store`; `openai-completions`. */
+ supportsStore?: boolean
+ /**
+ * Whether the endpoint accepts the `developer` role for the system prompt,
+ * which pi-ai sends only to a reasoning model; `false` keeps `system`.
+ * `openai-completions` and the three Responses protocols.
+ */
+ supportsDeveloperRole?: boolean
+ /** Whether the endpoint accepts `reasoning_effort`; `openai-completions`. */
supportsReasoningEffort?: boolean
+ /** Whether the endpoint accepts `stream_options: {include_usage: true}`; `openai-completions`. */
+ supportsUsageInStreaming?: boolean
+ /** Which output-cap field the endpoint reads; `openai-completions`. */
+ maxTokensField?: NonNullable
+ /** Whether tool results must carry `name`; `openai-completions`. */
+ requiresToolResultName?: boolean
+ /** Whether a user message after tool results needs an assistant message between; `openai-completions`. */
+ requiresAssistantAfterToolResult?: boolean
+ /** Whether thinking blocks must travel as text in `` delimiters; `openai-completions`. */
+ requiresThinkingAsText?: boolean
+ /** Whether replayed assistant messages need an empty `reasoning_content` while reasoning is on; `openai-completions`. */
+ requiresReasoningContentOnAssistantMessages?: boolean
+ /** Reasoning parameter format the endpoint expects; `openai-completions`. */
+ thinkingFormat?: PiAiThinkingFormat
+ /**
+ * Kwargs sent as `chat_template_kwargs`, which pi-ai reads only under the
+ * two `chat-template` thinking formats; `openai-completions`. Nothing checks
+ * that pairing: the format in force may come from the installed catalog
+ * entry or from pi-ai's own baseURL detection, neither of which resolution
+ * can read, so kwargs set beside another format are sent nowhere.
+ */
+ chatTemplateKwargs?: NonNullable
+ /**
+ * Whether the endpoint accepts `strict` in tool definitions;
+ * `openai-completions`, the three Responses protocols, `bedrock-converse-stream`.
+ */
+ supportsStrictMode?: boolean
+ /** Prompt-cache marker convention; `openai-completions`. */
+ cacheControlFormat?: NonNullable
+ /**
+ * Whether the endpoint accepts long prompt-cache retention;
+ * `openai-completions`, the three Responses protocols, `anthropic-messages`.
+ */
+ supportsLongCacheRetention?: boolean
+ /** Whether the endpoint accepts per-tool `eager_input_streaming`; `anthropic-messages`. */
+ supportsEagerToolInputStreaming?: boolean
+ /** Whether the endpoint accepts `cache_control` on tool definitions; `anthropic-messages`. */
+ supportsCacheControlOnTools?: boolean
+ /** Whether the endpoint accepts the `temperature` request field; `anthropic-messages`. */
+ supportsTemperature?: boolean
+ /** Whether to force adaptive thinking regardless of model id; `anthropic-messages`. */
+ forceAdaptiveThinking?: boolean
+ /** Whether to replay an empty thinking signature instead of converting thinking to text; `anthropic-messages`. */
+ allowEmptySignature?: boolean
+ /** Whether the endpoint accepts Anthropic strict tool schemas; `anthropic-messages`. */
+ supportsStrictTools?: boolean
+}
+
+/** Compile-time constraint that `T` is `never`. */
+type AssertNever = T
+
+/**
+ * Proof that every documented field is one a gate offers. A field the profile
+ * declares past the gates fails compilation with its own name in the error.
+ */
+export type EveryProfileFieldIsOffered = AssertNever>
+
+/**
+ * Proof that every offered field is documented. A gate entry flipped to
+ * `offer` without a profile field fails compilation with its own name in the
+ * error, which is the half a schema alone cannot catch.
+ */
+export type EveryOfferedFieldIsDocumented = AssertNever>
+
+/** Compile-time constraint that `T` is `true`. */
+type AssertTrue = T
+
+/** Every compat type a gate classifies, merged so one `Pick` reaches all offered fields. */
+type UpstreamCompat = OpenAICompletionsCompat & OpenAIResponsesCompat & AnthropicMessagesCompat & BedrockCompat
+
+/**
+ * Proof that each documented field carries its upstream type, not a hand-copied
+ * restatement of it. The name gates above pin *which* fields exist; this pins
+ * their types, in both directions because each catches a different drift. A
+ * profile field wider than upstream accepts a value the provider rejects, and
+ * `resolveModelCompat`'s cast to `ModelCompat` would hide it; a narrower one
+ * refuses a value the provider accepts, which is how an upgrade that widens a
+ * union would otherwise leave configuration silently behind.
+ */
+export type EveryProfileFieldMatchesUpstream = AssertTrue<
+ PiAiCompatProfile extends Partial>
+ ? Partial> extends PiAiCompatProfile ? true : false
+ : false
+>
+
+/**
+ * The compat entries a profile actually set.
+ *
+ * schemastery materializes an absent dict as `{}` — the behavior
+ * `reasoningEfforts` works around with a union — so every parsed profile
+ * carries a `chatTemplateKwargs` key whether or not anyone wrote one. An empty
+ * one states nothing here: it would send no kwargs, which is exactly what
+ * leaving the field out does, so absent and empty are the same request and
+ * neither may make a route look like it configured a switch. A valueless
+ * scalar is the other thing schemastery lets through, and it is refused by
+ * {@link assertOfferedCompatFields} before this runs rather than filtered.
+ * @param compat - the configured switches, when any.
+ * @returns the entries carrying a value, in declaration order.
+ */
+function configuredCompatEntries(compat: PiAiCompatProfile | undefined): readonly (readonly [string, unknown])[] {
+ return Object.entries(compat ?? {}).flatMap(([field, value]) => {
+ const empty = typeof value === 'object' && value !== null && !Array.isArray(value)
+ && Object.keys(value as object).length === 0
+ return empty ? [] : [[field, value] as const]
+ })
+}
+
+/**
+ * The protocols offering one compat field, in {@link COMPAT_GATES} order.
+ * @param field - configured compat field name.
+ * @returns the protocols whose compat takes it; empty when none does, which
+ * is either a withheld field or a name no upstream compat type declares.
+ */
+function compatProtocols(field: string): readonly string[] {
+ return Object.entries(COMPAT_GATES).flatMap(([api, gate]) => gate[field] === 'offer' ? [api] : [])
+}
+
+/**
+ * The compat fields one protocol offers, for a diagnostic that has to show
+ * what was available instead of the name that missed.
+ * @param api - wire protocol.
+ * @returns the offered field names, or an empty list for a protocol taking no compat.
+ */
+function offeredCompatFields(api: string): readonly string[] {
+ return Object.entries(compatGate(api) ?? {}).flatMap(([field, disposition]) => disposition === 'offer' ? [field] : [])
+}
+
+/**
+ * Every offered field name, deduplicated, for the one diagnostic that cannot
+ * narrow by protocol: the vocabulary check runs before any protocol resolves,
+ * which is what lets it refuse a misspelling on a route whose models would
+ * never have reached the protocol that declares the intended field.
+ * @returns the offered field names across every protocol, in gate order.
+ */
+function allOfferedCompatFields(): readonly string[] {
+ const fields = new Set()
+ for (const api of Object.keys(COMPAT_GATES)) {
+ for (const field of offeredCompatFields(api)) fields.add(field)
+ }
+ return [...fields]
+}
+
+/**
+ * Reject a compat key no protocol offers. Runs before any protocol is
+ * resolved, so a withheld field or a misspelling fails even on a route whose
+ * models never reach the protocol that would have taken it — the alternative
+ * being the silent drop that let an unreadable switch look applied.
+ * @param provider - provider route key, for diagnostics.
+ * @param site - the configuration site, for diagnostics.
+ * @param compat - the configured switches, when any.
+ * @throws Error naming the offending key.
+ */
+function assertOfferedCompatFields(
+ provider: string,
+ site: string,
+ compat: PiAiCompatProfile | undefined,
+): void {
+ // Every key, not only the ones carrying a value: a withheld or undeclared
+ // name is never in the schema, so schemastery cannot have materialized it —
+ // whatever its value, a person wrote it and expects it to do something.
+ for (const [field, value] of Object.entries(compat ?? {})) {
+ // The name is judged before the value, so a withheld or misspelled key
+ // written bare is refused for being that name rather than for being empty:
+ // the other order sends someone to supply a value the key would be refused
+ // with anyway.
+ if (compatProtocols(field).length === 0) {
+ const declared = Object.values(COMPAT_GATES).some(gate => gate[field] !== undefined)
+ if (declared) {
+ invalid(provider, `${site} sets compat "${field}", which is not configurable here: pi-ai's installed`
+ + ' catalog sets it for the vendors that need it, so name that provider as the route instead')
+ }
+ invalid(provider, `${site} sets compat "${field}", which no wire protocol declares; the configurable`
+ + ` switches are ${allOfferedCompatFields().join(', ')}`)
+ }
+ // A valueless key (`supportsDeveloperRole:`) survives schemastery, which
+ // passes nullable data through before any member schema runs — the same
+ // behavior `reasoningEfforts` documents — and a `cordis.yml` entry may
+ // reach the same state through `!!js undefined`. Either way the key is
+ // kept, so carrying it forward writes nothing over whatever the next layer
+ // resolved, leaving pi-ai's `??` at its baseURL detection: the "written but
+ // not applied" outcome this surface exists to refuse.
+ if (value == null) {
+ invalid(provider, `${site} sets compat "${field}" with no value; give it one, or remove the key to`
+ + ' leave the field to the next layer — the installed catalog entry, then pi-ai\'s own detection')
+ }
+ }
}
/** One configured model entry: an id plus the catalog fields it overrides. */
@@ -233,7 +565,7 @@ export interface PiAiModelProfile {
* declares the offered levels and their wire spellings.
*/
reasoningEfforts?: false | PiAiReasoningEfforts
- /** Reasoning-dispatch switches for this model, winning over the route's. */
+ /** pi-ai wire-compatibility switches for this model, winning over the route's per field; one its protocol does not declare is refused. */
compat?: PiAiCompatProfile
}
@@ -258,7 +590,7 @@ export interface RouteCatalogRequest {
models?: readonly PiAiModelProfile[]
/** Installed-catalog customizations by model id; only meaningful while `models` is absent. */
modelOverrides?: Readonly>
- /** Reasoning-dispatch switches for every `openai-completions` model on the route; entries override per field. */
+ /** Route-level wire-compatibility switches, landing on each model whose protocol declares them; entries override per field. */
compat?: PiAiCompatProfile
/** Context capacity for a model neither the entry nor the catalog sizes. */
defaultContextWindow: number
@@ -368,16 +700,20 @@ function resolveModelReasoning(
return { reasoning: true, thinkingLevelMap: map }
}
+/** The compat block a materialized model carries, whichever protocol it speaks. */
+type ModelCompat = OpenAICompletionsCompat | OpenAIResponsesCompat | AnthropicMessagesCompat | BedrockCompat
+
/**
- * Resolve one model's compat block from the profile's reasoning switches.
+ * Resolve one model's compat block from the profile's switches.
*
- * A model switch wins over the route switch; whatever neither sets keeps the
- * installed entry's value, and a field no layer decides falls through to
- * pi-ai's baseURL-derived detection. Only an `openai-completions` model takes
- * the switches at all: a model-level switch on any other protocol fails
- * resolution, while a route-level default skips past such models — the same
- * posture as the route-level `reasoning` default, which also must not fail
- * models it does not fit.
+ * A model switch wins over the route switch field by field; whatever neither
+ * sets keeps the installed entry's value, and a field no layer decides falls
+ * through to pi-ai's own detection. A model-level switch its protocol does not
+ * take fails resolution — about one named model it can only be a mistake —
+ * while a route-level one skips past such models, since a route default must
+ * stay settable on a route whose models do not all speak one protocol. Every
+ * field reaching here is offered by some protocol; {@link
+ * assertOfferedCompatFields} has already refused the rest.
* @param provider - provider route key, for diagnostics.
* @param entry - the configured model entry.
* @param route - the route-level switches, when any.
@@ -391,31 +727,31 @@ function resolveModelCompat(
route: PiAiCompatProfile | undefined,
base: Model | undefined,
api: string,
-): { compat: OpenAICompletionsCompat } | Record {
- const thinkingFormat = entry.compat?.thinkingFormat ?? route?.thinkingFormat
- const supportsReasoningEffort = entry.compat?.supportsReasoningEffort ?? route?.supportsReasoningEffort
- if (thinkingFormat === undefined && supportsReasoningEffort === undefined) return {}
- if (api !== 'openai-completions') {
- if (entry.compat?.thinkingFormat !== undefined || entry.compat?.supportsReasoningEffort !== undefined) {
- invalid(provider, `model "${entry.id}" sets compat reasoning switches, but its api is "${api}";`
- + ' thinkingFormat and supportsReasoningEffort exist only on openai-completions')
- }
- return {}
+): { compat: ModelCompat } | Record {
+ const gate = compatGate(api)
+ const configured: Record = {}
+ for (const [field, value] of configuredCompatEntries(route)) {
+ if (gate?.[field] !== 'offer') continue
+ configured[field] = value
}
+ for (const [field, value] of configuredCompatEntries(entry.compat)) {
+ if (gate?.[field] !== 'offer') {
+ const offered = offeredCompatFields(api)
+ invalid(provider, `model "${entry.id}" sets compat "${field}", but its api is "${api}", which does not`
+ + ` take it; that switch exists on ${compatProtocols(field).join(', ')}, and "${api}" offers`
+ + ` ${offered.length === 0 ? 'no configurable compat' : offered.join(', ')}`)
+ }
+ configured[field] = value
+ }
+ if (Object.keys(configured).length === 0) return {}
// The installed entry's compat matches the entry's OWN api — a route-level
// `api` repoint (an anthropic catalog served through an OpenAI-compatible
// gateway) leaves `base.compat` in the other protocol's shape, so it is
// inherited only while the resolved api still is the entry's. A repointed
// model starts from pi-ai's baseURL-derived detection instead, which is
// what a protocol change means for every other compat field too.
- const inherited: OpenAICompletionsCompat | undefined = base?.api === api ? base.compat : undefined
- return {
- compat: {
- ...inherited,
- ...thinkingFormat === undefined ? {} : { thinkingFormat },
- ...supportsReasoningEffort === undefined ? {} : { supportsReasoningEffort },
- },
- }
+ const inherited = base?.api === api ? base.compat : undefined
+ return { compat: { ...inherited, ...configured } as ModelCompat }
}
/** One route's materialized catalog, plus the request caps its profile chose. */
@@ -485,8 +821,13 @@ export function resolveRouteModels(request: RouteCatalogRequest): RouteCatalog {
+ ' must be listed in configuration')
}
const routeApi = sharedCatalogApi(defaults)
- const routeCompatDefined = request.compat?.thinkingFormat !== undefined
- || request.compat?.supportsReasoningEffort !== undefined
+ // Vocabulary before protocols: a withheld or undeclared switch is refused
+ // wherever it is written, so it cannot look applied on a route whose models
+ // never reach the protocol that would have taken it.
+ assertOfferedCompatFields(provider, 'route', request.compat)
+ for (const entry of entries) {
+ assertOfferedCompatFields(provider, `model "${entry.id}"`, entry.compat)
+ }
const seen = new Set()
const configuredMaxTokens = new Map()
const models = entries.map((entry) => {
@@ -538,9 +879,15 @@ export function resolveRouteModels(request: RouteCatalogRequest): RouteCatalog {
...resolveModelCompat(provider, entry, request.compat, base, api),
}
})
- if (routeCompatDefined && !models.some(model => model.api === 'openai-completions')) {
- invalid(provider, 'sets compat reasoning switches, but no model on the route speaks openai-completions;'
- + ' thinkingFormat and supportsReasoningEffort exist only on that protocol')
+ // Per field, not per block: a route may default a switch its completions
+ // models take beside one only its anthropic models do, and neither should
+ // fail for the other's sake. What is refused is a route default no model on
+ // the route could ever read, which is a route that will not behave as written.
+ for (const [field] of configuredCompatEntries(request.compat)) {
+ const takers = compatProtocols(field)
+ if (models.some(model => takers.includes(model.api))) continue
+ invalid(provider, `sets compat "${field}", but no model on the route speaks a protocol that takes it;`
+ + ` it exists on ${takers.join(', ')}`)
}
return { models, configuredMaxTokens }
}
diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts
index d1e1f697a9..62d49a58ee 100644
--- a/packages/llm/llm-pi-ai/src/config.ts
+++ b/packages/llm/llm-pi-ai/src/config.ts
@@ -14,14 +14,22 @@
* @module dsh-llm-pi-ai/config
*/
-import type { CacheRetention, ModelThinkingLevel, Provider, ThinkingBudgets, Transport } from '@earendil-works/pi-ai'
+import type { CacheRetention, ChatTemplateKwargValue, ModelThinkingLevel, Provider, ThinkingBudgets, Transport } from '@earendil-works/pi-ai'
import z from '@deepseek-ai/schemastery'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import type { CredentialRef } from '@deepseek-ai/dsh-credentials'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import { resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm'
import type { ResolvedRetryPolicy, RetryPolicyConfig } from '@deepseek-ai/dsh-llm'
-import { MODALITIES, resolveRouteModels, SUPPORTED_THINKING_FORMATS, THINKING_LEVELS } from './catalog.ts'
+import {
+ CACHE_CONTROL_FORMATS,
+ CHAT_TEMPLATE_VARS,
+ MAX_TOKENS_FIELDS,
+ MODALITIES,
+ resolveRouteModels,
+ SUPPORTED_THINKING_FORMATS,
+ THINKING_LEVELS,
+} from './catalog.ts'
import type {
PiAiCompatProfile,
PiAiModality,
@@ -102,10 +110,11 @@ export interface PiAiProviderProfile {
*/
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.
+ * pi-ai wire-compatibility switches defaulting every model on this route
+ * whose protocol declares them; each model's own `compat` overrides per
+ * field. What neither sets keeps the installed catalog entry's value, then
+ * pi-ai's own detection. A switch no model on the route could read is
+ * refused rather than left looking applied.
*/
compat?: PiAiCompatProfile
/**
@@ -205,9 +214,43 @@ const thinkingBudgets = z.object({
high: z.number(),
})
+/**
+ * One `chat_template_kwargs` value. The `$var` member is pi-ai's placeholder
+ * for a value dispatch fills from the request's thinking state, which is what
+ * makes a chat-template gateway configurable without restating its template.
+ */
+const chatTemplateKwarg: z = z.union([
+ z.string(),
+ z.number(),
+ z.boolean(),
+ z.const(null),
+ z.object({
+ $var: z.union(CHAT_TEMPLATE_VARS).required(),
+ omitWhenOff: z.boolean(),
+ }),
+])
+
const compatProfile: z = z.object({
- thinkingFormat: z.union(SUPPORTED_THINKING_FORMATS),
+ supportsStore: z.boolean(),
+ supportsDeveloperRole: z.boolean(),
supportsReasoningEffort: z.boolean(),
+ supportsUsageInStreaming: z.boolean(),
+ maxTokensField: z.union(MAX_TOKENS_FIELDS),
+ requiresToolResultName: z.boolean(),
+ requiresAssistantAfterToolResult: z.boolean(),
+ requiresThinkingAsText: z.boolean(),
+ requiresReasoningContentOnAssistantMessages: z.boolean(),
+ thinkingFormat: z.union(SUPPORTED_THINKING_FORMATS),
+ chatTemplateKwargs: z.dict(chatTemplateKwarg),
+ supportsStrictMode: z.boolean(),
+ cacheControlFormat: z.union(CACHE_CONTROL_FORMATS),
+ supportsLongCacheRetention: z.boolean(),
+ supportsEagerToolInputStreaming: z.boolean(),
+ supportsCacheControlOnTools: z.boolean(),
+ supportsTemperature: z.boolean(),
+ forceAdaptiveThinking: z.boolean(),
+ allowEmptySignature: z.boolean(),
+ supportsStrictTools: z.boolean(),
})
/**
diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts
index 1bbeec79db..846509f8c6 100644
--- a/packages/llm/llm-pi-ai/src/index.ts
+++ b/packages/llm/llm-pi-ai/src/index.ts
@@ -61,10 +61,12 @@ import { assertUsableApiKey, LlmError } from '@deepseek-ai/dsh-llm'
import type { AdapterRegistrationHandle, DirectoryRegistrationHandle, LlmConfigurableProvider } from '@deepseek-ai/dsh-llm'
import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
import { PiAiAdapter } from './adapter.ts'
-import { catalogProviderIds, catalogProviderTakesApiKey } from './catalog.ts'
+import { authContextFrom, credentialStoreFrom } from './auth.ts'
+import { catalogProviderIds } from './catalog.ts'
import { assertServiceable, Config, resolveProfiles } from './config.ts'
import type { ResolvedPiAiProviderProfile } from './config.ts'
import { discoverModels } from './discovery.ts'
+import { registerPiAiFlows } from './login.ts'
export { PiAiAdapter } from './adapter.ts'
export type { PiAiAdapterOptions } from './adapter.ts'
@@ -79,6 +81,7 @@ export type {
PiAiThinkingFormat,
ResolvedPiAiProviderProfile,
} from './config.ts'
+export { recordKeyFor } from './auth.ts'
export { supportedProtocols } from './provider.ts'
export const name = 'llm-pi-ai'
@@ -105,15 +108,10 @@ function registrationFacts(profiles: ReadonlyMap ctx.get('attachments'),
onReplayDegrade: ({ provider, model, reason }) => {
ctx.logger.warn(
@@ -208,6 +204,12 @@ export function apply(ctx: Context, config: Config): void {
)
},
})
+ // Independent of the route set: signing in is what makes a route worth
+ // adding, so the flows are offered before any profile names their provider.
+ // Scoped to the authorization seam rather than injected outright, because a
+ // composition without it (headless, ACP) simply has no surface to sign in
+ // from, while everything else this plugin does still works.
+ ctx.inject(['authorization'], (authorized) => { registerPiAiFlows(authorized, auth) })
// The full installed catalog is configurable from the moment the plugin
// mounts — dormant or not — so configuration surfaces can offer every
// pi-ai provider before any route exists. Hand-declared routes join it as
diff --git a/packages/llm/llm-pi-ai/src/login.ts b/packages/llm/llm-pi-ai/src/login.ts
new file mode 100644
index 0000000000..578489afb4
--- /dev/null
+++ b/packages/llm/llm-pi-ai/src/login.ts
@@ -0,0 +1,161 @@
+/**
+ * Authorization flows for the pi-ai providers that ship a login. This is the
+ * whole of the translation between the harness's neutral notice/prompt
+ * vocabulary and pi-ai's `AuthInteraction`; nothing above it knows which
+ * library ran the conversation.
+ *
+ * @module dsh-llm-pi-ai/login
+ */
+
+import { createModels } from '@earendil-works/pi-ai'
+import type { AuthEvent, AuthPrompt, AuthType, Provider } from '@earendil-works/pi-ai'
+import type { Context } from '@deepseek-ai/cordis'
+import type { AuthorizationMethod, AuthorizationPrompt, AuthorizationSession } from '@deepseek-ai/dsh-authorization'
+import { isCredentialKeySegment } from '@deepseek-ai/dsh-credentials'
+import { catalogProvider, catalogProviderIds } from './catalog.ts'
+import { recordKeyFor } from './auth.ts'
+import type { PiAiAuthInjection } from './adapter.ts'
+
+/**
+ * The login methods one catalog provider offers.
+ *
+ * A method appears only when pi-ai can actually run it: `oauth` always carries
+ * a `login`, while an api-key method has one only when the provider collects
+ * its key interactively — which every installed one currently does, so a key is
+ * typed into pi-ai's own prompt rather than into the settings form.
+ * @param provider - the installed catalog provider, if pi-ai ships one.
+ * @returns its methods, most preferred first; empty when it offers no login.
+ */
+function loginMethods(provider: Provider | undefined): AuthorizationMethod[] {
+ const methods: AuthorizationMethod[] = []
+ const oauth = provider?.auth.oauth
+ if (oauth !== undefined) methods.push({ id: 'oauth', label: oauth.loginLabel ?? oauth.name })
+ const apiKey = provider?.auth.apiKey
+ if (apiKey?.login !== undefined) methods.push({ id: 'api-key', label: apiKey.name })
+ return methods
+}
+
+/**
+ * Restate one pi-ai login event in the seam's vocabulary.
+ *
+ * A device-code grant is the one event carrying two things the human needs at
+ * once — where to go and what to type there — which is why the neutral notice
+ * has a `code` beside its `url` rather than folding the code into the message.
+ * @param event - what pi-ai reported.
+ * @param session - the attempt to report it to.
+ */
+function relay(event: AuthEvent, session: AuthorizationSession): void {
+ switch (event.type) {
+ case 'info': {
+ const link = event.links?.[0]
+ session.notify({ message: event.message, ...link === undefined ? {} : { url: link.url } })
+ return
+ }
+ case 'auth_url':
+ session.notify({
+ message: event.instructions ?? 'Open this page to continue signing in.',
+ url: event.url,
+ })
+ return
+ case 'device_code':
+ session.notify({
+ message: 'Enter this code on the verification page to finish signing in.',
+ url: event.verificationUri,
+ code: event.userCode,
+ })
+ return
+ case 'progress':
+ session.notify({ message: event.message })
+ return
+ default:
+ // pi-ai's event union is open to new members: a build that meets one it
+ // does not know still shows the human that something is happening rather
+ // than going silent mid-login.
+ session.notify({ message: 'Signing in…' })
+ }
+}
+
+/**
+ * Restate one pi-ai prompt in the seam's vocabulary.
+ *
+ * `manual_code` becomes a plain text question because the difference pi-ai
+ * draws — a code the human copies from a browser rather than a value they know
+ * — changes nothing a surface renders. Its own `signal` is carried through, and
+ * that is the part which matters: it is how a flow racing a typed code against
+ * a browser callback withdraws the losing question.
+ * @param prompt - what pi-ai asked.
+ * @returns the neutral prompt to put to the human.
+ */
+function restate(prompt: AuthPrompt): AuthorizationPrompt {
+ const signal = prompt.signal === undefined ? {} : { signal: prompt.signal }
+ switch (prompt.type) {
+ case 'select':
+ return { ...signal, kind: 'select', message: prompt.message, options: prompt.options }
+ case 'secret':
+ return {
+ ...signal,
+ kind: 'secret',
+ message: prompt.message,
+ ...prompt.placeholder === undefined ? {} : { placeholder: prompt.placeholder },
+ }
+ default:
+ return {
+ ...signal,
+ kind: 'text',
+ message: prompt.message,
+ ...prompt.placeholder === undefined ? {} : { placeholder: prompt.placeholder },
+ }
+ }
+}
+
+/**
+ * Register one authorization flow per installed provider that ships a login.
+ *
+ * Registration is unconditional on configuration: a provider has to be signed
+ * into before a route for it is worth adding, so the flow exists from the
+ * moment the plugin mounts rather than appearing once a profile does.
+ * @param ctx - the plugin context carrying `ctx.authorization`.
+ * @param auth - the injectables every collection here is built with.
+ */
+export function registerPiAiFlows(ctx: Context, auth: PiAiAuthInjection): void {
+ for (const providerId of catalogProviderIds()) {
+ const provider = catalogProvider(providerId)
+ const [first, ...rest] = loginMethods(provider)
+ /* v8 ignore next 3 -- every id here names an installed provider and every
+ installed provider ships a login, so nothing is skipped today; the guard
+ is what keeps that from becoming a crash if either stops being true. */
+ if (provider === undefined || first === undefined) continue
+ /* v8 ignore next 7 -- every installed catalog id today is a lowercase
+ hyphenated identifier; the guard keeps a future upstream id outside the
+ record grammar (dotted or uppercase, as vendor ids elsewhere already
+ are) from throwing in `recordKeyFor` and failing the whole mount. */
+ if (!isCredentialKeySegment(providerId)) {
+ ctx.logger.warn(
+ 'llm-pi-ai: catalog provider "%s" cannot address a credential record; its sign-in is not offered',
+ providerId)
+ continue
+ }
+ ctx.authorization.registerFlow({
+ key: recordKeyFor(providerId),
+ label: provider.name,
+ methods: [first, ...rest],
+ async run(session) {
+ // A collection of its own, holding only the provider being signed
+ // into: login is not serving requests, and the credential it produces
+ // lands in the shared store either way.
+ const models = createModels(auth)
+ models.setProvider(provider)
+ // Total over the two ids declared above, and the seam only ever hands
+ // back one a flow declared.
+ const type: AuthType = session.method === 'oauth' ? 'oauth' : 'api_key'
+ // pi-ai persists what the login returns through that same store, which
+ // is what makes it the single writer of this record.
+ await models.login(providerId, type, {
+ signal: session.signal,
+ notify: (event) => { relay(event, session) },
+ prompt: prompt => session.prompt(restate(prompt)),
+ })
+ },
+ })
+ }
+}
diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts
index d45a2e3089..a36a180f7d 100644
--- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts
+++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts
@@ -13,6 +13,7 @@ import { PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all'
import { DEFAULT_MAX_REQUEST_IMAGE_BYTES, resolveProfiles } from '../src/config.ts'
+import { memoryAuth } from './auth-double.ts'
import { assemble } from './assemble.ts'
import { closeMockServers, mockServer, textEvents } from './mock-server.ts'
@@ -47,6 +48,7 @@ function adapterOf(
return new PiAiAdapter({
profiles: () => resolveProfiles(providers),
resolveApiKey: () => Promise.resolve(apiKey),
+ auth: memoryAuth(),
})
}
@@ -597,6 +599,46 @@ describe('provider profile lifecycle', () => {
expect(server.requests[1]).not.toHaveProperty('reasoning_effort')
})
+ it('keeps the system role on a declared route whose gateway rejects the developer one', async () => {
+ vi.stubEnv('PI_TEST_KEY', 'test-key')
+ const server = await mockServer([{ events: textEvents }, { events: textEvents }])
+ const ctx = new Context()
+ await ctx.plugin(LlmRuntime)
+ await ctx.plugin(LlmPiAi, {
+ providers: {
+ 'acme-gateway': {
+ apiKeyEnv: 'PI_TEST_KEY',
+ api: 'openai-completions',
+ baseURL: `${server.url}/v1`,
+ models: [
+ // pi-ai sends the system prompt as `developer` to a reasoning
+ // model whenever its URL detection says the endpoint is OpenAI —
+ // which is what an unrecognized private URL resolves to. Most
+ // OpenAI-compatible gateways reject that role.
+ { id: 'acme-think', reasoningEfforts: { off: null, high: 'high' }, compat: { supportsDeveloperRole: false } },
+ { id: 'acme-guess', reasoningEfforts: { off: null, high: 'high' } },
+ ],
+ },
+ },
+ })
+ const roles = async (model: string): Promise => {
+ await assemble(ctx, {
+ provider: 'acme-gateway',
+ model,
+ reasoningEffort: ReasoningEffortId('high'),
+ system: 'you are a harness',
+ messages: [],
+ })
+ const request = server.requests.at(-1) as { messages: { role: string }[] }
+ return request.messages.map(message => message.role)
+ }
+
+ expect(await roles('acme-think')).toEqual(['system'])
+ // The switch is the only thing that changes it: the same route, same
+ // endpoint, same reasoning declaration still gets pi-ai's guess.
+ expect(await roles('acme-guess')).toEqual(['developer'])
+ })
+
it('sends a declared off value as the effort parameter instead of omitting it', async () => {
vi.stubEnv('PI_TEST_KEY', 'test-key')
const server = await mockServer([{ events: textEvents }])
diff --git a/packages/llm/llm-pi-ai/tests/auth-double.ts b/packages/llm/llm-pi-ai/tests/auth-double.ts
new file mode 100644
index 0000000000..a05c017d04
--- /dev/null
+++ b/packages/llm/llm-pi-ai/tests/auth-double.ts
@@ -0,0 +1,39 @@
+import type { Credential } from '@earendil-works/pi-ai'
+import type { PiAiAuthInjection } from '../src/adapter.ts'
+
+/**
+ * The auth injectables for tests that exercise streaming rather than
+ * authentication: an in-process credential store and an ambient context that
+ * finds nothing. A test needing real records builds the store over
+ * `ctx.credentials` instead, through `credentialStoreFrom`.
+ * @param seed - credentials to start with, by pi-ai provider id.
+ * @returns the injection to hand `PiAiAdapter`, with its store readable.
+ */
+export function memoryAuth(seed: Record = {}): PiAiAuthInjection & {
+ stored: Map
+} {
+ const stored = new Map(Object.entries(seed))
+ return {
+ stored,
+ credentials: {
+ read: id => Promise.resolve(stored.get(id)),
+ list: () => Promise.resolve([...stored].map(([providerId, credential]) => ({
+ providerId,
+ type: credential.type,
+ }))),
+ async modify(id, mutate) {
+ const next = await mutate(stored.get(id))
+ if (next !== undefined) stored.set(id, next)
+ return stored.get(id)
+ },
+ delete: (id) => {
+ stored.delete(id)
+ return Promise.resolve()
+ },
+ },
+ authContext: {
+ env: () => Promise.resolve(undefined),
+ fileExists: () => Promise.resolve(false),
+ },
+ }
+}
diff --git a/packages/llm/llm-pi-ai/tests/auth.spec.ts b/packages/llm/llm-pi-ai/tests/auth.spec.ts
new file mode 100644
index 0000000000..27a496a2c3
--- /dev/null
+++ b/packages/llm/llm-pi-ai/tests/auth.spec.ts
@@ -0,0 +1,172 @@
+import { mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import LocalCredentialProvider from '@deepseek-ai/dsh-credentials-local'
+import { credentialKey, credentialRef } from '@deepseek-ai/dsh-credentials'
+import { authContextFrom, credentialStoreFrom, recordKeyFor } from '../src/auth.ts'
+
+const CODEX = recordKeyFor('openai-codex')
+
+const dirs: string[] = []
+
+/** A context whose credential records live in a throwaway `$DSH_HOME`. */
+async function stored(): Promise {
+ const dir = await mkdtemp(join(tmpdir(), 'dsh-pi-auth-'))
+ dirs.push(dir)
+ const ctx = new Context()
+ await ctx.plugin(LocalCredentialProvider, { path: join(dir, '.credentials.yaml'), watch: false })
+ return ctx
+}
+
+afterEach(async () => {
+ vi.unstubAllEnvs()
+ await Promise.all(dirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
+})
+
+describe('pi-ai credential store over harness records', () => {
+ it('reads nothing for a provider with no record', async () => {
+ const store = credentialStoreFrom(await stored())
+
+ await expect(store.read('openai-codex')).resolves.toBeUndefined()
+ })
+
+ it('round-trips an api-key credential field by field', async () => {
+ const ctx = await stored()
+ const store = credentialStoreFrom(ctx)
+
+ await store.modify('cloudflare', () =>
+ Promise.resolve({ type: 'api_key', key: 'sk-live', env: { ACCOUNT_ID: 'acct-1' } }))
+
+ await expect(store.read('cloudflare'))
+ .resolves.toEqual({ type: 'api_key', key: 'sk-live', env: { ACCOUNT_ID: 'acct-1' } })
+ await expect(ctx.credentials.readRecord(recordKeyFor('cloudflare')))
+ .resolves.toEqual({ kind: 'api-key', key: 'sk-live', env: { ACCOUNT_ID: 'acct-1' } })
+ })
+
+ it('stores an api-key credential carrying neither a key nor env', async () => {
+ const store = credentialStoreFrom(await stored())
+
+ await store.modify('bedrock', () => Promise.resolve({ type: 'api_key' }))
+
+ await expect(store.read('bedrock')).resolves.toEqual({ type: 'api_key' })
+ })
+
+ it('keeps an OAuth credential verbatim, refresh fields and all', async () => {
+ const ctx = await stored()
+ const store = credentialStoreFrom(ctx)
+ const granted = { type: 'oauth' as const, access: 'at', refresh: 'rt', expires: 42, accountId: 'acc' }
+
+ await store.modify('openai-codex', () => Promise.resolve(granted))
+
+ await expect(store.read('openai-codex')).resolves.toEqual(granted)
+ await expect(ctx.credentials.readRecord(CODEX)).resolves.toEqual({ kind: 'grant', payload: granted })
+ })
+
+ it('shows the mutation the current credential and leaves it alone when declined', async () => {
+ const store = credentialStoreFrom(await stored())
+ await store.modify('openai-codex', () =>
+ Promise.resolve({ type: 'oauth', access: 'first', refresh: 'r', expires: 1 }))
+ const seen: unknown[] = []
+
+ const unchanged = await store.modify('openai-codex', (current) => {
+ seen.push(current)
+ return Promise.resolve(undefined)
+ })
+
+ expect(seen).toEqual([{ type: 'oauth', access: 'first', refresh: 'r', expires: 1 }])
+ expect(unchanged).toEqual({ type: 'oauth', access: 'first', refresh: 'r', expires: 1 })
+ })
+
+ it('lists only the records this adapter family owns', async () => {
+ const ctx = await stored()
+ const store = credentialStoreFrom(ctx)
+ await store.modify('openai-codex', () =>
+ Promise.resolve({ type: 'oauth', access: 'at', refresh: 'rt', expires: 1 }))
+ await store.modify('cloudflare', () => Promise.resolve({ type: 'api_key', key: 'k' }))
+ // Another plugin's record for a provider name this one also serves: its
+ // payload is written in a format pi-ai never agreed to.
+ await ctx.credentials.modifyRecord(credentialKey('llm-kimi', 'openai-codex'), () =>
+ Promise.resolve({ kind: 'grant', payload: { theirs: true } }))
+
+ await expect(store.list()).resolves.toEqual([
+ { providerId: 'openai-codex', type: 'oauth' },
+ { providerId: 'cloudflare', type: 'api_key' },
+ ])
+ })
+
+ it('forgets a credential on delete, and stays quiet when there was none', async () => {
+ const store = credentialStoreFrom(await stored())
+ await store.modify('openai-codex', () =>
+ Promise.resolve({ type: 'oauth', access: 'at', refresh: 'rt', expires: 1 }))
+
+ await store.delete('openai-codex')
+ await store.delete('openai-codex')
+
+ await expect(store.read('openai-codex')).resolves.toBeUndefined()
+ })
+
+ it('reads empty but refuses to write without a credentials service', async () => {
+ const store = credentialStoreFrom(new Context())
+
+ await expect(store.read('openai-codex')).resolves.toBeUndefined()
+ await expect(store.list()).resolves.toEqual([])
+ await expect(store.modify('openai-codex', () => Promise.resolve({ type: 'api_key', key: 'k' })))
+ .rejects.toThrow(/mounts no credentials service/)
+ await expect(store.delete('openai-codex')).rejects.toThrow(/mounts no credentials service/)
+ })
+
+ it('treats a provider id outside the record grammar as holding nothing', async () => {
+ const store = credentialStoreFrom(await stored())
+
+ // A hand-declared route key is an arbitrary settings dict key, and pi-ai
+ // reads it during auth resolution: the answer is "not signed in", never a
+ // thrown address error…
+ await expect(store.read('My_Proxy')).resolves.toBeUndefined()
+ // …nothing can ever be stored under it, so a logout has nothing to remove…
+ await expect(store.delete('My_Proxy')).resolves.toBeUndefined()
+ // …while a write that cannot land must refuse rather than report success.
+ await expect(store.modify('My_Proxy', () => Promise.resolve({ type: 'api_key', key: 'k' })))
+ .rejects.toThrow(/cannot address a stored credential record/)
+ })
+})
+
+describe('pi-ai ambient auth context', () => {
+ beforeEach(() => {
+ vi.stubEnv('PI_AUTH_AMBIENT', 'from-environment')
+ })
+
+ it('answers an environment name from the credential seam first', async () => {
+ const ctx = await stored()
+ await ctx.credentials.set(credentialRef('PI_AUTH_SEAM'), 'from-seam')
+
+ await expect(authContextFrom(ctx).env('PI_AUTH_SEAM')).resolves.toBe('from-seam')
+ })
+
+ it('falls back to the launch environment when nothing is stored', async () => {
+ await expect(authContextFrom(await stored()).env('PI_AUTH_AMBIENT')).resolves.toBe('from-environment')
+ })
+
+ it('answers "not set" for a name no reference could ever address', async () => {
+ // pi-ai asks about provider-declared names; one outside the reference
+ // grammar has no reference to miss, and must not throw.
+ await expect(authContextFrom(await stored()).env('not a var')).resolves.toBeUndefined()
+ })
+
+ it('answers about the host filesystem, expanding a leading ~', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'dsh-pi-home-'))
+ dirs.push(dir)
+ await writeFile(join(dir, 'creds'), 'x')
+ // Both spellings of "home": os.homedir() reads HOME on POSIX and
+ // USERPROFILE on Windows, and the expansion under test goes through it.
+ vi.stubEnv('HOME', dir)
+ vi.stubEnv('USERPROFILE', dir)
+ const context = authContextFrom(await stored())
+
+ await expect(context.fileExists('~/creds')).resolves.toBe(true)
+ await expect(context.fileExists('~/missing')).resolves.toBe(false)
+ await expect(context.fileExists(join(dir, 'creds'))).resolves.toBe(true)
+ await expect(context.fileExists('~')).resolves.toBe(true)
+ })
+})
diff --git a/packages/llm/llm-pi-ai/tests/catalog.spec.ts b/packages/llm/llm-pi-ai/tests/catalog.spec.ts
index eb4ba511d4..99886cdbaf 100644
--- a/packages/llm/llm-pi-ai/tests/catalog.spec.ts
+++ b/packages/llm/llm-pi-ai/tests/catalog.spec.ts
@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
-import LlmRuntime, { createUserMessage } from '@deepseek-ai/dsh-llm'
+import LlmRuntime, { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
import FileSettingsProvider from '@deepseek-ai/dsh-settings-file'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
@@ -15,6 +15,7 @@ import type { Api, Model, OpenAICompletionsCompat, Provider } from '@earendil-wo
import { resolveProfiles } from '../src/config.ts'
import { buildProvider, supportedProtocols } from '../src/provider.ts'
import { assemble } from './assemble.ts'
+import { memoryAuth } from './auth-double.ts'
import { closeMockServers, mockServer, textEvents } from './mock-server.ts'
const homes: string[] = []
@@ -755,7 +756,7 @@ describe('modelOverrides', () => {
})
})
-describe('reasoning-dispatch compat switches', () => {
+describe('compat switches', () => {
/** The materialized models of one route, keyed by id. */
function modelsOf(providers: Record, route: string): Map> {
const models = resolveProfiles(providers).get(route)?.piProvider.getModels() ?? []
@@ -813,18 +814,244 @@ describe('reasoning-dispatch compat switches', () => {
expect(models.get(responses.id)?.compat).toEqual(responses.compat)
})
- it('rejects a model-level switch on a protocol that has no such field', () => {
+ it('rejects a model-level switch on a protocol that has no such field, naming what it offers', () => {
expect(() => resolveProfiles({
anthropic: {
models: [{ id: 'claude-sonnet-4-5', compat: { thinkingFormat: 'openai' } }],
},
- })).toThrow(/exist only on openai-completions/)
+ })).toThrow(/its api is "anthropic-messages", which does not take it.*exists on openai-completions/s)
})
it('rejects route switches no model on the route can take', () => {
expect(() => resolveProfiles({
anthropic: { compat: { thinkingFormat: 'openai' } },
- })).toThrow(/no model on the route speaks openai-completions/)
+ })).toThrow(/no model on the route speaks a protocol that takes it/)
+ })
+
+ it('carries the developer-role switch onto a hand-declared reasoning model', () => {
+ // pi-ai reads this switch only for a reasoning model, and detects it from
+ // the endpoint URL — which for a private gateway answers as though it were
+ // OpenAI itself, so the route must be able to say otherwise.
+ const models = modelsOf({
+ 'acme-gateway': {
+ api: 'openai-completions',
+ baseURL: 'https://acme.test',
+ compat: { supportsDeveloperRole: false, maxTokensField: 'max_tokens' },
+ models: [{ id: 'acme-think', reasoningEfforts: { off: null, high: 'high' } }],
+ },
+ }, 'acme-gateway')
+
+ expect(models.get('acme-think')?.compat).toEqual({
+ supportsDeveloperRole: false,
+ maxTokensField: 'max_tokens',
+ })
+ })
+
+ it('carries a switch both OpenAI protocols declare onto an openai-responses route', () => {
+ const models = modelsOf({
+ 'acme-responses': {
+ api: 'openai-responses',
+ baseURL: 'https://acme.test',
+ compat: { supportsDeveloperRole: false },
+ models: [{ id: 'acme-r', reasoningEfforts: { off: null, high: 'high' } }],
+ },
+ }, 'acme-responses')
+
+ expect(models.get('acme-r')?.compat).toEqual({ supportsDeveloperRole: false })
+ })
+
+ it('carries an anthropic-only switch onto an anthropic-messages route', () => {
+ const models = modelsOf({
+ 'acme-claude': {
+ api: 'anthropic-messages',
+ baseURL: 'https://acme.test',
+ compat: { supportsTemperature: false, supportsCacheControlOnTools: false },
+ models: [{ id: 'acme-opus' }],
+ },
+ }, 'acme-claude')
+
+ expect(models.get('acme-opus')?.compat).toEqual({
+ supportsTemperature: false,
+ supportsCacheControlOnTools: false,
+ })
+ })
+
+ it('lands each route switch only on the models whose protocol declares it', () => {
+ const catalog = getBuiltinModels('xai') as readonly Model[]
+ const completions = catalog.find(model => model.api === 'openai-completions')
+ const responses = catalog.find(model => model.api === 'openai-responses')
+ if (completions === undefined || responses === undefined) throw new Error('xai no longer ships a mixed catalog')
+
+ const models = modelsOf({
+ xai: {
+ // Both protocols take the first switch; only completions takes the second.
+ compat: { supportsDeveloperRole: false, thinkingFormat: 'openai' },
+ models: [{ id: completions.id }, { id: responses.id }],
+ },
+ }, 'xai')
+
+ const onCompletions = models.get(completions.id)?.compat as OpenAICompletionsCompat
+ expect(onCompletions.supportsDeveloperRole).toBe(false)
+ expect(onCompletions.thinkingFormat).toBe('openai')
+ const onResponses = models.get(responses.id)?.compat as { supportsDeveloperRole?: boolean; thinkingFormat?: string }
+ expect(onResponses.supportsDeveloperRole).toBe(false)
+ expect(onResponses.thinkingFormat).toBeUndefined()
+ })
+
+ it('carries chat-template kwargs beside the thinking format that dispatches through them', () => {
+ const models = modelsOf({
+ 'acme-qwen': {
+ api: 'openai-completions',
+ baseURL: 'https://acme.test',
+ models: [{
+ id: 'qwen-local',
+ reasoningEfforts: { off: null, medium: 'medium' },
+ compat: {
+ thinkingFormat: 'qwen-chat-template',
+ chatTemplateKwargs: { enable_thinking: { $var: 'thinking.enabled' } },
+ },
+ }],
+ },
+ }, 'acme-qwen')
+
+ expect(models.get('qwen-local')?.compat).toEqual({
+ thinkingFormat: 'qwen-chat-template',
+ chatTemplateKwargs: { enable_thinking: { $var: 'thinking.enabled' } },
+ })
+ })
+
+ it('rejects a model switch on an unrecognized protocol as having no configurable compat', () => {
+ expect(() => resolveProfiles({
+ 'acme-gateway': {
+ api: 'acme-chat',
+ baseURL: 'https://acme.test',
+ models: [{ id: 'acme-a', compat: { supportsStore: false } }],
+ },
+ })).toThrow(/its api is "acme-chat", which does not take it.*"acme-chat" offers no configurable compat/s)
+ })
+
+ it('refuses a valueless compat key written through the composed settings path', async () => {
+ // The write path an operator reaches: a section resolved by schemastery,
+ // judged by this adapter's section validator before it is stored.
+ // schemastery keeps the null, so nothing but that check stands between it
+ // and `Model.compat`.
+ const dir = await home()
+ const ctx = await bootWithSettings(dir, {})
+ await expect(ctx.settings.update(settingsNamespace('llm-pi-ai'), {
+ providers: {
+ 'acme-gateway': {
+ api: 'openai-completions',
+ baseURL: 'https://acme.test/v1',
+ compat: { supportsDeveloperRole: null },
+ models: [{ id: 'acme-a' }],
+ },
+ },
+ })).rejects.toThrow(/compat "supportsDeveloperRole" with no value/)
+ })
+
+ it('carries a compat switch from a written settings section onto the wire', async () => {
+ // End to end for the reported gap: the switch enters as configuration and
+ // changes the request the provider receives, not merely the resolved model.
+ vi.stubEnv(KEY_ENV, 'test-key')
+ const server = await mockServer([{ events: textEvents }])
+ const dir = await home()
+ const ctx = await bootWithSettings(dir, {})
+ await ctx.settings.update(settingsNamespace('llm-pi-ai'), {
+ providers: {
+ 'acme-gateway': {
+ apiKeyEnv: KEY_ENV,
+ api: 'openai-completions',
+ baseURL: `${server.url}/v1`,
+ compat: { supportsDeveloperRole: false },
+ models: [{ id: 'acme-think', reasoningEfforts: { off: null, high: 'high' } }],
+ },
+ },
+ })
+
+ await assemble(ctx, {
+ provider: 'acme-gateway',
+ model: 'acme-think',
+ reasoningEffort: ReasoningEffortId('high'),
+ system: 'you are a harness',
+ messages: [],
+ })
+
+ const request = server.requests[0] as { messages: { role: string }[] }
+ expect(request.messages.map(message => message.role)).toEqual(['system'])
+ })
+
+ it('refuses a valueless compat key rather than writing null over the catalog', () => {
+ // schemastery passes a YAML bare key through as null. Carried forward it
+ // would replace the installed entry's value, and pi-ai's `??` would then
+ // reach for its baseURL detection — the "written but not applied" outcome.
+ expect(() => resolveProfiles({
+ 'acme-gateway': {
+ api: 'openai-completions',
+ baseURL: 'https://acme.test',
+ compat: { supportsDeveloperRole: null } as never,
+ models: [{ id: 'acme-a' }],
+ },
+ })).toThrow(/compat "supportsDeveloperRole" with no value/)
+ })
+
+ it('refuses a compat key whose value is undefined, as a cordis.yml entry can write', () => {
+ // `!!js undefined` reaches the same state as a YAML bare key, and
+ // schemastery keeps the key either way, so both are refused together.
+ expect(() => resolveProfiles({
+ 'acme-gateway': {
+ api: 'openai-completions',
+ baseURL: 'https://acme.test',
+ compat: { supportsDeveloperRole: undefined } as never,
+ models: [{ id: 'acme-a' }],
+ },
+ })).toThrow(/compat "supportsDeveloperRole" with no value/)
+ })
+
+ it('refuses a valueless compat key on a model entry too', () => {
+ expect(() => resolveProfiles({
+ deepseek: {
+ modelOverrides: { 'deepseek-v4-flash': { compat: { requiresReasoningContentOnAssistantMessages: null } } as never },
+ },
+ })).toThrow(/model "deepseek-v4-flash" sets compat "requiresReasoningContentOnAssistantMessages" with no value/)
+ })
+
+ it('serves the Responses compat type on every protocol pi-ai gives it to', () => {
+ // pi-ai types azure-openai-responses and openai-codex-responses with the
+ // same OpenAIResponsesCompat, so a switch settable on one is settable on all.
+ for (const route of ['azure-openai-responses', 'openai-codex']) {
+ const models = modelsOf({ [route]: { compat: { supportsDeveloperRole: false } } }, route)
+ const [first] = [...models.values()]
+ expect((first?.compat as { supportsDeveloperRole?: boolean }).supportsDeveloperRole).toBe(false)
+ }
+ })
+
+ it('serves the Bedrock compat type on its own protocol', () => {
+ const models = modelsOf({ 'amazon-bedrock': { compat: { supportsStrictMode: false } } }, 'amazon-bedrock')
+ const [first] = [...models.values()]
+ expect((first?.compat as { supportsStrictMode?: boolean }).supportsStrictMode).toBe(false)
+ })
+
+ it('refuses a compat key no wire protocol declares instead of dropping it', () => {
+ // The silent drop is what let an unreadable switch look applied: schemastery
+ // passes unknown keys through, and resolution used to read only two fields.
+ expect(() => resolveProfiles({
+ 'acme-gateway': {
+ api: 'openai-completions',
+ baseURL: 'https://acme.test',
+ compat: { supportsDevelperRole: false } as never,
+ models: [{ id: 'acme-a' }],
+ },
+ })).toThrow(/compat "supportsDevelperRole", which no wire protocol declares; the configurable switches are .*\bsupportsDeveloperRole\b/)
+ })
+
+ it('refuses a compat key pi-ai’s catalog owns, pointing at the catalog route', () => {
+ expect(() => resolveProfiles({
+ 'acme-gateway': {
+ api: 'openai-completions',
+ baseURL: 'https://acme.test',
+ models: [{ id: 'acme-a', compat: { openRouterRouting: {} } as never }],
+ },
+ })).toThrow(/compat "openRouterRouting", which is not configurable here/)
})
})
@@ -839,6 +1066,7 @@ describe('resolution snapshots', () => {
// Credential resolution is the real await inside a stream call, and the
// window a configuration change has to land in.
resolveApiKey: async () => { await held; return 'k' },
+ auth: memoryAuth(),
})
const chunks: StreamChunk[] = []
@@ -867,7 +1095,11 @@ describe('resolution snapshots', () => {
const first = await mockServer([{ events: textEvents }])
const second = await mockServer([{ events: textEvents }])
let current = resolveProfiles({ deepseek: { baseURL: `${first.url}/v1` } })
- const adapter = new PiAiAdapter({ profiles: () => current, resolveApiKey: () => Promise.resolve('k') })
+ const adapter = new PiAiAdapter({
+ profiles: () => current,
+ resolveApiKey: () => Promise.resolve('k'),
+ auth: memoryAuth(),
+ })
const drain = async (): Promise => {
for await (const _chunk of adapter.stream({
provider: 'deepseek', model: 'deepseek-v4-flash', messages: [],
@@ -934,30 +1166,22 @@ describe('configurable-provider directory', () => {
expect(ctx.llm.listConfigurableProviders()).toHaveLength(catalogOnly)
})
- it('withholds a catalog route this adapter cannot authenticate', async () => {
+ it('offers every installed catalog route, including one that only signs in', async () => {
const ctx = await harness({})
const offered = ctx.llm.listConfigurableProviders().map(entry => entry.provider)
// `openai-codex` is the one installed provider that authenticates through
- // OAuth alone. pi-ai resolves OAuth only from a *stored* credential, this
- // adapter constructs its collection with no credential store, and nothing
- // here runs a login flow — so every request on such a route fails with
- // `Provider is not configured` before it goes out. Offering it would put a
- // provider on the settings page that no amount of configuration can make
- // work.
- expect(offered).not.toContain('openai-codex')
- // A provider that offers OAuth *beside* an api-key method keeps its entry:
- // the key is a path this adapter can serve.
+ // OAuth alone. It is offered like any other because the collection now
+ // carries a durable credential store and a login flow writes into it, so
+ // the route has a posture that works rather than only one that fails.
+ expect(offered).toContain('openai-codex')
expect(offered).toContain('anthropic')
expect(offered).toContain('openai')
})
- it('still lists a withheld route a stored profile names, as a catalog route', async () => {
- // Withholding the offer must not strand a profile someone already stored:
- // the route keeps its entry so a configuration surface can edit or delete
- // it, and `declared` still answers catalog membership rather than the
- // offer, so the page does not mislabel it as a route this deployment
- // invented.
+ it('lists a route a stored profile names as a catalog route, not a declared one', async () => {
+ // `declared` answers catalog membership, so a profile stored against a
+ // route pi-ai ships is not mislabelled as one this deployment invented.
const ctx = await harness({ providers: { 'openai-codex': { apiKeyEnv: KEY_ENV } } })
expect(ctx.llm.listConfigurableProviders()).toContainEqual({
diff --git a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts
index 1019354156..3decff3749 100644
--- a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts
+++ b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts
@@ -9,6 +9,7 @@ import { LocalCredentialProvider } from '@deepseek-ai/dsh-credentials-local'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import { FileSettingsProvider } from '@deepseek-ai/dsh-settings-file'
import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
+import AuthorizationService from '@deepseek-ai/dsh-authorization'
import { assemble } from './assemble.ts'
import { closeMockServers, mockServer, textEvents } from './mock-server.ts'
@@ -37,7 +38,11 @@ async function home(): Promise {
}
/** Real dynamic composition mirroring the deepseek twin's harness. */
-async function boot(dir: string, config: LlmPiAi.Config): Promise {
+async function boot(
+ dir: string,
+ config: LlmPiAi.Config,
+ options: { authorization?: boolean } = {},
+): Promise {
const ctx = new Context()
cleanups.push(async () => {
await ctx.fiber.dispose()
@@ -45,17 +50,38 @@ async function boot(dir: string, config: LlmPiAi.Config): Promise {
await ctx.plugin(LlmRuntime)
await ctx.plugin(FileSettingsProvider, { path: join(dir, 'settings.yaml'), watch: false })
await ctx.plugin(LocalCredentialProvider, { path: join(dir, '.credentials.yaml'), watch: false })
+ if (options.authorization === true) await ctx.plugin(AuthorizationService)
await ctx.plugin(LlmPiAi, config)
return ctx
}
+describe('login flows in a real composition', () => {
+ it('offers a sign-in for a provider no route names, once the seam is mounted', async () => {
+ const ctx = await boot(await home(), {}, { authorization: true })
+
+ // Zero routes configured: signing in is what makes a route worth adding,
+ // so the offer cannot wait for a profile to name the provider.
+ const codex = ctx.authorization.describe(LlmPiAi.recordKeyFor('openai-codex'))
+ expect(codex?.methods.map(method => method.id)).toEqual(['oauth'])
+ })
+
+ it('mounts without the seam, and simply offers no sign-in', async () => {
+ const ctx = await boot(await home(), {})
+
+ // A headless or ACP composition has no surface to sign in from; everything
+ // else this plugin does still works.
+ expect(ctx.get('authorization')).toBeUndefined()
+ expect(ctx.llm.listConfigurableProviders().length).toBeGreaterThan(0)
+ })
+})
+
describe('request-level dynamic profiles', () => {
it('mounts bare and dormant, then registers routes the moment settings supply providers', async () => {
vi.stubEnv('PI_DYNAMIC_KEY', '')
const dir = await home()
await writeFile(
join(dir, '.credentials.yaml'),
- 'PI_DYNAMIC_KEY: pk-from-settings\nPI_LIVE_KEY: live-key\nPI_OTHER_KEY: other\n',
+ 'version: 1\nrefs:\n PI_DYNAMIC_KEY: pk-from-settings\n PI_LIVE_KEY: live-key\n PI_OTHER_KEY: other\n',
{ mode: 0o600 },
)
const server = await mockServer([{ events: textEvents }])
@@ -93,7 +119,7 @@ describe('request-level dynamic profiles', () => {
const dir = await home()
await writeFile(
join(dir, '.credentials.yaml'),
- 'PI_LIVE_KEY: live-key\nPI_OTHER_KEY: other\n',
+ 'version: 1\nrefs:\n PI_LIVE_KEY: live-key\n PI_OTHER_KEY: other\n',
{ mode: 0o600 },
)
const server = await mockServer([{ events: textEvents }])
@@ -122,7 +148,7 @@ describe('request-level dynamic profiles', () => {
it('rotates the per-request credential referenced by apiKeyEnv', async () => {
vi.stubEnv('PI_DYNAMIC_KEY', '')
const dir = await home()
- await writeFile(join(dir, '.credentials.yaml'), 'PI_DYNAMIC_KEY: pk-one\n', { mode: 0o600 })
+ await writeFile(join(dir, '.credentials.yaml'), 'version: 1\nrefs:\n PI_DYNAMIC_KEY: pk-one\n', { mode: 0o600 })
const server = await mockServer([{ events: textEvents }, { events: textEvents }])
const ctx = await boot(dir, {
providers: { deepseek: { apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: server.url } },
@@ -173,7 +199,7 @@ describe('request-level dynamic profiles', () => {
const dir = await home()
await writeFile(
join(dir, '.credentials.yaml'),
- 'PI_LIVE_KEY: live-key\nPI_OTHER_KEY: other\n',
+ 'version: 1\nrefs:\n PI_LIVE_KEY: live-key\n PI_OTHER_KEY: other\n',
{ mode: 0o600 },
)
const server = await mockServer([{ events: textEvents }, { events: textEvents }])
diff --git a/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts b/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts
index a4e89424f6..6ca02f281a 100644
--- a/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts
+++ b/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts
@@ -49,7 +49,7 @@ async function loadComposition(): Promise<{ ctx: Context; settingsPath: string }
root = await mkdtemp(join(tmpdir(), 'dsh-pi-composition-'))
const settingsPath = join(root, 'settings.yaml')
await writeFile(settingsPath, '# personal settings\n')
- await writeFile(join(root, '.credentials.yaml'), 'PI_COMPOSITION_KEY: key-from-store\n', { mode: 0o600 })
+ await writeFile(join(root, '.credentials.yaml'), 'version: 1\nrefs:\n PI_COMPOSITION_KEY: key-from-store\n', { mode: 0o600 })
const configPath = join(root, 'cordis.yml')
await writeFile(configPath, [
diff --git a/packages/llm/llm-pi-ai/tests/login.spec.ts b/packages/llm/llm-pi-ai/tests/login.spec.ts
new file mode 100644
index 0000000000..beb65ce188
--- /dev/null
+++ b/packages/llm/llm-pi-ai/tests/login.spec.ts
@@ -0,0 +1,198 @@
+import { mkdtemp, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import AuthorizationService from '@deepseek-ai/dsh-authorization'
+import type { AuthorizationInteraction, AuthorizationNotice, AuthorizationPrompt } from '@deepseek-ai/dsh-authorization'
+import LocalCredentialProvider from '@deepseek-ai/dsh-credentials-local'
+import type { CredentialKey } from '@deepseek-ai/dsh-credentials'
+import type { AuthEvent, AuthInteraction, AuthPrompt, AuthType, Credential } from '@earendil-works/pi-ai'
+
+const login = vi.hoisted(() => vi.fn())
+
+// The whole of what this module does with pi-ai is run one provider's login
+// against a collection built with the harness store, so the collection is the
+// boundary worth observing; a real login would open a browser.
+vi.mock('@earendil-works/pi-ai', async importOriginal => ({
+ ...await importOriginal(),
+ createModels: () => ({ setProvider: () => {}, login }),
+}))
+
+const { credentialStoreFrom, authContextFrom, recordKeyFor } = await import('../src/auth.ts')
+const { registerPiAiFlows } = await import('../src/login.ts')
+
+const CODEX = recordKeyFor('openai-codex')
+const dirs: string[] = []
+
+/** A context with the record store, the seam, and every pi-ai login flow. */
+async function harness(): Promise {
+ const dir = await mkdtemp(join(tmpdir(), 'dsh-pi-login-'))
+ dirs.push(dir)
+ const ctx = new Context()
+ await ctx.plugin(LocalCredentialProvider, { path: join(dir, '.credentials.yaml'), watch: false })
+ await ctx.plugin(AuthorizationService)
+ registerPiAiFlows(ctx, { credentials: credentialStoreFrom(ctx), authContext: authContextFrom(ctx) })
+ return ctx
+}
+
+/** An interaction recording everything a flow says, answering every question. */
+function surface(answer = 'typed'): AuthorizationInteraction & {
+ notices: AuthorizationNotice[]
+ prompts: AuthorizationPrompt[]
+} {
+ const notices: AuthorizationNotice[] = []
+ const prompts: AuthorizationPrompt[] = []
+ return {
+ notices,
+ prompts,
+ notify: (notice) => { notices.push(notice) },
+ prompt: (prompt) => {
+ prompts.push(prompt)
+ return Promise.resolve(answer)
+ },
+ }
+}
+
+/** Drive one attempt, letting the mocked login talk back through `converse`. */
+async function attempt(
+ ctx: Context,
+ converse: (interaction: AuthInteraction) => Promise,
+ request: { key?: CredentialKey; method?: string } = {},
+): Promise> {
+ const ui = surface()
+ login.mockImplementation(async (providerId: string, _type: AuthType, interaction: AuthInteraction) => {
+ await converse(interaction)
+ const granted: Credential = { type: 'oauth', access: 'at', refresh: 'rt', expires: 1 }
+ await credentialStoreFrom(ctx).modify(providerId, () => Promise.resolve(granted))
+ return granted
+ })
+ await expect(ctx.authorization.begin({
+ key: request.key ?? CODEX,
+ interaction: ui,
+ ...request.method === undefined ? {} : { method: request.method },
+ })).resolves.toEqual({ status: 'authorized' })
+ return ui
+}
+
+afterEach(async () => {
+ login.mockReset()
+ await Promise.all(dirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
+})
+
+describe('pi-ai login flows', () => {
+ it('offers one flow per installed provider, with the methods that provider ships', async () => {
+ const ctx = await harness()
+ const offered = ctx.authorization.list()
+
+ // The OAuth-only provider is exactly the case this exists for: nothing
+ // else could ever configure it.
+ expect(offered.find(entry => entry.key === CODEX)?.methods)
+ .toEqual([{ id: 'oauth', label: expect.stringContaining('ChatGPT') as string }])
+ // A provider offering both keeps both, the subscription login first.
+ expect(offered.find(entry => entry.key === recordKeyFor('anthropic'))?.methods.map(one => one.id))
+ .toEqual(['oauth', 'api-key'])
+ // A key-only provider still gets a flow, because pi-ai collects the key
+ // through its own prompt rather than leaving it to the settings form.
+ expect(offered.find(entry => entry.key === recordKeyFor('deepseek'))?.methods.map(one => one.id))
+ .toEqual(['api-key'])
+ })
+
+ it('runs the pi-ai auth type the chosen method names', async () => {
+ const ctx = await harness()
+
+ await attempt(ctx, () => Promise.resolve())
+ expect(login).toHaveBeenLastCalledWith('openai-codex', 'oauth', expect.anything())
+
+ await attempt(ctx, () => Promise.resolve(), { key: recordKeyFor('anthropic'), method: 'api-key' })
+ expect(login).toHaveBeenLastCalledWith('anthropic', 'api_key', expect.anything())
+ })
+
+ it('commits what the login produced, where the adapter reads it back', async () => {
+ const ctx = await harness()
+
+ await attempt(ctx, () => Promise.resolve())
+
+ await expect(ctx.credentials.readRecord(CODEX)).resolves.toEqual({
+ kind: 'grant',
+ payload: { type: 'oauth', access: 'at', refresh: 'rt', expires: 1 },
+ })
+ })
+
+ it('restates every pi-ai login event in the neutral vocabulary', async () => {
+ const ctx = await harness()
+ const events: AuthEvent[] = [
+ { type: 'info', message: 'Read this first', links: [{ url: 'https://help.example' }] },
+ { type: 'info', message: 'Nothing to open' },
+ { type: 'auth_url', url: 'https://auth.example/start', instructions: 'Approve in the tab' },
+ { type: 'auth_url', url: 'https://auth.example/plain' },
+ { type: 'device_code', userCode: 'WXYZ-1234', verificationUri: 'https://device.example' },
+ { type: 'progress', message: 'Exchanging the code' },
+ // pi-ai's event union is open; an unrecognised member must still show
+ // the human that something is happening.
+ { type: 'quantum-handshake' } as unknown as AuthEvent,
+ ]
+
+ const ui = await attempt(ctx, (interaction) => {
+ for (const event of events) interaction.notify(event)
+ return Promise.resolve()
+ })
+
+ expect(ui.notices).toEqual([
+ { message: 'Read this first', url: 'https://help.example' },
+ { message: 'Nothing to open' },
+ { message: 'Approve in the tab', url: 'https://auth.example/start' },
+ { message: 'Open this page to continue signing in.', url: 'https://auth.example/plain' },
+ {
+ message: 'Enter this code on the verification page to finish signing in.',
+ url: 'https://device.example',
+ code: 'WXYZ-1234',
+ },
+ { message: 'Exchanging the code' },
+ { message: 'Signing in…' },
+ ])
+ })
+
+ it('restates every pi-ai prompt, carrying the per-prompt withdrawal signal', async () => {
+ const ctx = await harness()
+ const withdraw = new AbortController()
+ const prompts: AuthPrompt[] = [
+ { type: 'text', message: 'Your workspace', placeholder: 'acme' },
+ { type: 'secret', message: 'Paste the key' },
+ { type: 'secret', message: 'Paste the token', placeholder: 'sk-…' },
+ { type: 'select', message: 'Which account?', options: [{ id: 'a', label: 'Work' }] },
+ // The manual-code question a browser callback can win the race against.
+ { type: 'manual_code', message: 'Paste the code', signal: withdraw.signal },
+ ]
+
+ const ui = await attempt(ctx, async (interaction) => {
+ for (const prompt of prompts) await interaction.prompt(prompt)
+ })
+
+ expect(ui.prompts).toEqual([
+ { kind: 'text', message: 'Your workspace', placeholder: 'acme' },
+ { kind: 'secret', message: 'Paste the key' },
+ { kind: 'secret', message: 'Paste the token', placeholder: 'sk-…' },
+ { kind: 'select', message: 'Which account?', options: [{ id: 'a', label: 'Work' }] },
+ { kind: 'text', message: 'Paste the code', signal: withdraw.signal },
+ ])
+ })
+
+ it('hands the flow the attempt-wide cancellation signal', async () => {
+ const ctx = await harness()
+ let seen: AbortSignal | undefined
+ const controller = new AbortController()
+ login.mockImplementation((_id: string, _type: AuthType, interaction: AuthInteraction) => {
+ seen = interaction.signal
+ controller.abort()
+ return new Promise(() => {})
+ })
+
+ await expect(ctx.authorization.begin({
+ key: CODEX,
+ interaction: surface(),
+ signal: controller.signal,
+ })).resolves.toEqual({ status: 'cancelled' })
+ expect(seen?.aborted).toBe(true)
+ })
+})
diff --git a/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts b/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts
index 317b803f0c..2f409c6d9d 100644
--- a/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts
+++ b/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts
@@ -13,6 +13,7 @@ vi.mock('@earendil-works/pi-ai/api/openai-completions.lazy', () => ({
import { PiAiAdapter } from '../src/adapter.ts'
import { resolveProfiles } from '../src/config.ts'
+import { memoryAuth } from './auth-double.ts'
afterEach(() => { streamSimple.mockReset() })
@@ -27,6 +28,7 @@ function gatewayAdapter(): PiAiAdapter {
},
}),
resolveApiKey: () => Promise.resolve('test-key'),
+ auth: memoryAuth(),
})
}
diff --git a/packages/llm/llm-pi-ai/tsconfig.json b/packages/llm/llm-pi-ai/tsconfig.json
index 6f0b821d36..172dbe8a6e 100644
--- a/packages/llm/llm-pi-ai/tsconfig.json
+++ b/packages/llm/llm-pi-ai/tsconfig.json
@@ -29,6 +29,9 @@
{
"path": "../../credentials/credentials"
},
+ {
+ "path": "../../credentials/authorization"
+ },
{
"path": "../../settings/settings"
},
diff --git a/packages/llm/llm-retry/package.json b/packages/llm/llm-retry/package.json
index 44386ce78b..4773344586 100644
--- a/packages/llm/llm-retry/package.json
+++ b/packages/llm/llm-retry/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-llm-retry",
"description": "Provider-routed LLM request retry policy for the DeepSeek Harness",
- "version": "0.1.0-rc.7",
+ "version": "0.1.0-rc.8",
"publishConfig": {
"access": "public"
},
diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml
index 0700b14362..2621de89af 100644
--- a/packages/llm/llm/README.i18n.yaml
+++ b/packages/llm/llm/README.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/llm/llm/README.md
README.md: e6b3c4924ad4e7cf115abdbfb38d22d0f524e377
-README.zh.md: 51f034af632c19ca91c1d2f91a7c9c22714870e0
+README.zh.md: 91f1c6ede2b800671b35a90d02a23a6d79e96e0e
diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md
index 51f034af63..91f1c6ede2 100644
--- a/packages/llm/llm/README.zh.md
+++ b/packages/llm/llm/README.zh.md
@@ -63,9 +63,11 @@
`LlmCallConfig` 记录一个会话的模型请求所使用的提供方、模型、由适配器定义的可选推理强度,以及采样参数(`provider`、`model`、`reasoningEffort`、`temperature`、`maxTokens`、`stop`,分别与同名 `GenerateOptions` 字段一一对应)。它是作为请求标头一部分记录在会话日志中的每会话状态(见 dsh-session `request/header` 事件),绝不是可静默调整的每次调用旋钮:`agent/request` waterfall 会提议替换,`prepareCall()` 在轮次 signal 控制下校验它并填入适配器默认值,loop 随后记录生效值以及标明哪些字段由适配器默认值填入的标记,再使用已准备调用中与注册绑定的流。下一次提议会省略带标记的默认值,使变更后的路由解析自身的值;未带标记的显式字段会保留。`callConfigEquals(a, b)` 是逐字段真实变更检测器;`deepFreeze(value)` 是 loop 使用的请求所有权辅助函数:每个构造完成的请求都会在分发前深度冻结;`llm/stream` 监听器和适配器只能读取,绝不能改写。`markAgentLoopRequest()` 将该精确对象标记为由进程本地 agent loop 创建,`isAgentLoopRequest()` 让观测方可以将其与同样可能冻结并关联会话、但独立记录的辅助调用区分。`GenerateOptions.purpose` 会对已记录的辅助压缩和会话标题调用进行分类,使适配器可以按调用目的应用不同的传输策略,而不改变普通会话请求。
+
+
### 应用归因(`attribution.ts`)
-每个产品适配器都会在提供方 HTTP 请求上发送应用身份。`attributionHeaders(identity?)` 构建标准 `User-Agent`,默认为公开 `APP_IDENTITY`;白标部署可以替换它,但不能抑制它。适配器会直接验证 wire 标头,或通过自身库 hook 验证。详见 [归因 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md)。
+每个产品适配器都会在提供方 HTTP 请求上发送应用身份。`attributionHeaders(identity?)` 构建标准 `User-Agent`,默认为公开 `APP_IDENTITY`;白标部署可以替换它,但不能抑制它。适配器会直接验证 wire 标头,或通过自身库 hook 验证。详见 [归因 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md)。
### API 密钥校验(`api-key.ts`)
@@ -85,7 +87,7 @@
### 真实适配器
-两个适配器使用不同内部机制实现 `LlmAdapter`:[`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) 针对 `deepseek-official` 路由使用直接 fetch 加 `eventsource-parser` SSE(Server-Sent Events)分帧,[`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) 则通过 `@earendil-works/pi-ai` 动态解析已配置提供方/模型对。两者都遵循 `types.ts` 中的 `StreamChunk` 约定:usage 先于 finish,工具参数保持原始字符串。适配器实现在内部可以抛出异常或发出失败 finish;`LlmRuntime` 会将两者都暴露为终止失败 finish。适配器理由见[双 LLM 适配器](../../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md),服务边界见[终止失败决策](../../../.agents/notes/implemented/architecture/2026-07-29-terminal-llm-stream-failures.md)。
+两个适配器使用不同内部机制实现 `LlmAdapter`:[`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) 针对 `deepseek-official` 路由使用直接 fetch 加 `eventsource-parser` SSE(Server-Sent Events)分帧,[`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) 则通过 `@earendil-works/pi-ai` 动态解析已配置提供方/模型对。两者都遵循 `types.ts` 中的 `StreamChunk` 约定:usage 先于 finish,工具参数保持原始字符串。适配器实现在内部可以抛出异常或发出失败 finish;`LlmRuntime` 会将两者都暴露为终止失败 finish。适配器理由见[双 LLM 适配器](../../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md),服务边界见[终止失败决策](../../../.agents/notes/implemented/architecture/2026-07-29-terminal-llm-stream-failures.zh.md)。
## 模型体验
@@ -95,6 +97,8 @@
透传;注册表保留已组装请求前缀,cache 复用与路由边界属于所选适配器和提供方。
+
+
## 已知限制与暂缓事项
- **本服务不执行重试、缓存或速率限制**:提供方注册会存储重试策略,但 `llm/stream` 仍是单次尝试调用包装层。agent loop 会将已验证模型请求失败单独提供给 `agent/request-error`,其默认行为是保留原始失败;`@deepseek-ai/dsh-llm-retry` 是共享示例主干加载的可选执行器。
diff --git a/packages/llm/llm/package.json b/packages/llm/llm/package.json
index 8f491173d2..f8e2e40224 100644
--- a/packages/llm/llm/package.json
+++ b/packages/llm/llm/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-llm",
"description": "Provider-neutral LLM service interface for the DeepSeek Harness",
- "version": "0.1.0-rc.7",
+ "version": "0.1.0-rc.8",
"publishConfig": {
"access": "public"
},
diff --git a/packages/llm/llm/src/assembler.ts b/packages/llm/llm/src/assembler.ts
index 5eb3668915..7dc5221c9b 100644
--- a/packages/llm/llm/src/assembler.ts
+++ b/packages/llm/llm/src/assembler.ts
@@ -27,7 +27,8 @@ interface PartialBlock {
* {@link ContentBlock}s and a final assistant {@link Message}.
*
* The agent loop feeds it while logging raw chunks for replay fidelity, then
- * reads `blocks()` / `message()` / `usage` / `finish` once the stream ends.
+ * reads `blocks()` / `message()` / `usage` / `finish` once the stream ends,
+ * or `interruptedBlocks()` when cancellation cut the stream short.
*
* Tolerant of delta-only protocols (no block-start/end); deltas arriving for
* an index already closed by `block-end` are ignored (malformed stream) so a
@@ -157,6 +158,25 @@ export class BlockAssembler {
return this.assembled().blocks
}
+ /**
+ * Assemble the prefix an interrupted stream can safely finalize: closed and
+ * open text/reasoning blocks with non-whitespace content, in stream order.
+ * Tool calls are omitted because interruption precedes dispatch; retaining
+ * one would require a fabricated result. Open unknown blocks are also omitted.
+ * @returns the kept blocks; empty when nothing streamed before the interruption.
+ */
+ interruptedBlocks(): ContentBlock[] {
+ return this.order
+ .map((index) => {
+ const partial = this.mustGet(index)
+ const type = partial.block?.type ?? partial.blockType
+ if (type !== 'text' && type !== 'reasoning') return undefined
+ return this.assemble(partial, index)
+ })
+ .filter((block): block is ContentBlock =>
+ (block?.type === 'text' || block?.type === 'reasoning') && block.text.trim() !== '')
+ }
+
/** Usage from the `usage` chunk; undefined until one arrives. */
get usage(): TokenUsage | undefined {
return this._usage
diff --git a/packages/llm/llm/tests/assembler.spec.ts b/packages/llm/llm/tests/assembler.spec.ts
index 9f73ee3f96..2ecfcfd860 100644
--- a/packages/llm/llm/tests/assembler.spec.ts
+++ b/packages/llm/llm/tests/assembler.spec.ts
@@ -224,3 +224,41 @@ describe('BlockAssembler duplicate-close contract', () => {
expect(assembler.blocks()).toEqual([{ type: 'reasoning', text: 'first' }])
})
})
+
+describe('BlockAssembler.interruptedBlocks', () => {
+ it('keeps closed and open text/reasoning blocks with streamed content, in order', () => {
+ const assembler = new BlockAssembler()
+ assembler.push({ type: 'block-start', index: 0, blockType: 'reasoning' })
+ assembler.push({ type: 'reasoning-delta', index: 0, text: 'planning' })
+ assembler.push({ type: 'block-end', index: 0, block: { type: 'reasoning', text: 'planning' } })
+ assembler.push({ type: 'block-start', index: 1, blockType: 'text' })
+ assembler.push({ type: 'text-delta', index: 1, text: 'half an ans' })
+ expect(assembler.interruptedBlocks()).toEqual([
+ { type: 'reasoning', text: 'planning' },
+ { type: 'text', text: 'half an ans' },
+ ])
+ })
+
+ it('drops tool calls whether open or closed — interruption precedes dispatch', () => {
+ const assembler = new BlockAssembler()
+ assembler.push({ type: 'block-start', index: 0, blockType: 'text' })
+ assembler.push({ type: 'text-delta', index: 0, text: 'calling' })
+ assembler.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'calling' } })
+ assembler.push({ type: 'block-start', index: 1, blockType: 'tool-call' })
+ assembler.push({ type: 'tool-call-delta', index: 1, id: CallId('c1'), name: 'read', argumentsDelta: '{"a":1}' })
+ assembler.push({ type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('c1'), name: 'read', arguments: '{"a":1}' } })
+ assembler.push({ type: 'block-start', index: 2, blockType: 'tool-call' })
+ assembler.push({ type: 'tool-call-delta', index: 2, id: CallId('c2'), name: 'read', argumentsDelta: '{"pa' })
+ expect(assembler.interruptedBlocks()).toEqual([{ type: 'text', text: 'calling' }])
+ })
+
+ it('drops empty and whitespace-only text/reasoning blocks and unknown open block types', () => {
+ const assembler = new BlockAssembler()
+ assembler.push({ type: 'block-start', index: 0, blockType: 'text' })
+ assembler.push({ type: 'text-delta', index: 0, text: ' \n' })
+ // A merge-extended block kind this build does not know how to assemble.
+ assembler.push({ type: 'block-start', index: 1, blockType: 'mystery' } as unknown as StreamChunk)
+ assembler.push({ type: 'block-start', index: 2, blockType: 'reasoning' })
+ expect(assembler.interruptedBlocks()).toEqual([])
+ })
+})
diff --git a/packages/llm/token-meter/README.i18n.yaml b/packages/llm/token-meter/README.i18n.yaml
index 44db6530cd..98b96e634d 100644
--- a/packages/llm/token-meter/README.i18n.yaml
+++ b/packages/llm/token-meter/README.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/llm/token-meter/README.md
README.md: a2deab11a31285ba598b8864d3a734ecf7c56620
-README.zh.md: e0c3a8aa123ca0acc374277cd92b415e0b0b3cb5
+README.zh.md: d14cded74691f88db7267ea470f536db85a39218
diff --git a/packages/llm/token-meter/README.zh.md b/packages/llm/token-meter/README.zh.md
index e0c3a8aa12..d14cded746 100644
--- a/packages/llm/token-meter/README.zh.md
+++ b/packages/llm/token-meter/README.zh.md
@@ -41,7 +41,7 @@ fold 跟踪完整请求标头快照、步骤边界、表层追加与替换、成
这是刻意的选择。占用率百分比是面向用户的参考数字,既不是计费记录,也不是门控输入:harness 中没有任何环节依据它做决策,压缩改为直接读取 `measure()`。UI 用测得的压力除以为所选模型单独解析出的容量来计算占用率。
-[Agent Note](../../../.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md) 记录了否决「让这对值保持原子」方案的那次对比。需要同一边界精确数字的消费方应在自己的请求边界调用 `measure()`,而不是读取该投影。
+[Agent Note](../../../.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md) 记录了否决「让这对值保持原子」方案的那次对比。需要同一边界精确数字的消费方应在自己的请求边界调用 `measure()`,而不是读取该投影。
## 组合
diff --git a/packages/llm/token-meter/package.json b/packages/llm/token-meter/package.json
index c5236fbbf8..be479e4cf1 100644
--- a/packages/llm/token-meter/package.json
+++ b/packages/llm/token-meter/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-token-meter",
"description": "Replay-aware token measurement service (ctx.tokenMeter) for the DeepSeek Harness",
- "version": "0.1.0-rc.7",
+ "version": "0.1.0-rc.8",
"publishConfig": {
"access": "public"
},
diff --git a/packages/llm/token-meter/src/breakdown-projection.ts b/packages/llm/token-meter/src/breakdown-projection.ts
index c83879c63a..e0c980e843 100644
--- a/packages/llm/token-meter/src/breakdown-projection.ts
+++ b/packages/llm/token-meter/src/breakdown-projection.ts
@@ -10,22 +10,35 @@ import { canonicalHeader } from '@deepseek-ai/dsh-session'
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
import { estimateSystemTokens, estimateToolsTokens } from './estimate.ts'
import { foldSurfaceProjection } from './surface-projection.ts'
-import type { ShadowPriceClaim } from './surface-projection.ts'
-// Import for the `contextBreakdown` SessionProjectionMap key merge.
+// Import for the `contextBreakdown` SessionProjectionStateMap key merge.
import type {} from './projection.ts'
-interface ContextBreakdownState {
- systemTokens: number
- toolsTokens: number
- messageTokens: number
- /** Shadow price armed by the immediately preceding metering event. */
- claim?: ShadowPriceClaim
+declare module '@deepseek-ai/dsh-session-projection/types' {
+ interface SessionProjectionStateMap {
+ contextBreakdown: ContextBreakdownState
+ }
}
+/** Non-negative integer token count (the shared figure shape). */
+const tokenCount = z.number().int().nonnegative()
+
+/** The context-breakdown state schema and source of its inferred type. */
+const contextBreakdownStateSchema = z.object({
+ systemTokens: tokenCount,
+ toolsTokens: tokenCount,
+ messageTokens: tokenCount,
+ claim: z.object({
+ start: tokenCount,
+ end: tokenCount,
+ tokens: tokenCount,
+ }).optional(),
+}).strict()
+type ContextBreakdownState = z.infer
+
const breakdownSchema = z.object({
- systemTokens: z.number().int().nonnegative(),
- toolsTokens: z.number().int().nonnegative(),
- messageTokens: z.number().int().nonnegative(),
+ systemTokens: tokenCount,
+ toolsTokens: tokenCount,
+ messageTokens: tokenCount,
}).strict()
/**
@@ -39,10 +52,10 @@ const breakdownSchema = z.object({
* state is a fixed handful of numbers, so the persisted checkpoint stays
* O(1) over the session's life.
*/
-export const contextBreakdownProjectionDefinition:
-ProjectionDefinition<'contextBreakdown', ContextBreakdownState> = {
+export const contextBreakdownProjectionDefinition = {
key: 'contextBreakdown',
- schema: breakdownSchema,
+ stateVersion: 2,
+ stateSchema: contextBreakdownStateSchema,
init: () => ({ systemTokens: 0, toolsTokens: 0, messageTokens: 0 }),
apply: (state, event) => {
const fold = foldSurfaceProjection(state.claim, event)
@@ -65,6 +78,8 @@ ProjectionDefinition<'contextBreakdown', ContextBreakdownState> = {
...fold.claim === undefined ? {} : { claim: fold.claim },
}
},
- view: ({ systemTokens, toolsTokens, messageTokens }) => ({ systemTokens, toolsTokens, messageTokens }),
- stateVersion: 2,
-}
+ wire: {
+ viewSchema: breakdownSchema,
+ view: ({ systemTokens, toolsTokens, messageTokens }) => ({ systemTokens, toolsTokens, messageTokens }),
+ },
+} satisfies ProjectionDefinition<'contextBreakdown', ContextBreakdownState>
diff --git a/packages/llm/token-meter/src/usage-projection.ts b/packages/llm/token-meter/src/usage-projection.ts
index a7fc9debf0..864b1669ce 100644
--- a/packages/llm/token-meter/src/usage-projection.ts
+++ b/packages/llm/token-meter/src/usage-projection.ts
@@ -8,18 +8,6 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session'
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
import type { ContextPressureProjection, TokenUsageProjection } from './projection.ts'
import { foldSurfaceProjection } from './surface-projection.ts'
-import type { ShadowPriceClaim } from './surface-projection.ts'
-
-interface UsageSample {
- turn: number
- step: number
- buckets: TokenUsageProjection
-}
-
-interface TokenUsageState {
- totals: TokenUsageProjection
- last: UsageSample | null
-}
const zeroBuckets = (): TokenUsageProjection => ({
uncachedInputTokens: 0,
@@ -59,13 +47,30 @@ const projectionSchema = z.object({
cacheWriteTokens: z.number().int().nonnegative(),
}).strict()
-// Cast for the optional values: under exactOptionalPropertyTypes zod infers
-// `number | undefined` where the interface declares absent-or-number fields.
-const pressureSchema = z.object({
+/**
+ * The token-usage unit's state schema — the one definition of the state
+ * shape; the state type is inferred from it.
+ */
+const tokenUsageStateSchema = z.object({
+ totals: projectionSchema,
+ last: z.object({
+ turn: z.number().int().nonnegative(),
+ step: z.number().int().nonnegative(),
+ buckets: projectionSchema,
+ }).nullable(),
+}).strict()
+
+type TokenUsageState = z.infer
+
+const pressureSchema: z.ZodType = z.object({
pressureTokens: z.number().int().nonnegative().optional(),
projectedTokens: z.number().int().nonnegative().optional(),
contextWindow: z.number().int().positive().optional(),
-}).strict() as unknown as z.ZodType
+}).strict().transform(({ pressureTokens, projectedTokens, contextWindow }) => ({
+ ...pressureTokens === undefined ? {} : { pressureTokens },
+ ...projectedTokens === undefined ? {} : { projectedTokens },
+ ...contextWindow === undefined ? {} : { contextWindow },
+}))
/** Prompt-side pressure of one request: input plus cache traffic, no output. */
const pressureFrom = (usage: TokenUsage): number =>
@@ -79,21 +84,28 @@ const usageOf = (event: SessionEvent): TokenUsage | undefined =>
? event.data.usage
: undefined
-/**
- * Context-occupancy state: the two independent last-wins records plus the
- * O(1) running surface total needed to carry the newest sample forward.
- */
-interface ContextPressureState {
- contextWindow?: number
- pressureTokens?: number
- /** Running heuristic total over the current surface ({@link foldSurfaceProjection}). */
- surfaceTokens: number
- /** {@link surfaceTokens} at the newest usage sample; absent until one lands. */
- sampledSurfaceTokens?: number
- /** Shadow price armed by the immediately preceding metering event. */
- claim?: ShadowPriceClaim
+declare module '@deepseek-ai/dsh-session-projection/types' {
+ interface SessionProjectionStateMap {
+ tokenUsage: TokenUsageState
+ contextPressure: ContextPressureState
+ }
}
+/** The context-pressure state schema and source of its inferred type. */
+const contextPressureStateSchema = z.object({
+ contextWindow: z.number().int().positive().optional(),
+ pressureTokens: z.number().int().nonnegative().optional(),
+ surfaceTokens: z.number().int().nonnegative(),
+ sampledSurfaceTokens: z.number().int().nonnegative().optional(),
+ claim: z.object({
+ start: z.number().int().nonnegative(),
+ end: z.number().int().nonnegative(),
+ tokens: z.number().int().nonnegative(),
+ }).optional(),
+}).strict()
+
+type ContextPressureState = z.infer
+
/**
* Token-meter's session projection unit.
*
@@ -104,10 +116,10 @@ interface ContextPressureState {
* that usage reports for one turn/step are adjacent: once a later step begins,
* a legal log never reports usage for an earlier step again.
*/
-export const tokenUsageProjectionDefinition:
-ProjectionDefinition<'tokenUsage', TokenUsageState> = {
+export const tokenUsageProjectionDefinition = {
key: 'tokenUsage',
- schema: projectionSchema,
+ stateVersion: 1,
+ stateSchema: tokenUsageStateSchema,
init: () => ({ totals: zeroBuckets(), last: null }),
apply: (state, event) => {
let turn: number
@@ -135,9 +147,8 @@ ProjectionDefinition<'tokenUsage', TokenUsageState> = {
last: { turn, step, buckets },
}
},
- view: state => state.totals,
- stateVersion: 1,
-}
+ wire: { viewSchema: projectionSchema, view: state => state.totals },
+} satisfies ProjectionDefinition<'tokenUsage', TokenUsageState>
/**
* Token-meter's context-occupancy projection unit.
@@ -160,10 +171,10 @@ ProjectionDefinition<'tokenUsage', TokenUsageState> = {
* BEFORE the same event joins the surface, so an `assistant/message` anchors
* against the surface its own request saw.
*/
-export const contextPressureProjectionDefinition:
-ProjectionDefinition<'contextPressure', ContextPressureState> = {
+export const contextPressureProjectionDefinition = {
key: 'contextPressure',
- schema: pressureSchema,
+ stateVersion: 4,
+ stateSchema: contextPressureStateSchema,
init: () => ({ surfaceTokens: 0 }),
apply: (state, event) => {
const fold = foldSurfaceProjection(state.claim, event)
@@ -195,12 +206,14 @@ ProjectionDefinition<'contextPressure', ContextPressureState> = {
const { claim: _expired, ...withoutClaim } = next
return fold.claim === undefined ? withoutClaim : { ...withoutClaim, claim: fold.claim }
},
- view: ({ contextWindow, pressureTokens, surfaceTokens, sampledSurfaceTokens }) => ({
- ...contextWindow === undefined ? {} : { contextWindow },
- ...pressureTokens === undefined ? {} : { pressureTokens },
- ...pressureTokens === undefined || sampledSurfaceTokens === undefined
- ? {}
- : { projectedTokens: Math.max(0, pressureTokens + surfaceTokens - sampledSurfaceTokens) },
- }),
- stateVersion: 4,
-}
+ wire: {
+ viewSchema: pressureSchema,
+ view: ({ contextWindow, pressureTokens, surfaceTokens, sampledSurfaceTokens }) => ({
+ ...contextWindow === undefined ? {} : { contextWindow },
+ ...pressureTokens === undefined ? {} : { pressureTokens },
+ ...pressureTokens === undefined || sampledSurfaceTokens === undefined
+ ? {}
+ : { projectedTokens: Math.max(0, pressureTokens + surfaceTokens - sampledSurfaceTokens) },
+ }),
+ },
+} satisfies ProjectionDefinition<'contextPressure', ContextPressureState>
diff --git a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts
index 350ed335c9..841b7d6aef 100644
--- a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts
+++ b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts
@@ -209,20 +209,20 @@ describe('contextBreakdown session projection', () => {
state = definition.apply(state, append(1))
state = definition.apply(state, append(3))
// No metering event: the replacement contributes zero instead of throwing.
- expect(definition.view(definition.apply(state, replace(1, 3))).messageTokens)
- .toBe(definition.view(state).messageTokens)
+ expect(definition.wire.view(definition.apply(state, replace(1, 3))).messageTokens)
+ .toBe(definition.wire.view(state).messageTokens)
// An adjacent claim for another range contradicts the replacement.
const mismatched = definition.apply(state, meter(1, 1, 8))
expect(() => definition.apply(mismatched, replace(1, 3))).toThrow('no adjacent shadow price')
// A claim expires after one intervening event, so replacement delta is zero.
let expired = definition.apply(state, meter(1, 3, 8))
expired = definition.apply(expired, { type: 'todo/write', seq: 9, time: 0, data: { todos: [] } } as unknown as SessionEvent)
- expect(definition.view(definition.apply(expired, replace(1, 3))).messageTokens)
- .toBe(definition.view(state).messageTokens)
+ expect(definition.wire.view(definition.apply(expired, replace(1, 3))).messageTokens)
+ .toBe(definition.wire.view(state).messageTokens)
// The armed claim prices exactly the next event's matching replacement.
const armed = definition.apply(state, meter(1, 3, 8))
- expect(definition.view(definition.apply(armed, replace(1, 3))).messageTokens)
- .toBe(definition.view(state).messageTokens - 5 + estimateMessage(
+ expect(definition.wire.view(definition.apply(armed, replace(1, 3))).messageTokens)
+ .toBe(definition.wire.view(state).messageTokens - 5 + estimateMessage(
createUserMessage({ content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }),
))
})
diff --git a/packages/llm/token-meter/tests/token-meter.spec.ts b/packages/llm/token-meter/tests/token-meter.spec.ts
index 7805d39278..074f18fb76 100644
--- a/packages/llm/token-meter/tests/token-meter.spec.ts
+++ b/packages/llm/token-meter/tests/token-meter.spec.ts
@@ -4,6 +4,7 @@ import { createUserMessage, CallId, createMessage } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, Message, TokenUsage } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId, canonicalHeader } from '@deepseek-ai/dsh-session'
import type { EpochHeader, SessionEvent } from '@deepseek-ai/dsh-session'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import TokenMeter from '@deepseek-ai/dsh-token-meter'
import type { TokenMeasurement, TokenMeterConfig } from '@deepseek-ai/dsh-token-meter'
@@ -90,7 +91,11 @@ function appendSuccessfulCall(
}
function meter(config: TokenMeterConfig = {}): TokenMeter {
- return new TokenMeter(new Context(), config)
+ const ctx = new Context()
+ // The registry is a required injection of the service (its three projection
+ // units register in the constructor); mount it synchronously.
+ new SessionProjectionRegistry(ctx)
+ return new TokenMeter(ctx, config)
}
function expectSurfaceTotal(measurement: TokenMeasurement): void {
@@ -115,6 +120,7 @@ describe('TokenMeter configuration and registration', () => {
it('registers and unregisters ctx.tokenMeter with its plugin fiber', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
const fiber = await ctx.plugin(TokenMeter)
expect(ctx.get('tokenMeter')).toBeInstanceOf(TokenMeter)
await fiber.dispose()
@@ -660,6 +666,7 @@ describe('malformed replay and listener lifecycle', () => {
it('handles earlier-reader catch-up, eager observation, and service reload', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
let activeMeter: TokenMeter | undefined
const revisions: number[] = []
ctx.on('session/event', (session) => {
diff --git a/packages/llm/token-meter/tests/token-usage-projection.spec.ts b/packages/llm/token-meter/tests/token-usage-projection.spec.ts
index d72ba7f1f6..56ba67ee80 100644
--- a/packages/llm/token-meter/tests/token-usage-projection.spec.ts
+++ b/packages/llm/token-meter/tests/token-usage-projection.spec.ts
@@ -8,6 +8,7 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import TokenMeter from '@deepseek-ai/dsh-token-meter'
import type { ContextPressureProjection, TokenUsageProjection } from '@deepseek-ai/dsh-token-meter/client'
import { CompactionId } from '@deepseek-ai/dsh-compaction'
+import type {} from '../src/usage-projection.ts'
const ZERO: TokenUsageProjection = {
uncachedInputTokens: 0,
diff --git a/packages/lsp/README.i18n.yaml b/packages/lsp/README.i18n.yaml
index 195967e3a2..2670636cb5 100644
--- a/packages/lsp/README.i18n.yaml
+++ b/packages/lsp/README.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/lsp/README.md
README.md: 414311fd3f537634651daf6f313baf33daede26b
-README.zh.md: 8791b0ea9c01ed637586cee5c131690a73263a6a
+README.zh.md: 8128bd410daeaa12186191a2dccddddc2a0e5b37
diff --git a/packages/lsp/README.zh.md b/packages/lsp/README.zh.md
index 8791b0ea9c..8128bd410d 100644
--- a/packages/lsp/README.zh.md
+++ b/packages/lsp/README.zh.md
@@ -12,6 +12,6 @@
Service Definition 位于 `lsp/lsp/`。该 seam 恰好公开四种语义操作:`goToDefinition`、`findReferences`、`goToImplementation`、`hover`,且不提供通用 JSON-RPC 逃生口;因此,替换提供方不会改变模型请求导航的方式,也不会让协议载荷或未经评审的修改进入模型约定。提供方注册的是**能力**而非工具;`tool-lsp` 是面向模型的名称、schema、提示词指引和呈现的唯一 owner。
-设计原理见 [LSP 能力 seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md),其中也解释了文档为何在每次查询时临时打开、stdio 主机为何使用共享的文件系统/子进程执行环境,以及扩展名归属为何在同一运行时内互斥。
+设计原理见 [LSP 能力 seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md),其中也解释了文档为何在每次查询时临时打开、stdio 主机为何使用共享的文件系统/子进程执行环境,以及扩展名归属为何在同一运行时内互斥。
-子系统参考——操作、坐标、请求/结果、`LspError`——见 [docs/subsystems/lsp.md](../../docs/subsystems/lsp.md);设计依据见 [LSP 能力 seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md)。
+子系统参考——操作、坐标、请求/结果、`LspError`——见 [docs/subsystems/lsp.md](../../docs/subsystems/lsp.zh.md);设计依据见 [LSP 能力 seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md)。
diff --git a/packages/lsp/lsp-stdio/package.json b/packages/lsp/lsp-stdio/package.json
index 23d6113ea2..75fbd7afcd 100644
--- a/packages/lsp/lsp-stdio/package.json
+++ b/packages/lsp/lsp-stdio/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-lsp-stdio",
"description": "Generic stdio language-server provider for the DeepSeek Harness LSP capability seam (ctx.lsp) — spawns configured servers, translates JSON-RPC, and serves transient-open goToDefinition/findReferences/goToImplementation/hover queries in the host filesystem namespace",
- "version": "0.1.0-rc.7",
+ "version": "0.1.0-rc.8",
"publishConfig": {
"access": "public"
},
diff --git a/packages/lsp/lsp/README.i18n.yaml b/packages/lsp/lsp/README.i18n.yaml
index 1beb022f57..0fb8bcab4e 100644
--- a/packages/lsp/lsp/README.i18n.yaml
+++ b/packages/lsp/lsp/README.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/lsp/lsp/README.md
README.md: cf32f9cc60a346def48d9720bf0695db36553b0e
-README.zh.md: 6f19d1f649eb1483782b6ebb4c82f05da7a42a80
+README.zh.md: eba25a7494f8c624b0e9d0aa5b5c7dd484d1f0f9
diff --git a/packages/lsp/lsp/README.zh.md b/packages/lsp/lsp/README.zh.md
index 6f19d1f649..eba25a7494 100644
--- a/packages/lsp/lsp/README.zh.md
+++ b/packages/lsp/lsp/README.zh.md
@@ -39,6 +39,6 @@
## 已知限制与暂缓事项
-- **同一运行时内扩展名归属互斥**:两个提供方不能同时声明 `.ts`,即使 language id 不同;重叠会使注册失败。预期扩展是在注册之上增加部署配置的 selector;它可以放宽互斥保留,而无需把提供方选择加入模型输入(见 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md))。
+- **同一运行时内扩展名归属互斥**:两个提供方不能同时声明 `.ts`,即使 language id 不同;重叠会使注册失败。预期扩展是在注册之上增加部署配置的 selector;它可以放宽互斥保留,而无需把提供方选择加入模型输入(见 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md))。
- **仅四种操作**:symbol 与 call hierarchy 暂缓(它们需要不同 schema);diagnostics 需要独立的新鲜度/累积规则;修改操作(rename、code action、formatting)需要独立工具,并集成预览、权限和写入策略。
- **没有观测表层**:可用性只能通过运行 `query()` 并按抛出的 `LspError` code 路由来观测;没有提供方变更事件或能力状态查询。
diff --git a/packages/lsp/lsp/package.json b/packages/lsp/lsp/package.json
index 04d107cedb..a4a935bd1b 100644
--- a/packages/lsp/lsp/package.json
+++ b/packages/lsp/lsp/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-lsp",
"description": "Abstract LSP capability seam (ctx.lsp) for the DeepSeek Harness — language-server provider registry keyed by branded id and extension mapping, order-independent per-query selection, normalized definition/references/implementation/hover requests and results, and the LspError taxonomy",
- "version": "0.1.0-rc.7",
+ "version": "0.1.0-rc.8",
"publishConfig": {
"access": "public"
},
diff --git a/packages/lsp/tool-lsp/README.i18n.yaml b/packages/lsp/tool-lsp/README.i18n.yaml
index 2eb5cb2f17..8287d4b40b 100644
--- a/packages/lsp/tool-lsp/README.i18n.yaml
+++ b/packages/lsp/tool-lsp/README.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/lsp/tool-lsp/README.md
README.md: e0fd5738cf114314a8483125d20142d5f1ffac40
-README.zh.md: 8c908c1e8766ab47e9cefbc9d1f72f05e8c2709f
+README.zh.md: 71d6a4b5479b2da187b78a388d617afe7214e78c
diff --git a/packages/lsp/tool-lsp/README.zh.md b/packages/lsp/tool-lsp/README.zh.md
index 8c908c1e87..71d6a4b547 100644
--- a/packages/lsp/tool-lsp/README.zh.md
+++ b/packages/lsp/tool-lsp/README.zh.md
@@ -46,7 +46,7 @@ Use search/read for ordinary navigation. Use lsp when textual matches are ambigu
#### 模型看到的内容
-模型会看到生成的 [`lsp` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-lsp)。
+模型会看到生成的 [`lsp` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-lsp)。
#### Token 影响
@@ -86,5 +86,5 @@ Use search/read for ordinary navigation. Use lsp when textual matches are ambigu
## 已知限制与暂缓事项
-- **UTF-16 光标坐标**:列坐标与协议精确一致,但模型难以在非 BMP 字符周围计数;未落在符号上的位置可能返回空结果,因此提示词解释了该约定,但不鼓励广泛使用 LSP(见 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md))。
+- **UTF-16 光标坐标**:列坐标与协议精确一致,但模型难以在非 BMP 字符周围计数;未落在符号上的位置可能返回空结果,因此提示词解释了该约定,但不鼓励广泛使用 LSP(见 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md))。
- **不承诺跨服务器完整性**:受支持的服务器仍可能根据索引就绪情况返回空或部分结果;该工具不承诺跨语言或服务器的完整性。
diff --git a/packages/lsp/tool-lsp/package.json b/packages/lsp/tool-lsp/package.json
index d6efa136a8..d0bbf867fa 100644
--- a/packages/lsp/tool-lsp/package.json
+++ b/packages/lsp/tool-lsp/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-tool-lsp",
"description": "Model-facing lsp tool over the DeepSeek Harness LSP capability seam (ctx.lsp) — one read-only tool with goToDefinition/findReferences/goToImplementation/hover operations, one-based UTF-16 cursor coordinates, bounded location rendering, and hover normalization",
- "version": "0.1.0-rc.7",
+ "version": "0.1.0-rc.8",
"publishConfig": {
"access": "public"
},
diff --git a/packages/mcp/README.i18n.yaml b/packages/mcp/README.i18n.yaml
index 3ce6f13ac3..93fa4aa4aa 100644
--- a/packages/mcp/README.i18n.yaml
+++ b/packages/mcp/README.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/mcp/README.md
README.md: cc3440f6cb2e507277e6141393ccf39fc7022d7d
-README.zh.md: 0a74f3e9016be95f76104303396dc690888d645d
+README.zh.md: 9cdc01b80dce63e06bb36fe92cfaf62ed5adce51
diff --git a/packages/mcp/README.zh.md b/packages/mcp/README.zh.md
index 0a74f3e901..9cdc01b80d 100644
--- a/packages/mcp/README.zh.md
+++ b/packages/mcp/README.zh.md
@@ -6,4 +6,4 @@
| 包 | 职责 |
|---|---|
-| [`mcp-client/`](mcp-client/README.md) | MCP 客户端桥接,将外部服务器工具注册到 `ctx.tools` |
+| [`mcp-client/`](mcp-client/README.zh.md) | MCP 客户端桥接,将外部服务器工具注册到 `ctx.tools` |
diff --git a/packages/mcp/mcp-client/package.json b/packages/mcp/mcp-client/package.json
index e3cc3e04c8..4fe83ee574 100644
--- a/packages/mcp/mcp-client/package.json
+++ b/packages/mcp/mcp-client/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-mcp-client",
"description": "MCP client bridge: connects to MCP servers and registers their tools on ctx.tools",
- "version": "0.1.0-rc.7",
+ "version": "0.1.0-rc.8",
"publishConfig": {
"access": "public"
},
diff --git a/packages/plan/README.i18n.yaml b/packages/plan/README.i18n.yaml
index c2a60d7d2d..51a2fb09eb 100644
--- a/packages/plan/README.i18n.yaml
+++ b/packages/plan/README.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/plan/README.md
README.md: 535a2b02e297d7c3bc68538fa1d03226089372e0
-README.zh.md: 0ee1c658ee0a7863eb16b99d956e882f320a3717
+README.zh.md: 288daf7a7b19a14372ab9396363bf558f1bcc240
diff --git a/packages/plan/README.zh.md b/packages/plan/README.zh.md
index 0ee1c658ee..288daf7a7b 100644
--- a/packages/plan/README.zh.md
+++ b/packages/plan/README.zh.md
@@ -6,8 +6,8 @@ Plan mode 是按 agent(智能体)记录的协作状态,而不是通用模
| 包 | 职责 | ctx 键 |
|---|---|---|
-| [`plan-mode/`](plan-mode/README.md) | 负责 plan mode 状态、指引、命令和评审流程 | `ctx.planMode` |
+| [`plan-mode/`](plan-mode/README.zh.md) | 负责 plan mode 状态、指引、命令和评审流程 | `ctx.planMode` |
-[plan 专用协作状态](../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md)决策记录了该家族的设计。
+[plan 专用协作状态](../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.zh.md)决策记录了该家族的设计。
-子系统参考——`plan/mode` 折叠、步骤边界刷写、配置、退出工具——见 [docs/subsystems/plan.md](../../docs/subsystems/plan.md);设计见[计划专属协作状态](../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md)。
+子系统参考——`plan/mode` 折叠、步骤边界刷写、配置、退出工具——见 [docs/subsystems/plan.md](../../docs/subsystems/plan.zh.md);设计见[计划专属协作状态](../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.zh.md)。
diff --git a/packages/plan/plan-mode/README.i18n.yaml b/packages/plan/plan-mode/README.i18n.yaml
index c12c5c248f..b1c7671e8b 100644
--- a/packages/plan/plan-mode/README.i18n.yaml
+++ b/packages/plan/plan-mode/README.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/plan/plan-mode/README.md
README.md: 3eabe2cb3f04b434b7f908f7beca869f1022a59e
-README.zh.md: f7d6a1f8e9f5ba95f8aad9457f3dde5fc415fdcf
+README.zh.md: b3548da880af5d0cacfebba4486294d9d5085626
diff --git a/packages/plan/plan-mode/README.zh.md b/packages/plan/plan-mode/README.zh.md
index f7d6a1f8e9..b3548da880 100644
--- a/packages/plan/plan-mode/README.zh.md
+++ b/packages/plan/plan-mode/README.zh.md
@@ -10,6 +10,8 @@
`ctx.planMode.set(agent, active)` 会在 agent 空闲时立即追加独立的 `plan/mode` 事件,因为下一个提示词之前不会运行轮内 pre-step。agent 运行时,该方法会保留待生效选择,直到下一个被接受的轮内 pre-step。返回值区分 `committed`、`queued`、表示反转的 `cancelled` 和 `noop`。`get(agent)` 返回 `{ active, pending? }`,将用于组装当前步骤的日志状态与用户的轮中选择分开。初始与续步 pre-step 都会应用待生效选择;同一步骤的请求恢复重试会复用已冻结的 assembly,并将该选择保留到下一个被接受的轮内 pre-step。当最后记录的请求头描述了另一状态时,用户选择的变更会贡献一条插件来源的 `user/message` 通知(两条提交路径皆然)。
+
+
## 模型与人类交互
激活时,`plan:policy` 会渲染已配置的 `section`。插件始终注册 `exit_plan_mode`,使工具 schema 在转换期间保持稳定;其 execute 路径只接受已激活的 plan mode,且只有通过 `ctx.userQuestions` 获得用户明确批准后才退出。
@@ -22,7 +24,7 @@ Web 客户端使用该插件提供的 `/plan` 命令;其他入口可以直接
## 会话投影
-当组合挂载 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md))时,本包会在一个注入的子插件中注册 `plan` 投影单元。名为 `plan` 且携带已记录 `args` 的 `command/run` 记录会开始一个候选目标(`off` → 未激活,其余 → 激活);与它配对的 `command/done` 保留成功选择并丢弃错误选择;`plan/mode` 提交已记录状态并清除已保留的选择。其他任何事件都返回同一个状态引用。`view` 推导 `{ active, pending }`,其中 `pending` 仅在未结算或已成功的选择与已记录状态不同时为 true。该值仍完全由日志回放得出,因此 host 重启、其他标签页和冷读都能仅凭日志恢复它,被拒绝的带图 `/plan off` 也不会留下待退出状态。key 由 `src/types.ts` 通过声明合并加入 `SessionProjectionMap`:host 消费方经 `./types` 获取,client 聚合经 `./client` 获取。框架负责驱动该单元,载体通过历史尾页和 `session/projection` 推送帧提供其值。未挂载注册表的组合不受影响。
+当组合挂载 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.zh.md))时,本包会在一个注入的子插件中注册 `plan` 投影单元。名为 `plan` 且携带已记录 `args` 的 `command/run` 记录会开始一个候选目标(`off` → 未激活,其余 → 激活);与它配对的 `command/done` 保留成功选择并丢弃错误选择;`plan/mode` 提交已记录状态并清除已保留的选择。其他任何事件都返回同一个状态引用。`view` 推导 `{ active, pending }`,其中 `pending` 仅在未结算或已成功的选择与已记录状态不同时为 true。该值仍完全由日志回放得出,因此 host 重启、其他标签页和冷读都能仅凭日志恢复它,被拒绝的带图 `/plan off` 也不会留下待退出状态。key 由 `src/types.ts` 通过声明合并加入 `SessionProjectionMap`:host 消费方经 `./types` 获取,client 聚合经 `./client` 获取。框架负责驱动该单元,载体通过历史尾页和 `session/projection` 推送帧提供其值。未挂载注册表的组合不受影响。
## 配置
@@ -37,7 +39,7 @@ Web 客户端使用该插件提供的 `/plan` 命令;其他入口可以直接
`section` 必填且非空。出现未知键时,插件会加载失败。该包不接受任意命名的 mode、工具过滤器、沙箱设置或批准策略。
-设计:[plan 专用协作状态](../../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md)。
+设计:[plan 专用协作状态](../../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.zh.md)。
## 模型体验
@@ -79,7 +81,7 @@ You are in plan mode. Explore and design before presenting the complete plan thr
#### 模型所见内容
-[`exit_plan_mode` schema](../../../docs/tool-catalog.md#deepseek-aidsh-plan-mode) 在两种状态下均可用;在 plan mode 外执行会失败,而 plan mode 内经批准的评审会返回规范的 `{ approved: true }` 值,并渲染既有的确认文本。拒绝仍是携带评审反馈的失败调用,放弃审阅则是一次指明用户接手的失败调用。
+[`exit_plan_mode` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-plan-mode) 在两种状态下均可用;在 plan mode 外执行会失败,而 plan mode 内经批准的评审会返回规范的 `{ approved: true }` 值,并渲染既有的确认文本。拒绝仍是携带评审反馈的失败调用,放弃审阅则是一次指明用户接手的失败调用。
#### Token 影响
@@ -89,6 +91,8 @@ You are in plan mode. Explore and design before presenting the complete plan thr
mode 转换不改变工具目录;plan 参数与评审结果按常规方式扩展对话。
+
+
## 已知限制与暂缓事项
- Plan mode 只进行引导,而不强制执行;需要强制限制的部署必须分别配置沙箱与批准控制。
diff --git a/packages/plan/plan-mode/package.json b/packages/plan/plan-mode/package.json
index 72295f7328..91319304d1 100644
--- a/packages/plan/plan-mode/package.json
+++ b/packages/plan/plan-mode/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-plan-mode",
"description": "Logged per-agent plan mode with deployment guidance, a direct slash command, and a user-reviewed exit",
- "version": "0.1.0-rc.7",
+ "version": "0.1.0-rc.8",
"publishConfig": {
"access": "public"
},
diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts
index b3d0933256..d81865932c 100644
--- a/packages/plan/plan-mode/src/index.ts
+++ b/packages/plan/plan-mode/src/index.ts
@@ -33,8 +33,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
import type {} from '@deepseek-ai/dsh-system-prompt'
import { UserQuestionError } from '@deepseek-ai/dsh-user-questions'
// Type-only edge: resolves `ctx.commands` for the optional command child.
-import type {} from '@deepseek-ai/dsh-commands'
-import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
+import type { CommandId } from '@deepseek-ai/dsh-commands'
// Type-only: resolves ctx.sessionProjections for the optional unit child.
import type {} from '@deepseek-ai/dsh-session-projection'
import type { PlanProjection } from './types.ts'
@@ -152,6 +151,21 @@ interface PlanUnitState {
running: { commandId: CommandId; wanted: boolean } | null
}
+declare module '@deepseek-ai/dsh-session-projection/types' {
+ interface SessionProjectionStateMap {
+ plan: PlanUnitState
+ }
+}
+
+const planUnitStateSchema: ZodType = zod.object({
+ active: zod.boolean(),
+ wanted: zod.boolean().nullable(),
+ running: zod.object({
+ commandId: zod.string() as unknown as ZodType,
+ wanted: zod.boolean(),
+ }).strict().nullable(),
+}).strict()
+
/** Wire payload schema of the `plan` projection. */
const planProjectionSchema: ZodType = zod.object({
active: zod.boolean(),
@@ -247,7 +261,7 @@ export class PlanModeController extends Service {
ctx.inject(['sessionProjections'], (projectionCtx) => {
projectionCtx.sessionProjections.register<'plan', PlanUnitState>({
key: 'plan',
- schema: planProjectionSchema,
+ stateSchema: planUnitStateSchema,
init: () => ({ active: false, wanted: null, running: null }),
apply: (state, event) => {
if (event.type === 'command/run' && event.data.name === 'plan') {
@@ -266,9 +280,12 @@ export class PlanModeController extends Service {
}
return state
},
- view: (state) => {
- const wanted = state.running?.wanted ?? state.wanted
- return { active: state.active, pending: wanted !== null && wanted !== state.active }
+ wire: {
+ viewSchema: planProjectionSchema,
+ view: (state) => {
+ const wanted = state.running?.wanted ?? state.wanted
+ return { active: state.active, pending: wanted !== null && wanted !== state.active }
+ },
},
stateVersion: 2,
})
diff --git a/packages/preset/README.i18n.yaml b/packages/preset/README.i18n.yaml
index b8d5dd62d9..bf3583068a 100644
--- a/packages/preset/README.i18n.yaml
+++ b/packages/preset/README.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/preset/README.md
README.md: 5e7805eaa303b78c4d8a1a8972e1d031ee304fae
-README.zh.md: 9afb0a4f585fc445977f81839695f15cf8b21327
+README.zh.md: 217d807f40abc0908fb0c67fa0c94dc4d0806592
diff --git a/packages/preset/README.zh.md b/packages/preset/README.zh.md
index 9afb0a4f58..217d807f40 100644
--- a/packages/preset/README.zh.md
+++ b/packages/preset/README.zh.md
@@ -13,4 +13,4 @@
本组假定的组装划分是:注册表与跨会话设施是进程单例,留在宿主组装中;preset 只承载单个 agent 对它们的贡献。若 preset 中某一行发布了进程级全局服务,挂载时即被拒绝,而不是留到与下一个会话相撞。
-设计详见 [按会话组装 agent preset 的 Agent Note](../../.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md)。
+设计详见 [按会话组装 agent preset 的 Agent Note](../../.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md)。
diff --git a/packages/preset/agent-presets/README.i18n.yaml b/packages/preset/agent-presets/README.i18n.yaml
index f3fef85bbd..25ebbe1b15 100644
--- a/packages/preset/agent-presets/README.i18n.yaml
+++ b/packages/preset/agent-presets/README.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/preset/agent-presets/README.md
README.md: 2438d751f000297cc4ede7b2a34736a62cc39dda
-README.zh.md: c8ab8e167d624f639f6657d4c0ade7205b23821b
+README.zh.md: a786afbc3790595edbd64ec16686717ef3d8140f
diff --git a/packages/preset/agent-presets/README.zh.md b/packages/preset/agent-presets/README.zh.md
index c8ab8e167d..a786afbc37 100644
--- a/packages/preset/agent-presets/README.zh.md
+++ b/packages/preset/agent-presets/README.zh.md
@@ -4,7 +4,7 @@
按 preset 组装 agent(智能体)。**preset** 是一个目录,其中放置一份 `agent.cordis.yml`;roster 在整个进程内只把它挂载一次(常驻 scope),命名它的每个会话通过把自己 agent 的 scope key 认父到该挂载(`dsh-scope` 的父链)来加入。挂载的工具、提示词段落与投影单元只存在一份,覆盖所有已加入的 agent——其插件本就按 Session/Agent 分键存状态,会话在共享实例内互不串扰——而完全没有 agent 的宿主读取方(冷读记录)也能按 preset id 解析到同一份常驻注册。
-其机制是两条 seam。entry 上下文沿原型链连到子树被挂载时所在的上下文,而 [`dsh-tools`](../../core/tools/README.md) 与 [`dsh-system-prompt`](../../core/system-prompt/README.md) 本就按调用方上下文的 scope 分层归档注册——因此常驻挂载的贡献落在 **preset 的分层**里。把它们送达每个会话的是 `dsh-scope` 的父链:agent 的视图按 `agent → preset → global` 解析(近者遮蔽远者),挂载的监听器对认父到它的每个 agent 放行,而兄弟 preset 的监听器保持失聪。
+其机制是两条 seam。entry 上下文沿原型链连到子树被挂载时所在的上下文,而 [`dsh-tools`](../../core/tools/README.zh.md) 与 [`dsh-system-prompt`](../../core/system-prompt/README.zh.md) 本就按调用方上下文的 scope 分层归档注册——因此常驻挂载的贡献落在 **preset 的分层**里。把它们送达每个会话的是 `dsh-scope` 的父链:agent 的视图按 `agent → preset → global` 解析(近者遮蔽远者),挂载的监听器对认父到它的每个 agent 放行,而兄弟 preset 的监听器保持失聪。
## 服务:`AgentPresets`(ctx 键:`agentPresets`)
@@ -36,7 +36,7 @@ subagent 的子 agent 通过 `composeFrom()` 加入其父方的常驻组装,
按 id 重新挂载父方的 preset 与认父有两处差别,且两处都要紧。父方启动后被编辑过的组装文件会把与父方历史所产出时**不同**的一个代际交给子 agent;而此后被删除的 preset 会让子 agent 直接失败,尽管其父方仍在正常运行。认父还是同步的,这正是进程内 subagent 驱动能够使用它的前提——它们在同步的创建窗口里组装子 agent。
-子 agent 会把所加入的 id 记在自己的持久化 header 上(见 [`dsh-subagent`](../../subagent/subagent/README.md)),因此冷读子 agent 的历史时重建的是它实际运行过的组装,而不是部署默认值。
+子 agent 会把所加入的 id 记在自己的持久化 header 上(见 [`dsh-subagent`](../../subagent/subagent/README.zh.md)),因此冷读子 agent 的历史时重建的是它实际运行过的组装,而不是部署默认值。
### 会话实际运行的是哪个 preset
@@ -48,7 +48,7 @@ subagent 的子 agent 通过 `composeFrom()` 加入其父方的常驻组装,
`recompose()` 先卸载已装入的子树、再装入新的,因为两份组装无法共存——它们会把相同的工具名注册进同一个层。挂载失败会恢复先前的组装,而不是让 agent 一无所有;未知 id 则在任何东西被拆除之前就被拒绝。
-"仅限尚未产出任何内容的 agent"是一条产品规则而非机制约束:在对话进行中调换工具,会留下新组装无法执行的、已被记录的工具调用。该规则由网关在传输层执行([`dsh-apiproxy`](../../host/apiproxy/README.md) 返回 `agent-preset-locked`),因为会话历史在那里才拿得到。
+"仅限尚未产出任何内容的 agent"是一条产品规则而非机制约束:在对话进行中调换工具,会留下新组装无法执行的、已被记录的工具调用。该规则由网关在传输层执行([`dsh-apiproxy`](../../host/apiproxy/README.zh.md) 返回 `agent-preset-locked`),因为会话历史在那里才拿得到。
## 创作
@@ -93,7 +93,7 @@ description: 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agen
### 可写根目录属于本包,随附根目录属于 app
-`/.agent-presets` 是个人自有 preset 的所在,正如 `/skills` 是其自有 skill 的所在([`dsh-skill-filesystem`](../../skill/skill-filesystem/README.md)),因此 roster 自行推导它,而不等某个部署记得配置——一个什么都没配的启动器同样能发现并创作 preset。它追加在全部已配置根目录**之后**,从而保持靠前的根目录赢得重复 id:随附的 `standard` 仍然遮蔽一个占用该名字的家目录目录,而 `copy()` 会拒绝该 id,不会落下一个无人解析得到的 preset。
+`/.agent-presets` 是个人自有 preset 的所在,正如 `/skills` 是其自有 skill 的所在([`dsh-skill-filesystem`](../../skill/skill-filesystem/README.zh.md)),因此 roster 自行推导它,而不等某个部署记得配置——一个什么都没配的启动器同样能发现并创作 preset。它追加在全部已配置根目录**之后**,从而保持靠前的根目录赢得重复 id:随附的 `standard` 仍然遮蔽一个占用该名字的家目录目录,而 `copy()` 会拒绝该 id,不会落下一个无人解析得到的 preset。
根目录在服务构造时解析一次。若根目录集合在一次 `list()` 与依据其答案执行的 `copy()` 之间发生变化,写入的将是调用方从未见过的目录。
diff --git a/packages/preset/agent-presets/package.json b/packages/preset/agent-presets/package.json
index a8f374d765..08c035c953 100644
--- a/packages/preset/agent-presets/package.json
+++ b/packages/preset/agent-presets/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-agent-presets",
"description": "Per-session agent composition from preset cordis.yml files for the DeepSeek Harness",
- "version": "0.1.0-rc.7",
+ "version": "0.1.0-rc.8",
"publishConfig": {
"access": "public"
},
diff --git a/packages/preset/persona/README.i18n.yaml b/packages/preset/persona/README.i18n.yaml
index 0323741aed..560a90256a 100644
--- a/packages/preset/persona/README.i18n.yaml
+++ b/packages/preset/persona/README.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/preset/persona/README.md
README.md: 978d01ddbd52095bc2486d27d08fde2d4a8fa79b
-README.zh.md: d36af6c028f1258d044608535c6da4eaf2fb92e8
+README.zh.md: a6e682711c0d6bba742cd1bb192fb9f38bf32cd0
diff --git a/packages/preset/persona/README.zh.md b/packages/preset/persona/README.zh.md
index d36af6c028..a6e682711c 100644
--- a/packages/preset/persona/README.zh.md
+++ b/packages/preset/persona/README.zh.md
@@ -4,7 +4,7 @@
把 agent(智能体)人设做成一个可组装的行:它既可以遮蔽部署级人设,也可以拥有完整系统提示词。
-[`dsh-system-prompt`](../../core/system-prompt/README.md) 以自身配置持有部署级人设,并且无条件注册该段落,因此一个进程只有一份。[agent preset](../agent-presets/README.md) 无法自行挂载提示词注册表——若没有属于自己的行,preset 能改变 agent 的工具,却永远改不了它的身份。本包就是那一行。
+[`dsh-system-prompt`](../../core/system-prompt/README.zh.md) 以自身配置持有部署级人设,并且无条件注册该段落,因此一个进程只有一份。[agent preset](../agent-presets/README.zh.md) 无法自行挂载提示词注册表——若没有属于自己的行,preset 能改变 agent 的工具,却永远改不了它的身份。本包就是那一行。
## 仅限 scope 内使用
diff --git a/packages/preset/persona/package.json b/packages/preset/persona/package.json
index 0453ca0897..24f6569815 100644
--- a/packages/preset/persona/package.json
+++ b/packages/preset/persona/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-persona",
"description": "Composition-authored deployment persona section for the DeepSeek Harness",
- "version": "0.1.0-rc.7",
+ "version": "0.1.0-rc.8",
"publishConfig": {
"access": "public"
},
diff --git a/packages/runtime-diagnostics/invariants/package.json b/packages/runtime-diagnostics/invariants/package.json
index 537381af9c..59e6983bb0 100644
--- a/packages/runtime-diagnostics/invariants/package.json
+++ b/packages/runtime-diagnostics/invariants/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-invariants",
"description": "Registry service for package-owned DeepSeek Harness runtime invariants",
- "version": "0.1.0-rc.7",
+ "version": "0.1.0-rc.8",
"publishConfig": {
"access": "public"
},
diff --git a/packages/sandbox/README.i18n.yaml b/packages/sandbox/README.i18n.yaml
index ab79d37170..cc93658129 100644
--- a/packages/sandbox/README.i18n.yaml
+++ b/packages/sandbox/README.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/sandbox/README.md
README.md: 61127118c895cce728ab0b4eed387f51054a83f1
-README.zh.md: 984a677aad6ab97579abf861ea8b99f149c07d08
+README.zh.md: c5b8f12c544e2b6b2c7ce1ec3fc63d274df18609
diff --git a/packages/sandbox/README.zh.md b/packages/sandbox/README.zh.md
index 984a677aad..c5b8f12c54 100644
--- a/packages/sandbox/README.zh.md
+++ b/packages/sandbox/README.zh.md
@@ -6,10 +6,10 @@
| 包 | 职责 | ctx key |
|---|---|---|
-| [`sandbox/`](sandbox/README.md) | 定义进程沙箱服务和共享升权词汇 | `ctx.sandbox` |
-| [`sandbox-local/`](sandbox-local/README.md) | 提供本地平台限制后端 | 注册到 `ctx.sandbox` |
-| [`sandbox-policy/`](sandbox-policy/README.md) | 解析持久的逐会话沙箱策略 | `ctx.sandboxPolicy` |
+| [`sandbox/`](sandbox/README.zh.md) | 定义进程沙箱服务和共享升权词汇 | `ctx.sandbox` |
+| [`sandbox-local/`](sandbox-local/README.zh.md) | 提供本地平台限制后端 | 注册到 `ctx.sandbox` |
+| [`sandbox-policy/`](sandbox-policy/README.zh.md) | 解析持久的逐会话沙箱策略 | `ctx.sandboxPolicy` |
-[沙箱决策](../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)记录了能力边界,[文件系统集成决策](../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md)记录了跨家族策略的使用方式。
+[沙箱决策](../../.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md)记录了能力边界,[文件系统集成决策](../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.zh.md)记录了跨家族策略的使用方式。
-子系统参考——模式与强制执行、按调用策略、包装 argv 方言、故障关闭错误——见 [docs/subsystems/sandbox.md](../../docs/subsystems/sandbox.md);边界与跨家族阶段见[沙箱](../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)与[跨家族 fs 沙箱](../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md) Agent Note。
+子系统参考——模式与强制执行、按调用策略、包装 argv 方言、故障关闭错误——见 [docs/subsystems/sandbox.md](../../docs/subsystems/sandbox.zh.md);边界与跨家族阶段见[沙箱](../../.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md)与[跨家族 fs 沙箱](../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.zh.md) Agent Note。
diff --git a/packages/sandbox/sandbox-local/README.i18n.yaml b/packages/sandbox/sandbox-local/README.i18n.yaml
index d8155add50..8c63ddc717 100644
--- a/packages/sandbox/sandbox-local/README.i18n.yaml
+++ b/packages/sandbox/sandbox-local/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/sandbox/sandbox-local/README.md
-README.md: e1b59385a1380e0918e69c06dd86834c00cf2395
-README.zh.md: f4e31e220f3f69612243066a3183ebf79ed6f73a
+README.md: 7f96ebd725280cc5b767c40dcf52e9353aaafe79
+README.zh.md: 8cb14128ae4b97c3089d72bb6126bc4fd835ac31
diff --git a/packages/sandbox/sandbox-local/README.md b/packages/sandbox/sandbox-local/README.md
index e1b59385a1..7f96ebd725 100644
--- a/packages/sandbox/sandbox-local/README.md
+++ b/packages/sandbox/sandbox-local/README.md
@@ -6,10 +6,12 @@ Local implementation of the [`dsh-sandbox`](../sandbox/) seam. It selects and ca
The package root exports the default and named `LocalSandboxProvider` plugin and `Config`; platform profile builders stay internal.
-Unsupported platforms and unusable runners fail closed with `SANDBOX_UNAVAILABLE`; execution never silently falls through unconfined. Each wrap carries structured runner-failure rules so consumers can distinguish a broken sandbox from a command failure. The [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) owns selection rationale and profile differences.
+Unsupported platforms and unusable runners fail closed with `SANDBOX_UNAVAILABLE`; execution never silently falls through unconfined. Each wrap carries structured runner-failure rules so consumers can distinguish a broken sandbox from a command failure.
Policy is per call; the provider stores only the mechanism and cached runner verdict. Each wrap reports enforcement completeness plus backend-specific denial signatures and runner-failure rules. Landlock requires exit 125 and a `landlock-run:` fatal line after excluding only the exact partial-enforcement notice; a notice with child exit 1, 2, or 125 remains a child outcome. Bubblewrap and Seatbelt remain signature-only because neither public contract reserves a launcher-failure status. Consumers spawn the returned argv directly, so a missing or unexecutable runner is an out-of-band spawn failure while a successfully launched child exit 126 or 127 remains ordinary. `runnerCommand` skips probes and requires one or more non-empty, single-line, case-insensitive `runnerFailureSignatures` entries for the custom runner's own fatal dialect. Because its mechanism is unknown, it carries both Linux denial dialects. `probeTimeoutMs` bounds functional probes. The [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) owns selection and failure semantics.
+The bwrap profile combines a read-only host root, fresh `/dev`, and private-PID `/proc`. Commands manage descendants but cannot see host processes; hiding host `/proc/` entries prevents magic links such as `root` and `fd` from bypassing its mounts. `workspace-write` adds ephemeral `/tmp` and a writable workspace bind. The [private-PID Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-06-bwrap-private-pid-namespace.md) records the boundary.
+
The Seatbelt profile is allow-default with `(deny file-write*)` plus write allow-lists, so exactly the mode's promised file effects are governed: `read-only` grants the `/dev/null` literal alone; `workspace-write` adds the workspace root, `/tmp`, and the per-user darwin temp dir (`os.tmpdir()` — the platform's real temp area for mkstemp-family tools), every root canonicalized because Seatbelt matches resolved paths (`/tmp` IS `/private/tmp`). Apple marks the `sandbox-exec` CLI deprecated but ships it on every macOS; the functional probe is what fails closed if that ever changes.
The Windows rung keeps one deterministic write SID and standing ACE per workspace, but gives every live session/workspace pair a random private temp directory with a distinct SID and revocable ACE. Sessions sharing a workspace therefore share its intended write authority without inheriting one another's temp authority. A fresh provider always chooses a new temp path and SID, so crash residue cannot block or authorize a resumed session; agentless calls receive the same per-invocation isolation from the runner. A workspace equal to or containing the platform temp root fails before any ACL mutation because its inheritable workspace ACE would otherwise reach every private temp child.
diff --git a/packages/sandbox/sandbox-local/README.zh.md b/packages/sandbox/sandbox-local/README.zh.md
index f4e31e220f..8cb14128ae 100644
--- a/packages/sandbox/sandbox-local/README.zh.md
+++ b/packages/sandbox/sandbox-local/README.zh.md
@@ -6,9 +6,11 @@
包根目录导出默认及命名的 `LocalSandboxProvider` 插件和 `Config`;平台 profile builder 仍为内部实现。
-不受支持的平台和不可用 runner 会以 `SANDBOX_UNAVAILABLE` 拒绝执行;执行绝不会静默回退为不受限制。每次包装都携带结构化 runner 失败规则,使消费方能够区分损坏的沙箱与命令失败。[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) 负责说明选择依据与 profile 差异。
+不受支持的平台和不可用 runner 会以 `SANDBOX_UNAVAILABLE` 拒绝执行;执行绝不会静默回退为不受限制。每次包装都携带结构化 runner 失败规则,使消费方能够区分损坏的沙箱与命令失败。
-策略逐调用传入;提供方只存储机制与缓存的 runner 结论。每次包装都会报告强制执行完整度,以及后端专用的拒绝签名和 runner 失败规则。Landlock 只有在退出码为 125,且仅排除完全匹配的部分强制执行通知后仍存在一行 `landlock-run:` 致命诊断时,才判定 runner 失败;携带该通知的子进程即使以 1、2 或 125 退出,也仍按子进程结果处理。Bubblewrap 和 Seatbelt 仍仅依据签名,因为两者的公开约定均未保留 launcher 失败状态。消费方会直接 spawn 返回的 argv,因此 runner 缺失或不可执行属于带外 spawn 失败,而成功启动的子进程以 126 或 127 退出时仍按普通结果处理。`runnerCommand` 会跳过探测,并要求为自定义 runner 自身的致命方言提供一个或多个非空、单行、不区分大小写的 `runnerFailureSignatures` 条目。由于其机制未知,它会同时携带两种 Linux 拒绝方言。`probeTimeoutMs` 限定功能探测的时长。[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) 负责说明选择与失败语义。
+策略逐调用传入;提供方只存储机制与缓存的 runner 结论。每次包装都会报告强制执行完整度,以及后端专用的拒绝签名和 runner 失败规则。Landlock 只有在退出码为 125,且仅排除完全匹配的部分强制执行通知后仍存在一行 `landlock-run:` 致命诊断时,才判定 runner 失败;携带该通知的子进程即使以 1、2 或 125 退出,也仍按子进程结果处理。Bubblewrap 和 Seatbelt 仍仅依据签名,因为两者的公开约定均未保留 launcher 失败状态。消费方会直接 spawn 返回的 argv,因此 runner 缺失或不可执行属于带外 spawn 失败,而成功启动的子进程以 126 或 127 退出时仍按普通结果处理。`runnerCommand` 会跳过探测,并要求为自定义 runner 自身的致命方言提供一个或多个非空、单行、不区分大小写的 `runnerFailureSignatures` 条目。由于其机制未知,它会同时携带两种 Linux 拒绝方言。`probeTimeoutMs` 限定功能探测的时长。[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md) 负责说明选择与失败语义。
+
+bwrap profile 将只读宿主根目录、全新的 `/dev` 与使用私有 PID 命名空间的 `/proc` 组合起来。命令可管理后代进程,但看不到宿主进程;隐藏宿主的 `/proc/` 条目,可以防止 `root`、`fd` 等魔法链接绕过其挂载约束。`workspace-write` 另加临时的 `/tmp` 与可写工作区绑定挂载。[私有 PID Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-06-bwrap-private-pid-namespace.zh.md)记录该边界。
Seatbelt profile 默认允许,但带 `(deny file-write*)` 和写入 allow-list,因此恰好约束相应模式承诺的文件操作:`read-only` 只授予 `/dev/null` 字面路径;`workspace-write` 另加工作区根目录、`/tmp` 和逐用户 darwin 临时目录(`os.tmpdir()`,即平台供 mkstemp 家族工具使用的真实临时区域)。每个根目录都经过规范化,因为 Seatbelt 匹配解析后的路径(`/tmp` 就是 `/private/tmp`)。Apple 将 `sandbox-exec` CLI(命令行界面)标为 deprecated,但所有 macOS 系统仍会提供它;若情况发生变化,功能探测会使执行被拒绝。
@@ -25,7 +27,7 @@ Windows 档为每个工作区保留一个确定性写入 SID 和常驻 ACE,但
## 模型体验
-通过 [`dsh-bash-sandbox`](../../shell/bash-sandbox/README.md) 和 [`dsh-tool-bash`](../../shell/tool-bash/README.md) 间接影响;它们渲染该提供方的强制执行与拒绝事实,而 [`dsh-sandbox`](../sandbox/README.md) seam 负责定义 `SANDBOX_UNAVAILABLE` 文本,runner 选择与 profile 则不进入上下文。
+通过 [`dsh-bash-sandbox`](../../shell/bash-sandbox/README.zh.md) 和 [`dsh-tool-bash`](../../shell/tool-bash/README.zh.md) 间接影响;它们渲染该提供方的强制执行与拒绝事实,而 [`dsh-sandbox`](../sandbox/README.zh.md) seam 负责定义 `SANDBOX_UNAVAILABLE` 文本,runner 选择与 profile 则不进入上下文。
#### KV Cache 影响
diff --git a/packages/sandbox/sandbox-local/package.json b/packages/sandbox/sandbox-local/package.json
index 1866c8ddd7..8256e98ed7 100644
--- a/packages/sandbox/sandbox-local/package.json
+++ b/packages/sandbox/sandbox-local/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-sandbox-local",
"description": "Local process-sandbox backends for the DeepSeek Harness sandbox seam: bwrap, the npm-distributed landlock-run launcher, macOS Seatbelt, or the Windows ACL restricted-token runner — functionally probed, fail-closed",
- "version": "0.1.0-rc.7",
+ "version": "0.1.0-rc.8",
"publishConfig": {
"access": "public"
},
diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts
index 8880da8103..5965d86d32 100644
--- a/packages/sandbox/sandbox-local/src/index.ts
+++ b/packages/sandbox/sandbox-local/src/index.ts
@@ -66,7 +66,7 @@ export interface Config {
/** Probe whether `bwrap` can create the profile; the provider caches the bounded result. */
function defaultProbeBwrap(timeoutMs: number): boolean {
- const probe = spawnSync('bwrap', ['--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--die-with-parent', '--', 'true'], {
+ const probe = spawnSync('bwrap', [...bwrapProfileArgs({ mode: 'read-only', workspaceRoot: '/' }), '--', 'true'], {
timeout: timeoutMs,
stdio: 'ignore',
})
diff --git a/packages/sandbox/sandbox-local/src/profiles.ts b/packages/sandbox/sandbox-local/src/profiles.ts
index 5b76390319..4d647580c5 100644
--- a/packages/sandbox/sandbox-local/src/profiles.ts
+++ b/packages/sandbox/sandbox-local/src/profiles.ts
@@ -14,7 +14,7 @@ import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
* @returns profile arguments before the trailing separator and command argv.
*/
export function bwrapProfileArgs(policy: SandboxPolicy): string[] {
- const args = ['--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--die-with-parent']
+ const args = ['--ro-bind', '/', '/', '--dev', '/dev', '--unshare-pid', '--proc', '/proc', '--die-with-parent']
if (policy.mode === 'workspace-write') {
args.push('--tmpfs', '/tmp')
args.push('--bind', policy.workspaceRoot, policy.workspaceRoot)
diff --git a/packages/sandbox/sandbox-local/tests/bwrap.e2e.ts b/packages/sandbox/sandbox-local/tests/bwrap.e2e.ts
index 3da6787049..9a8289b95a 100644
--- a/packages/sandbox/sandbox-local/tests/bwrap.e2e.ts
+++ b/packages/sandbox/sandbox-local/tests/bwrap.e2e.ts
@@ -1,5 +1,5 @@
import { spawnSync } from 'node:child_process'
-import { existsSync, readFileSync, rmSync } from 'node:fs'
+import { existsSync, readFileSync, readlinkSync, rmSync } from 'node:fs'
import { mkdtemp, rm } from 'node:fs/promises'
import { homedir, tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -78,6 +78,44 @@ describe.skipIf(!bwrapUsable)('sandbox-local: real bwrap confinement', () => {
expect(result.stdout).toBe('dev-ok\n')
})
+ it.each(['read-only', 'workspace-write'] as const)(
+ '%s runs in a private PID namespace and blocks writes through procfs root magic links',
+ async (mode) => {
+ const workdir = await tempDir(homedir())
+ const outside = await tempDir(homedir())
+ const target = join(outside, 'escaped.txt')
+ const sandbox = await provider()
+ // Compare PID-namespace identity, not PID numbers: numeric /proc entries
+ // recur inside a private namespace, and the /proc/1/root write below is
+ // denied even in a shared namespace (host init is root-owned), so this
+ // comparison is the assertion that fails when --unshare-pid is lost.
+ const hostPidNamespace = readlinkSync('/proc/self/ns/pid')
+ const visibility = runConfined(sandbox, 'readlink /proc/self/ns/pid', { mode, workspaceRoot: workdir })
+ expect(visibility.result.status).toBe(0)
+ expect(visibility.result.stdout.trim()).not.toBe('')
+ expect(visibility.result.stdout.trim()).not.toBe(hostPidNamespace)
+
+ const escape = runConfined(
+ sandbox,
+ `printf escaped > /proc/1/root${target}`,
+ { mode, workspaceRoot: workdir },
+ )
+ expect(escape.result.status).not.toBe(0)
+ expect(existsSync(target)).toBe(false)
+ },
+ )
+
+ it('keeps descendants observable and controllable inside the private PID namespace', async () => {
+ const workdir = await tempDir(homedir())
+ const sandbox = await provider()
+ const { result } = runConfined(
+ sandbox,
+ 'sleep 30 & child=$!; kill -0 "$child" && kill "$child"; wait "$child"; status=$?; test "$status" -ge 128',
+ { mode: 'read-only', workspaceRoot: workdir },
+ )
+ expect(result.status).toBe(0)
+ })
+
it('workspace-write lands a write inside the workspace root and still denies one beside it', async () => {
const workdir = await tempDir(homedir())
const outside = await tempDir(homedir())
diff --git a/packages/sandbox/sandbox-local/tests/local.spec.ts b/packages/sandbox/sandbox-local/tests/local.spec.ts
index 389f23a011..285afb865b 100644
--- a/packages/sandbox/sandbox-local/tests/local.spec.ts
+++ b/packages/sandbox/sandbox-local/tests/local.spec.ts
@@ -62,13 +62,13 @@ function fakeSeatbeltExec(status: number): string {
const SEATBELT_RO_PROFILE = '(version 1) (allow default) (deny file-write*) (allow file-write* (literal "/dev/null"))'
describe('profile dialects', () => {
- it('bwrap read-only: whole tree read-only with fresh /dev and /proc, no writable mounts', () => {
- expect(bwrapProfileArgs(RO)).toEqual(['--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--die-with-parent'])
+ it('bwrap read-only: whole tree read-only with fresh /dev and private PID-scoped /proc, no writable mounts', () => {
+ expect(bwrapProfileArgs(RO)).toEqual(['--ro-bind', '/', '/', '--dev', '/dev', '--unshare-pid', '--proc', '/proc', '--die-with-parent'])
})
it('bwrap workspace-write: adds an ephemeral /tmp and rebinds the workspace root', () => {
expect(bwrapProfileArgs(WW)).toEqual([
- '--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--die-with-parent',
+ '--ro-bind', '/', '/', '--dev', '/dev', '--unshare-pid', '--proc', '/proc', '--die-with-parent',
'--tmpfs', '/tmp', '--bind', '/ws', '/ws',
])
})
diff --git a/packages/sandbox/sandbox-policy/README.i18n.yaml b/packages/sandbox/sandbox-policy/README.i18n.yaml
index f12249398a..31ef984c5e 100644
--- a/packages/sandbox/sandbox-policy/README.i18n.yaml
+++ b/packages/sandbox/sandbox-policy/README.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/sandbox/sandbox-policy/README.md
README.md: c85420019b490d311360f310c04212ac6cd737d1
-README.zh.md: 58354adf55a55951cad1ea9adb88aea2457c7f7b
+README.zh.md: 46e1deea680122622e58490064dcc4ea6699d1d3
diff --git a/packages/sandbox/sandbox-policy/README.zh.md b/packages/sandbox/sandbox-policy/README.zh.md
index 58354adf55..46e1deea68 100644
--- a/packages/sandbox/sandbox-policy/README.zh.md
+++ b/packages/sandbox/sandbox-policy/README.zh.md
@@ -2,11 +2,11 @@
[English](README.md) | 中文
-沙箱策略解析的唯一归属位置:部署默认 [`SandboxMode`](../sandbox/README.md) 与回退根目录,加上每个会话的持久模式覆盖和不可变工作区根目录。每项负责强制执行的能力在每次调用时都会收到一项解析完成的模式与根目录策略;模型在每次请求前会收到当前策略,而不会另收一份能力清单。
+沙箱策略解析的唯一归属位置:部署默认 [`SandboxMode`](../sandbox/README.zh.md) 与回退根目录,加上每个会话的持久模式覆盖和不可变工作区根目录。每项负责强制执行的能力在每次调用时都会收到一项解析完成的模式与根目录策略;模型在每次请求前会收到当前策略,而不会另收一份能力清单。
## 为何需要共享归属位置
-文件系统工具、一次性 bash 命令和终端会话可以用不同组合强制执行同一套模式词汇。如果各自解析 `mode` + `workspaceRoot`,就可能漂移成分裂世界,正是[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)所警告的情况。每个强制执行后端都会消费归属方解析出的完整策略,而当前上下文只说明该策略对于任何受 DSH 文件沙箱强制执行的可用操作有何含义。[跨家族 fs 沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md)记录了共享策略决策。
+文件系统工具、一次性 bash 命令和终端会话可以用不同组合强制执行同一套模式词汇。如果各自解析 `mode` + `workspaceRoot`,就可能漂移成分裂世界,正是[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md)所警告的情况。每个强制执行后端都会消费归属方解析出的完整策略,而当前上下文只说明该策略对于任何受 DSH 文件沙箱强制执行的可用操作有何含义。[跨家族 fs 沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.zh.md)记录了共享策略决策。
## 配置
diff --git a/packages/sandbox/sandbox-policy/package.json b/packages/sandbox/sandbox-policy/package.json
index 3038e99718..6f4532b376 100644
--- a/packages/sandbox/sandbox-policy/package.json
+++ b/packages/sandbox/sandbox-policy/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-sandbox-policy",
"description": "Per-call sandbox policy resolver and current model context: deployment fallbacks plus each session's mode and workspace root, shared by every enforcing capability family",
- "version": "0.1.0-rc.7",
+ "version": "0.1.0-rc.8",
"publishConfig": {
"access": "public"
},
diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml
index a394957f49..ef0522e661 100644
--- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml
+++ b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/sandbox/sandbox-windows-acl/README.md
README.md: a78f334342196ec1848a4a360e5c60b375a28057
-README.zh.md: 8962653b69b23b92fe763e4fcc90bf45911865f7
+README.zh.md: dc7f4d4c080d4e2204103afb8b1a2defcfb306c7
diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md
index 8962653b69..dc7f4d4c08 100644
--- a/packages/sandbox/sandbox-windows-acl/README.zh.md
+++ b/packages/sandbox/sandbox-windows-acl/README.zh.md
@@ -6,7 +6,7 @@
一句话机制:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 携带彼此独立的工作区能力与私有临时目录能力。工作区 SID 由规范工作区路径确定性派生(`workspaceWriteSid`),因此工作区根目录 ACE 每台机器每个工作区只物化一次,之后每次会话、调用或重启都命中精确 ACE 跳过。每个活跃的会话/工作区对则获得一个随机临时目录,以及一个从该路径派生的 SID(`tempWriteSid`),因此各会话共享预期的工作区权限,却不会继承彼此的临时目录权限。此后 Windows 只在「调用者正常权限」与「restricting SID 交集」同时允许时才放行写入。这些 SID 是主要白名单,在系统其余位置不授予任何权限;但该检查还会继承**其他** restricting SID 的环境写 ACE(保活组登录 SID + Everyone),而 NTFS ACL 属于文件对象而非路径。Everyone 与硬链接边界正是该档报告部分而非完整强制执行的原因。
-直接构建在原生 ACL 机制上是记录在案的设计选择:它实现两种隔离模式,且不背负被否决的容器方案的问题——见[设计笔记](../../../.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md)([mxc](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md) 要求 Windows 11 24H2 的 OS 下限,且任意路径读取需要整体改写宿主 DACL;AppContainer 根本无法任意路径读取)。
+直接构建在原生 ACL 机制上是记录在案的设计选择:它实现两种隔离模式,且不背负被否决的容器方案的问题——见[设计笔记](../../../.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md)([mxc](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md) 要求 Windows 11 24H2 的 OS 下限,且任意路径读取需要整体改写宿主 DACL;AppContainer 根本无法任意路径读取)。
## 用法
@@ -86,7 +86,7 @@ koffi 结构体定义在模块加载时对照探针断言其大小,因此头
## 模型体验
-间接地通过 [`dsh-bash-sandbox`](../../shell/bash-sandbox/README.md)、[`dsh-pwsh-sandbox`](../../shell/pwsh-sandbox/README.md) 及其工具呈现:它们渲染此后端的部分强制执行与拒绝事实(工具层通过 `denialSignatures` 分类的受限 stderr),而 [`dsh-sandbox`](../sandbox/README.md) seam 拥有 `SANDBOX_UNAVAILABLE` 文本与 runner 选择。
+间接地通过 [`dsh-bash-sandbox`](../../shell/bash-sandbox/README.zh.md)、[`dsh-pwsh-sandbox`](../../shell/pwsh-sandbox/README.zh.md) 及其工具呈现:它们渲染此后端的部分强制执行与拒绝事实(工具层通过 `denialSignatures` 分类的受限 stderr),而 [`dsh-sandbox`](../sandbox/README.zh.md) seam 拥有 `SANDBOX_UNAVAILABLE` 文本与 runner 选择。
#### KV Cache 影响
diff --git a/packages/sandbox/sandbox-windows-acl/package.json b/packages/sandbox/sandbox-windows-acl/package.json
index 817d52e48f..21e42e961a 100644
--- a/packages/sandbox/sandbox-windows-acl/package.json
+++ b/packages/sandbox/sandbox-windows-acl/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-sandbox-windows-acl",
"description": "Windows ACL write-restriction sandbox backend (restricted-token spawn with capability-SID write allowlist) for the DeepSeek Harness sandbox seam",
- "version": "0.1.0-rc.7",
+ "version": "0.1.0-rc.8",
"publishConfig": {
"access": "public"
},
diff --git a/packages/sandbox/sandbox/README.i18n.yaml b/packages/sandbox/sandbox/README.i18n.yaml
index 7e7ed765b2..d92c0552ea 100644
--- a/packages/sandbox/sandbox/README.i18n.yaml
+++ b/packages/sandbox/sandbox/README.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/sandbox/sandbox/README.md
README.md: 3ebbffd623eee51dd6fcb588eec374e6d09be2f0
-README.zh.md: bde83955a85cbe2d60c521e3677b5b305a29ecb1
+README.zh.md: b9215b59d5ed5314275e91261c5541be1e5586fe
diff --git a/packages/sandbox/sandbox/README.zh.md b/packages/sandbox/sandbox/README.zh.md
index bde83955a8..b9215b59d5 100644
--- a/packages/sandbox/sandbox/README.zh.md
+++ b/packages/sandbox/sandbox/README.zh.md
@@ -2,23 +2,25 @@
[English](README.md) | 中文
-进程沙箱 Service Definition。负责定义 `ctx.sandbox` 服务约定([`SandboxProvider`](src/index.ts))与 harness 共享的限制词汇:`SandboxMode`(`read-only`/`workspace-write`/`danger-full-access`,仅限文件操作)、`SandboxEnforcement`(`full`/`partial`,针对每种内核 ABI)、`SandboxExecutionPolicy`(每次调用的完整模式及工作区根目录)、`SandboxPolicy`(其中受限制的子集),以及故障时拒绝放行的 `SANDBOX_UNAVAILABLE` 错误。作为[能力 seam 拆分](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)中的 Service Definition 角色,它只依赖 cordis(及 harness 错误基类),绝不依赖后端。
+进程沙箱 Service Definition。负责定义 `ctx.sandbox` 服务约定([`SandboxProvider`](src/index.ts))与 harness 共享的限制词汇:`SandboxMode`(`read-only`/`workspace-write`/`danger-full-access`,仅限文件操作)、`SandboxEnforcement`(`full`/`partial`,针对每种内核 ABI)、`SandboxExecutionPolicy`(每次调用的完整模式及工作区根目录)、`SandboxPolicy`(其中受限制的子集),以及故障时拒绝放行的 `SANDBOX_UNAVAILABLE` 错误。作为[能力 seam 拆分](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md)中的 Service Definition 角色,它只依赖 cordis(及 harness 错误基类),绝不依赖后端。
-用一句话概括约定:`ctx.sandbox.confine(argv, policy)` 返回用于 spawn、应当取代调用方原始 argv 的 argv。返回值经过包装,使进程及其派生的所有进程都在限制下运行;还会附带所选后端达到的强制执行完整度、拒绝方言(`denialSignatures`)和结构化 runner 失败证据(`runnerFailureRules`)。没有可用后端时,它会抛出异常,绝不会原样传递 argv 使其不受限制地运行。[核心类型目录](../../../docs/subsystems/sandbox.md#wrapped-argv-and-classification-dialects)负责定义分类器的精确结构。
+用一句话概括约定:`ctx.sandbox.confine(argv, policy)` 返回用于 spawn、应当取代调用方原始 argv 的 argv。返回值经过包装,使进程及其派生的所有进程都在限制下运行;还会附带所选后端达到的强制执行完整度、拒绝方言(`denialSignatures`)和结构化 runner 失败证据(`runnerFailureRules`)。没有可用后端时,它会抛出异常,绝不会原样传递 argv 使其不受限制地运行。[核心类型目录](../../../docs/subsystems/sandbox.zh.md#wrapped-argv-and-classification-dialects)负责定义分类器的精确结构。
策略随调用传递,而不属于提供方:两个消费方可以同时按不同策略施加限制(bash 使用 `read-only`,而受限制的子 agent(智能体)保持其状态目录可写);获批的升权重试只是使用更宽策略发起的新调用。
-**只支持与宿主共享文件系统和内核的限制。** 后端与宿主共享文件系统和内核(`bwrap`、Landlock、Seatbelt);`workspaceRoot` 指向文件系统规范化后的真实主机目录。系统先解析工作区所指的目录,再做词法规范化,因此包含 `symlink/..` 的有效 cwd 会授权 `chdir` 实际到达的目录,而非无关的词法父目录。容器、microVM 与远程执行器都不是该 seam 的后端:它们会以环境一致的分组替换整个能力 seam 的 Service Provider(`ctx.shell`、`ctx.fs`)。边界及其设计理由见[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。
+**只支持与宿主共享文件系统和内核的限制。** 后端与宿主共享文件系统和内核(`bwrap`、Landlock、Seatbelt);`workspaceRoot` 指向文件系统规范化后的真实主机目录。系统先解析工作区所指的目录,再做词法规范化,因此包含 `symlink/..` 的有效 cwd 会授权 `chdir` 实际到达的目录,而非无关的词法父目录。容器、microVM 与远程执行器都不是该 seam 的后端:它们会以环境一致的分组替换整个能力 seam 的 Service Provider(`ctx.shell`、`ctx.fs`)。边界及其设计理由见[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md)。
实现:[`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/)(Linux:`bwrap`,否则使用相应平台的 Landlock launcher;macOS:`sandbox-exec`/Seatbelt)。消费方:[`@deepseek-ai/dsh-bash-sandbox`](../../shell/bash-sandbox/)(包装 `['bash', '-c', command]`)。
## 模型体验
+
+
### 间接的限制错误
#### 模型看到的内容
-通过 [`dsh-bash-sandbox`](../../shell/bash-sandbox/README.md) 和 [`dsh-tool-bash`](../../shell/tool-bash/README.md),无法强制执行所请求模式时会产生错误码 `SANDBOX_UNAVAILABLE` 及以下精确错误。执行期 runner 失败会追加 ` Runner failure: `。
+通过 [`dsh-bash-sandbox`](../../shell/bash-sandbox/README.zh.md) 和 [`dsh-tool-bash`](../../shell/tool-bash/README.zh.md),无法强制执行所请求模式时会产生错误码 `SANDBOX_UNAVAILABLE` 及以下精确错误。执行期 runner 失败会追加 ` Runner failure: `。
##### 精确错误
diff --git a/packages/sandbox/sandbox/package.json b/packages/sandbox/sandbox/package.json
index 7581e4a7ff..b2506fa10c 100644
--- a/packages/sandbox/sandbox/package.json
+++ b/packages/sandbox/sandbox/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-sandbox",
"description": "Abstract process-sandbox seam (ctx.sandbox) for the DeepSeek Harness: same-world confinement vocabulary and the SandboxProvider contract",
- "version": "0.1.0-rc.7",
+ "version": "0.1.0-rc.8",
"publishConfig": {
"access": "public"
},
diff --git a/packages/schedule/README.i18n.yaml b/packages/schedule/README.i18n.yaml
index 4d04edf194..90aa318ed0 100644
--- a/packages/schedule/README.i18n.yaml
+++ b/packages/schedule/README.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/schedule/README.md
README.md: 2fac190cc19f40e5d5e87acacaddce970e4b8474
-README.zh.md: 3db72e7e6650e38ab51fd75087d95cb2faf121d4
+README.zh.md: af4cd96dcb5f095a7ea83556e5f73eca532d0dee
diff --git a/packages/schedule/README.zh.md b/packages/schedule/README.zh.md
index 3db72e7e66..af4cd96dcb 100644
--- a/packages/schedule/README.zh.md
+++ b/packages/schedule/README.zh.md
@@ -10,4 +10,4 @@ Schedule 家族负责管理提醒,其持久状态保存在原 Session 日志
本包有意不公开 Schedule service 或可变数据库。工具与 runtime 向 Session stream 追加事件;到期工作通过 Agent 的普通 follow-up 队列进入同一对话。
-有关持久记录、转换、视图与交付约定,请参阅[仅限 Session 内的 Schedule](../../docs/subsystems/schedule.md)。
+有关持久记录、转换、视图与交付约定,请参阅[仅限 Session 内的 Schedule](../../docs/subsystems/schedule.zh.md)。
diff --git a/packages/schedule/schedule/README.i18n.yaml b/packages/schedule/schedule/README.i18n.yaml
index 76577697c4..8dc406becf 100644
--- a/packages/schedule/schedule/README.i18n.yaml
+++ b/packages/schedule/schedule/README.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/schedule/schedule/README.md
README.md: f56c689198b7e17a85f43a1ecb6e5f1527da6a91
-README.zh.md: 75425c77d2b43c6511c400a55bb5f8f898f978cc
+README.zh.md: 7180565370d83a7207c73aa9a2539219974dace0
diff --git a/packages/schedule/schedule/README.zh.md b/packages/schedule/schedule/README.zh.md
index 75425c77d2..7180565370 100644
--- a/packages/schedule/schedule/README.zh.md
+++ b/packages/schedule/schedule/README.zh.md
@@ -26,7 +26,7 @@ Schedule 负责确定性的日历规范化。落在夏令时缺口内的本地
## 管理工具
-生成的[工具目录](../../../docs/tool-catalog.md)负责 `schedule_create`、`schedule_list` 和 `schedule_delete` 的参数与输出 schema。虽然模型输入使用 `after_seconds` 和 `time_zone`,但其规范值中的记录字段使用 camelCase。
+生成的[工具目录](../../../docs/tool-catalog.zh.md)负责 `schedule_create`、`schedule_list` 和 `schedule_delete` 的参数与输出 schema。虽然模型输入使用 `after_seconds` 和 `time_zone`,但其规范值中的记录字段使用 camelCase。
一条 Agent-scoped 队列会将每项已接纳的管理事务与 live owner 的到期事务从 preflight 到任何 post-append barrier 全程串行化。`schedule_create` 要求 `after_seconds`、`at` 与 `every_seconds` 有且只有一项;它会在进入队列前验证只依赖输入形状的失败,随后执行检查点、分配永不复用的 id、追加 create,再次执行检查点。`schedule_list` 按创建顺序返回活动记录,其中包含 `state: "scheduled" | "overdue"` 与 `deliveryMode: "session-local"`。`schedule_delete` 会在进入队列前拒绝空 id 或前后带空白的 id,并只为活动 id 追加事件;未知或已终结的 id 会在 preflight 后返回 `{ id, deleted: false, code: "schedule_not_found" }`。
diff --git a/packages/schedule/schedule/package.json b/packages/schedule/schedule/package.json
index 70910080e5..a40830b3b1 100644
--- a/packages/schedule/schedule/package.json
+++ b/packages/schedule/schedule/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-schedule",
"description": "Agent-scoped durable after, at, and fixed-rate reminders over the session event log",
- "version": "0.1.0-rc.7",
+ "version": "0.1.0-rc.8",
"publishConfig": {
"access": "public"
},
diff --git a/packages/sdk/README.i18n.yaml b/packages/sdk/README.i18n.yaml
index c9fc6e2600..b78b628c94 100644
--- a/packages/sdk/README.i18n.yaml
+++ b/packages/sdk/README.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/sdk/README.md
README.md: 052ac933defae8766e99d79b79bc4cdc6c6d74db
-README.zh.md: 364f752fcbed06cae8b0675cae852be2760bd3f4
+README.zh.md: e420c5178de7233e9939147e6b674d83e29902b8
diff --git a/packages/sdk/README.zh.md b/packages/sdk/README.zh.md
index 364f752fcb..e420c5178d 100644
--- a/packages/sdk/README.zh.md
+++ b/packages/sdk/README.zh.md
@@ -2,10 +2,10 @@
[English](README.md) | 中文
-本组包含用于从另一进程驱动 Harness 运行时的协议栈。调用方提供运行时可执行文件及其 `cordis.yml`;本组不创建、配置、构建或启动开发者项目。[TypeScript SDK 决策](../../.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md)负责客户端约定,[工具链移除](../../.agents/notes/implemented/simplification/2026-08-11-remove-sdk-project-toolchain.md)负责产品边界。
+本组包含用于从另一进程驱动 Harness 运行时的协议栈。调用方提供运行时可执行文件及其 `cordis.yml`;本组不创建、配置、构建或启动开发者项目。[TypeScript SDK 决策](../../.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md)负责客户端约定,[工具链移除](../../.agents/notes/implemented/simplification/2026-08-11-remove-sdk-project-toolchain.zh.md)负责产品边界。
| 包 | 职责 |
|---|---|
-| [`protocol/`](protocol/README.md) | 定义 SDK 运行时通信协议 |
-| [`client/`](client/README.md) | 通过 TypeScript 客户端 API 驱动 Harness 运行时 |
-| [`server/`](server/README.md) | 通过 stdio JSON-RPC 为进程外 SDK 客户端提供服务 |
+| [`protocol/`](protocol/README.zh.md) | 定义 SDK 运行时通信协议 |
+| [`client/`](client/README.zh.md) | 通过 TypeScript 客户端 API 驱动 Harness 运行时 |
+| [`server/`](server/README.zh.md) | 通过 stdio JSON-RPC 为进程外 SDK 客户端提供服务 |
diff --git a/packages/sdk/client/README.i18n.yaml b/packages/sdk/client/README.i18n.yaml
index da2067692b..ab49e47455 100644
--- a/packages/sdk/client/README.i18n.yaml
+++ b/packages/sdk/client/README.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/sdk/client/README.md
README.md: b33457875f81d11d09bab2e5aa5ce730e233c78a
-README.zh.md: 271f07ffb0f97abe005971962beb517acfdc05a4
+README.zh.md: b89e56629fefe572394751bc1bee38aaba6f3300
diff --git a/packages/sdk/client/README.zh.md b/packages/sdk/client/README.zh.md
index 271f07ffb0..b89e56629f 100644
--- a/packages/sdk/client/README.zh.md
+++ b/packages/sdk/client/README.zh.md
@@ -2,9 +2,9 @@
[English](README.md) | 中文
-以子进程方式驱动 DeepSeek Harness 运行时、走 stdio JSON-RPC 的 TypeScript 客户端 SDK——[Python SDK](../../../python/README.md)(`deepseek-harness`)的设计孪生,共享同一个运行时对端、协议与分层:`DeepSeekHarness` 是高层自有运行 API,`HarnessClient` 是低层协议客户端。包(package)根枚举消费方接口:两层客户端、面向调用方的类型和 `JsonRpcResponseError`;源模块、规范化辅助函数与订阅投递机制不供消费方导入。纯库:不在任何 Cordis 上下文注册;它所 spawn 的运行时进程是一个完整 harness,其组成由自己的 `cordis.yml` 决定。
+以子进程方式驱动 DeepSeek Harness 运行时、走 stdio JSON-RPC 的 TypeScript 客户端 SDK——[Python SDK](../../../python/README.zh.md)(`deepseek-harness`)的设计孪生,共享同一个运行时对端、协议与分层:`DeepSeekHarness` 是高层自有运行 API,`HarnessClient` 是低层协议客户端。包(package)根枚举消费方接口:两层客户端、面向调用方的类型和 `JsonRpcResponseError`;源模块、规范化辅助函数与订阅投递机制不供消费方导入。纯库:不在任何 Cordis 上下文注册;它所 spawn 的运行时进程是一个完整 harness,其组成由自己的 `cordis.yml` 决定。
-与 Python SDK 不同,启动规格完全显式(`command`/`args`):本包面向仓库近旁的 TypeScript 消费方,包括 [`dsh-subagent-dsh-sdk`](../../subagent/subagent-dsh-sdk/README.md) 后端和自动化;它们知道自己要启动哪个运行时。捆绑运行时解析(寻找打包可执行文件)仍归 Python 发行版负责。
+与 Python SDK 不同,启动规格完全显式(`command`/`args`):本包面向仓库近旁的 TypeScript 消费方,包括 [`dsh-subagent-dsh-sdk`](../../subagent/subagent-dsh-sdk/README.zh.md) 后端和自动化;它们知道自己要启动哪个运行时。捆绑运行时解析(寻找打包可执行文件)仍归 Python 发行版负责。
## DeepSeekHarness
@@ -29,7 +29,7 @@ console.log(result.finalResponse)
自有运行 API 之下的协议客户端:显式 `start()`/`initialize()`/`prompt()`/`request()`/`close()`,外加通知订阅。`prompt()` 在运行时接受排队消息后立即返回该消息的 ID,绝不等待 agent 活动。`subscribe(filter?)` 返回 `NotificationSubscription`(可等待的 `next()`、非阻塞 `tryNext()`、异步迭代);`subscribeSessionTree(id)` 把范围限定到一个会话及从 `subagent.started` 血缘边发现的后代——运行时对上下文内每个会话都发通知,范围限定在客户端完成,与 Python SDK 完全一致。本包导出有明确类型的错误:`JsonRpcResponseError`(协议错误响应,保留 code/data)、`RequestTimeoutError`(配置的时限已到)、`SdkProtocolError`(响应超出文档化协议)、`TransportClosedError`(运行时已消失——消息携带退出码与有界 stderr 尾部)。
-`close()` 先请求协议 `shutdown`(受 `shutdownTimeoutMs` 约束,默认 1000 毫秒),然后走 stdin-EOF → SIGTERM → SIGKILL 阶梯(`disposeEofGraceMs` 默认 6000,`disposeGraceMs` 默认 3000)直到进程真正退出。该阶梯为本客户端私有:它运行在任何 harness 上下文之外,无法搭乘 [`dsh-subprocess`](../../subprocess/README.md) 服务——即该 seam 所记录的 SDK 托管传输例外。幂等,已关闭的客户端拒绝复用。
+`close()` 先请求协议 `shutdown`(受 `shutdownTimeoutMs` 约束,默认 1000 毫秒),然后走 stdin-EOF → SIGTERM → SIGKILL 阶梯(`disposeEofGraceMs` 默认 6000,`disposeGraceMs` 默认 3000)直到进程真正退出。该阶梯为本客户端私有:它运行在任何 harness 上下文之外,无法搭乘 [`dsh-subprocess`](../../subprocess/README.zh.md) 服务——即该 seam 所记录的 SDK 托管传输例外。幂等,已关闭的客户端拒绝复用。
`HarnessClientOptions.env` 给定时整体替换子进程环境(`undefined` 原样继承父进程环境);凭据策略归调用方——`dsh-subprocess` 的 `scrubbedParentEnv` 是面向隔离启动的共享擦除基底。
@@ -44,6 +44,6 @@ console.log(result.finalResponse)
## 已知限制与暂缓事项
- **无捆绑运行时解析**——调用方显式指定运行时可执行文件;打包可执行文件的发现留在 Python 侧,直到出现 TypeScript 发行版消费方。
-- **无轮次中取消**——协议层没有提示词取消方法;放弃轮次意味着关闭运行时(见协议的 [已知限制](../protocol/README.md))。
+- **无轮次中取消**——协议层没有提示词取消方法;放弃轮次意味着关闭运行时(见协议的 [已知限制](../protocol/README.zh.md))。
- **没有逐提示词结果或取消**——低层 `prompt()` 只返回入队回执;高层 `run()` 负责从回执收集到 idle,放弃该过程意味着关闭运行时。
- **客户端→服务端通知与服务端→客户端请求**在协议两端都未实现;传输层为未来审批流保留了承载能力。
diff --git a/packages/sdk/client/package.json b/packages/sdk/client/package.json
index 523223f102..a1270ffe8a 100644
--- a/packages/sdk/client/package.json
+++ b/packages/sdk/client/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-sdk-client",
"description": "TypeScript client SDK for driving a DeepSeek Harness runtime subprocess over stdio JSON-RPC: the DeepSeekHarness high-level turns API and the lower-level HarnessClient",
- "version": "0.1.0-rc.7",
+ "version": "0.1.0-rc.8",
"publishConfig": {
"access": "public"
},
diff --git a/packages/sdk/protocol/README.i18n.yaml b/packages/sdk/protocol/README.i18n.yaml
index 262a608875..63a7e665ac 100644
--- a/packages/sdk/protocol/README.i18n.yaml
+++ b/packages/sdk/protocol/README.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/sdk/protocol/README.md
README.md: 9024f9ca34a5467aff1b83cb9cd864c1ec06e56b
-README.zh.md: ffd67552a770561320f919eec95679681fadbe0d
+README.zh.md: 28372bf3dcd57a817225a2769ef969d6f0c10936
diff --git a/packages/sdk/protocol/README.zh.md b/packages/sdk/protocol/README.zh.md
index ffd67552a7..28372bf3dc 100644
--- a/packages/sdk/protocol/README.zh.md
+++ b/packages/sdk/protocol/README.zh.md
@@ -2,7 +2,7 @@
[English](README.md) | 中文
-DeepSeek Harness SDK 运行时的共享协议格式(wire format):一个按换行分帧的 JSON-RPC 2.0 传输类,加上协议两端共同使用的具名请求、结果与通知类型。包根枚举协议消费方接口;源模块不支持深层导入。服务端是 [`dsh-sdk-jsonrpc-server`](../server/README.md) 插件;客户端是 [`dsh-sdk-client`](../client/README.md)(TypeScript)与 [Python SDK](../../../python/README.md)(后者复现这些结构但不导入它们)。纯库——无插件、无 Config、无注册。
+DeepSeek Harness SDK 运行时的共享协议格式(wire format):一个按换行分帧的 JSON-RPC 2.0 传输类,加上协议两端共同使用的具名请求、结果与通知类型。包根枚举协议消费方接口;源模块不支持深层导入。服务端是 [`dsh-sdk-jsonrpc-server`](../server/README.zh.md) 插件;客户端是 [`dsh-sdk-client`](../client/README.zh.md)(TypeScript)与 [Python SDK](../../../python/README.zh.md)(后者复现这些结构但不导入它们)。纯库——无插件、无 Config、无注册。
## 传输
@@ -26,7 +26,7 @@ DeepSeek Harness SDK 运行时的共享协议格式(wire format):一个按
## 模型体验
-无,因为此包定义面向客户端的协议格式;模型可见接口属于组合在对外服务入口 [`dsh-sdk-jsonrpc-server`](../server/README.md) 后方的运行时插件。
+无,因为此包定义面向客户端的协议格式;模型可见接口属于组合在对外服务入口 [`dsh-sdk-jsonrpc-server`](../server/README.zh.md) 后方的运行时插件。
#### KV Cache 影响
@@ -35,5 +35,5 @@ DeepSeek Harness SDK 运行时的共享协议格式(wire format):一个按
## 已知限制与暂缓事项
- **无协议版本协商**——握手只携带 `serverInfo.version`(`0.0.1`,客户端不校验);处于预发布阶段,无兼容承诺。
-- **无取消与会话关闭方法**——客户端放弃轮次的方式是关闭运行时进程;见 [`dsh-sdk-jsonrpc-server` README](../server/README.md)。
+- **无取消与会话关闭方法**——客户端放弃轮次的方式是关闭运行时进程;见 [`dsh-sdk-jsonrpc-server` README](../server/README.zh.md)。
- **server→client 请求是未使用的功能**——传输层支持,但服务器从不发送;Python SDK 的应答接口为未来审批流程预留。
diff --git a/packages/sdk/protocol/package.json b/packages/sdk/protocol/package.json
index beda9e4982..b506b5d45e 100644
--- a/packages/sdk/protocol/package.json
+++ b/packages/sdk/protocol/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-sdk-protocol",
"description": "Shared wire protocol for the DeepSeek Harness SDK runtime: the newline-delimited JSON-RPC stdio transport and the named request, result, and notification types spoken between the runtime server and SDK clients",
- "version": "0.1.0-rc.7",
+ "version": "0.1.0-rc.8",
"publishConfig": {
"access": "public"
},
diff --git a/packages/sdk/server/README.i18n.yaml b/packages/sdk/server/README.i18n.yaml
index 4f277446d3..bd7b0b22c4 100644
--- a/packages/sdk/server/README.i18n.yaml
+++ b/packages/sdk/server/README.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/sdk/server/README.md
README.md: 29ad5840b9d70c9c22ecd387730ba21ce89cbe07
-README.zh.md: f5afb255c83fa62fa4c2891cf725ffbf4c93e77d
+README.zh.md: c5e170beb70e3cd887865d653f49f0810bc07d48
diff --git a/packages/sdk/server/README.zh.md b/packages/sdk/server/README.zh.md
index f5afb255c8..c5e170beb7 100644
--- a/packages/sdk/server/README.zh.md
+++ b/packages/sdk/server/README.zh.md
@@ -2,7 +2,7 @@
[English](README.md) | 中文
-`jsonrpc` 插件通过 stdio 提供以换行符分隔的 JSON-RPC,使进程外 SDK 客户端能够驱动 harness agent(智能体)。[`HarnessSdkJsonRpcServer`](src/server.ts) 负责协议方法和通知;传输与具名协议类型位于 [`dsh-sdk-protocol`](../protocol/README.md),与客户端 SDK 共享;[`jsonrpc-demo`](../../examples/jsonrpc-demo/README.md) 提供外围的 `cordis.yml` 应用。
+`jsonrpc` 插件通过 stdio 提供以换行符分隔的 JSON-RPC,使进程外 SDK 客户端能够驱动 harness agent(智能体)。[`HarnessSdkJsonRpcServer`](src/server.ts) 负责协议方法和通知;传输与具名协议类型位于 [`dsh-sdk-protocol`](../protocol/README.zh.md),与客户端 SDK 共享;[`jsonrpc-demo`](../../examples/jsonrpc-demo/README.zh.md) 提供外围的 `cordis.yml` 应用。
## 组装
diff --git a/packages/sdk/server/package.json b/packages/sdk/server/package.json
index ad55c29dd8..855254e97a 100644
--- a/packages/sdk/server/package.json
+++ b/packages/sdk/server/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-sdk-jsonrpc-server",
"description": "Stdio JSON-RPC server plugin for out-of-process DeepSeek Harness SDK clients",
- "version": "0.1.0-rc.7",
+ "version": "0.1.0-rc.8",
"publishConfig": {
"access": "public"
},
diff --git a/packages/session-query/README.i18n.yaml b/packages/session-query/README.i18n.yaml
index 544fef197b..ba11f33c04 100644
--- a/packages/session-query/README.i18n.yaml
+++ b/packages/session-query/README.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session-query/README.md
README.md: b955a7f6e19f5a49e48e564523cc343e32816dd2
-README.zh.md: d1e6c645050f4e58f9409e328552b5f9209bc3a5
+README.zh.md: ebf3bd5c5f18e00b0beec4855fb99907019664ed
diff --git a/packages/session-query/README.zh.md b/packages/session-query/README.zh.md
index d1e6c64505..ebf3bd5c5f 100644
--- a/packages/session-query/README.zh.md
+++ b/packages/session-query/README.zh.md
@@ -6,9 +6,9 @@
| 包 | 职责 | ctx 键 |
|---|---|---|
-| [`session-query/`](session-query/README.md) | 定义可信读取、关系查询和搜索操作 | `ctx.sessionQuery` |
-| [`session-query-sqlite/`](session-query-sqlite/README.md) | 使用 SQLite 全文搜索实现会话查询 | `ctx.sessionQuery` |
-| [`session-log-export/`](session-log-export/README.md) | 在 Host ZIP 端点之上增加 Web `/export` 命令、共享浏览器下载状态和结果弹窗 | `ctx.sessionLogDownload` |
-| [`tool-session-query/`](tool-session-query/README.md) | 向模型公开经过工作区授权的会话查询 | 注册到 `ctx.tools` |
+| [`session-query/`](session-query/README.zh.md) | 定义可信读取、关系查询和搜索操作 | `ctx.sessionQuery` |
+| [`session-query-sqlite/`](session-query-sqlite/README.zh.md) | 使用 SQLite 全文搜索实现会话查询 | `ctx.sessionQuery` |
+| [`session-log-export/`](session-log-export/README.zh.md) | 在 Host ZIP 端点之上增加 Web `/export` 命令、共享浏览器下载状态和结果弹窗 | `ctx.sessionLogDownload` |
+| [`tool-session-query/`](tool-session-query/README.zh.md) | 向模型公开经过工作区授权的会话查询 | 注册到 `ctx.tools` |
-子系统参考——逻辑记录、有界读取、追踪、筛选器、结果页——见 [docs/subsystems/session-query.md](../../docs/subsystems/session-query.md)。
+子系统参考——逻辑记录、有界读取、追踪、筛选器、结果页——见 [docs/subsystems/session-query.md](../../docs/subsystems/session-query.zh.md)。
diff --git a/packages/session-query/session-log-export/README.i18n.yaml b/packages/session-query/session-log-export/README.i18n.yaml
index d333d5ed90..0d158164ee 100644
--- a/packages/session-query/session-log-export/README.i18n.yaml
+++ b/packages/session-query/session-log-export/README.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session-query/session-log-export/README.md
README.md: f3625338e85a38ab279c5fa4700a7a9e1124cfb3
-README.zh.md: 5a2fa86d126f6bda808d3036bddc0e26fd310690
+README.zh.md: 101f0bb41c7296916b72a82186dae74b1b576e7f
diff --git a/packages/session-query/session-log-export/README.zh.md b/packages/session-query/session-log-export/README.zh.md
index 5a2fa86d12..101f0bb41c 100644
--- a/packages/session-query/session-log-export/README.zh.md
+++ b/packages/session-query/session-log-export/README.zh.md
@@ -2,7 +2,7 @@
[English](README.md) | 中文
-Web Session 日志下载控制,使用 `dsh-host-apiproxy` 拥有的 Host 流式 ZIP 端点。Host 半包注册 `/export`;浏览器半包在 Session Header 中提供 111×32 的 `Session log` 操作,以及一个供该按钮与斜杠命令共用的下载控制器和弹窗。ZIP 生成、原始 JSONL/zstd 读取、子 Session、附件、背压和 HTTP 错误语义仍由 [ApiProxy 下载实现](../../host/apiproxy/README.md)负责。
+Web Session 日志下载控制,使用 `dsh-host-apiproxy` 拥有的 Host 流式 ZIP 端点。Host 半包注册 `/export`;浏览器半包在 Session Header 中提供 111×32 的 `Session log` 操作,以及一个供该按钮与斜杠命令共用的下载控制器和弹窗。ZIP 生成、原始 JSONL/zstd 读取、子 Session、附件、背压和 HTTP 错误语义仍由 [ApiProxy 下载实现](../../host/apiproxy/README.zh.md)负责。
## 命令约定
diff --git a/packages/session-query/session-log-export/package.json b/packages/session-query/session-log-export/package.json
index 5092374444..f4c12768f1 100644
--- a/packages/session-query/session-log-export/package.json
+++ b/packages/session-query/session-log-export/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-session-log-export",
"description": "Web Session-log export command and shared download dialog",
- "version": "0.1.0-rc.7",
+ "version": "0.1.0-rc.8",
"publishConfig": { "access": "public" },
"repository": {
"type": "git",
diff --git a/packages/session-query/session-query-sqlite/package.json b/packages/session-query/session-query-sqlite/package.json
index 4bab825e49..6b507c1d1a 100644
--- a/packages/session-query/session-query-sqlite/package.json
+++ b/packages/session-query/session-query-sqlite/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-session-query-sqlite",
"description": "Concrete ctx.sessionQuery backend with SQLite FTS5 search",
- "version": "0.1.0-rc.7",
+ "version": "0.1.0-rc.8",
"publishConfig": {
"access": "public"
},
diff --git a/packages/session-query/session-query/README.i18n.yaml b/packages/session-query/session-query/README.i18n.yaml
index 783b6d9e6f..b668d26d4b 100644
--- a/packages/session-query/session-query/README.i18n.yaml
+++ b/packages/session-query/session-query/README.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session-query/session-query/README.md
README.md: 02640bee4b3eb7f6877756a2ce84b26f698c9f58
-README.zh.md: 794c0e6e86606d92ed82ec286844647487df3295
+README.zh.md: dbac518b2c295825459475dba5a9c6c38f53afc8
diff --git a/packages/session-query/session-query/README.zh.md b/packages/session-query/session-query/README.zh.md
index 794c0e6e86..dbac518b2c 100644
--- a/packages/session-query/session-query/README.zh.md
+++ b/packages/session-query/session-query/README.zh.md
@@ -29,7 +29,7 @@
`SessionQueryEngine.searchSessions(request, exec?)` 按匹配最强的事件对逻辑语料库分组;`searchEvents(request, exec?)` 搜索一个逻辑会话。这两个是服务仅有的抽象方法。两者都返回分页结果,其延续信息是由服务持有的带品牌 `SessionSearchCursor`;接受可选取消,并在不使用提供方专用数值分数的情况下提供摘录。事件搜索分页结果还携带来自与命中相同索引世代的克隆目标 header,使授权消费方可将策略绑定到此次载荷观察。搜索请求只接受事件元数据过滤器,因为字面文本过滤使用上文所述扫描路径。
-该包没有提供方协调器、回退实现或独立具体插件。具体服务后端继承已实现的读取、过滤和跟踪,同时负责全文观察、对账、排名、游标世代和查询执行;第一个实现是 [`@deepseek-ai/dsh-session-query-sqlite`](../session-query-sqlite/README.md)。
+该包没有提供方协调器、回退实现或独立具体插件。具体服务后端继承已实现的读取、过滤和跟踪,同时负责全文观察、对账、排名、游标世代和查询执行;第一个实现是 [`@deepseek-ai/dsh-session-query-sqlite`](../session-query-sqlite/README.zh.md)。
`SessionQueryError.code` 是一个封闭联合,覆盖请求验证、缺失目标、格式错误的表层、来源冲突、持久化/索引失败、取消,以及无效或陈旧游标;精确字面值在 [`src/config.ts`](src/config.ts) 中定义。
@@ -53,4 +53,4 @@
## 已知限制与暂缓事项
- **无调用方授权**:这是上下文范围内的可信基础设施;未来的模型工具或 UI 必须限制调用方可检查的会话。
-- **无注册表或面向模型工具**:尚未提供提取器和搜索提供方注册表、递归遍历所引用的源事件的能力,以及面向模型的工具。[跟踪决策](../../../.agents/notes/implemented/feature/2026-07-13-session-query-tracing.md) 负责关系语义;SQLite 归属和 tokenizer 决策位于[已实现搜索记录](../../../.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md)。
+- **无注册表或面向模型工具**:尚未提供提取器和搜索提供方注册表、递归遍历所引用的源事件的能力,以及面向模型的工具。[跟踪决策](../../../.agents/notes/implemented/feature/2026-07-13-session-query-tracing.zh.md) 负责关系语义;SQLite 归属和 tokenizer 决策位于[已实现搜索记录](../../../.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.zh.md)。
diff --git a/packages/session-query/session-query/package.json b/packages/session-query/session-query/package.json
index 8a2f49c053..06863b8a37 100644
--- a/packages/session-query/session-query/package.json
+++ b/packages/session-query/session-query/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-session-query",
"description": "Combined session query service contract with concrete reads, traces, and filters",
- "version": "0.1.0-rc.7",
+ "version": "0.1.0-rc.8",
"publishConfig": {
"access": "public"
},
diff --git a/packages/session-query/tool-session-query/README.i18n.yaml b/packages/session-query/tool-session-query/README.i18n.yaml
index 4bc40ec6a2..33ba5b3460 100644
--- a/packages/session-query/tool-session-query/README.i18n.yaml
+++ b/packages/session-query/tool-session-query/README.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session-query/tool-session-query/README.md
README.md: d973daf1124c4be05f7335b18661d431d45be39f
-README.zh.md: 18ff18520ba8bb9d3d3dba5091d6281a0a47b78a
+README.zh.md: 71c1c74addf8cb2b29773933a36f3368b1c81bf5
diff --git a/packages/session-query/tool-session-query/README.zh.md b/packages/session-query/tool-session-query/README.zh.md
index 18ff18520b..71c1c74add 100644
--- a/packages/session-query/tool-session-query/README.zh.md
+++ b/packages/session-query/tool-session-query/README.zh.md
@@ -45,7 +45,7 @@ Use session_search to find relevant work from prior sessions, or session_event_s
#### 模型看到的内容
-模型会看到生成的 [`session_search`、`session_event_search`、`session_trace`、`session_event_trace` 和 `session_event_read` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-session-query)。搜索过滤器会增加固定 schema token,而游标、工作区路径、输出分页和模型可控结果上限仍不存在。
+模型会看到生成的 [`session_search`、`session_event_search`、`session_trace`、`session_event_trace` 和 `session_event_read` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-session-query)。搜索过滤器会增加固定 schema token,而游标、工作区路径、输出分页和模型可控结果上限仍不存在。
#### Token 影响
diff --git a/packages/session-query/tool-session-query/package.json b/packages/session-query/tool-session-query/package.json
index 8c069a5907..e271fd1645 100644
--- a/packages/session-query/tool-session-query/package.json
+++ b/packages/session-query/tool-session-query/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-tool-session-query",
"description": "Workspace-authorized model-facing session history search, trace, and event read tools",
- "version": "0.1.0-rc.7",
+ "version": "0.1.0-rc.8",
"publishConfig": {
"access": "public"
},
diff --git a/packages/session/README.i18n.yaml b/packages/session/README.i18n.yaml
index 2939d73342..69cbe17e10 100644
--- a/packages/session/README.i18n.yaml
+++ b/packages/session/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/session/README.md
-README.md: 2680bc3c6aea519427da2f31ab2526b5115cac96
-README.zh.md: b446a6a1d17da39c2a3d29c10fbb8dddf573bf80
+README.md: 78fbc0ab6f0a3b9a77c9e3b1a538dd227c532296
+README.zh.md: e1c04337931a42a1751edb2d3d616b4b53d2af1b
diff --git a/packages/session/README.md b/packages/session/README.md
index 2680bc3c6a..78fbc0ab6f 100644
--- a/packages/session/README.md
+++ b/packages/session/README.md
@@ -13,7 +13,7 @@ Durable session persistence, semantic checkpoint policy, and the shipped storage
| [`session-persistence/`](session-persistence/README.md) | Defines the persistence service and shared write coordination | `ctx.sessionPersistence` |
| [`session-checkpoint-policy/`](session-checkpoint-policy/README.md) | Applies semantic durability checkpoints | wraps `ctx.llm` and `ctx.tools` |
| [`session-persistence-jsonl/`](session-persistence-jsonl/README.md) | Persists sessions in JSONL files | registers on `ctx.sessionPersistence` |
-| [`session-persistence-sqlite/`](session-persistence-sqlite/README.md) | Persists sessions in SQLite | registers on `ctx.sessionPersistence` |
+| [`session-persistence-sqlite/`](session-persistence-sqlite/README.md) | Opt-in SQLite backend with packed physical chunk rows | registers on `ctx.sessionPersistence` |
The [session-persistence decision](../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md) records the persistence design.
diff --git a/packages/session/README.zh.md b/packages/session/README.zh.md
index b446a6a1d1..e1c0433793 100644
--- a/packages/session/README.zh.md
+++ b/packages/session/README.zh.md
@@ -10,12 +10,12 @@
| 包 | 职责 | ctx 键 |
|---|---|---|
-| [`session-persistence/`](session-persistence/README.md) | 定义持久化服务和共享写入协调机制 | `ctx.sessionPersistence` |
-| [`session-checkpoint-policy/`](session-checkpoint-policy/README.md) | 应用语义持久性检查点 | 包装 `ctx.llm` 和 `ctx.tools` |
-| [`session-persistence-jsonl/`](session-persistence-jsonl/README.md) | 将会话持久化到 JSONL 文件 | 注册到 `ctx.sessionPersistence` |
-| [`session-persistence-sqlite/`](session-persistence-sqlite/README.md) | 将会话持久化到 SQLite | 注册到 `ctx.sessionPersistence` |
+| [`session-persistence/`](session-persistence/README.zh.md) | 定义持久化服务和共享写入协调机制 | `ctx.sessionPersistence` |
+| [`session-checkpoint-policy/`](session-checkpoint-policy/README.zh.md) | 应用语义持久性检查点 | 包装 `ctx.llm` 和 `ctx.tools` |
+| [`session-persistence-jsonl/`](session-persistence-jsonl/README.zh.md) | 将会话持久化到 JSONL 文件 | 注册到 `ctx.sessionPersistence` |
+| [`session-persistence-sqlite/`](session-persistence-sqlite/README.zh.md) | 使用物理分片打包行的可选 SQLite 后端 | 注册到 `ctx.sessionPersistence` |
-[会话持久化决策](../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)记录了持久化设计。
+[会话持久化决策](../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md)记录了持久化设计。
## 投影
@@ -23,9 +23,9 @@
| 包 | 职责 | ctx 键 |
|---|---|---|
-| [`session-projection/`](session-projection/README.md) | 定义并驱动会话投影单元 | `ctx.sessionProjections` |
-| [`session-projection-cache/`](session-projection-cache/README.md) | 持久化并恢复投影检查点 | `ctx.sessionProjectionCache` |
-| [`session-stats/`](session-stats/README.md) | 提供全日志会话计数与墙钟时间(`sessionStats` 单元) | 注册到 `ctx.sessionProjections` |
+| [`session-projection/`](session-projection/README.zh.md) | 定义并驱动会话投影单元 | `ctx.sessionProjections` |
+| [`session-projection-cache/`](session-projection-cache/README.zh.md) | 持久化并恢复投影检查点 | `ctx.sessionProjectionCache` |
+| [`session-stats/`](session-stats/README.zh.md) | 提供全日志会话计数与墙钟时间(`sessionStats` 单元) | 注册到 `ctx.sessionProjections` |
## 标题
@@ -33,20 +33,20 @@
| 包 | 职责 | ctx 键 |
|---|---|---|
-| [`session-title/`](session-title/README.md) | 负责标题状态、回退行为、提供方注册与刷新 | `ctx.sessionTitle` |
-| [`session-title-llm/`](session-title-llm/README.md) | 提供共享的模型标题生成能力 | — |
-| [`session-title-first-prompt-llm/`](session-title-first-prompt-llm/README.md) | 根据第一条合格的人类消息生成会话标题 | 注册到 `ctx.sessionTitle` |
-| [`session-title-all-prompts-llm/`](session-title-all-prompts-llm/README.md) | 根据所有合格的人类消息生成会话标题 | 注册到 `ctx.sessionTitle` |
+| [`session-title/`](session-title/README.zh.md) | 负责标题状态、回退行为、提供方注册与刷新 | `ctx.sessionTitle` |
+| [`session-title-llm/`](session-title-llm/README.zh.md) | 提供共享的模型标题生成能力 | — |
+| [`session-title-first-prompt-llm/`](session-title-first-prompt-llm/README.zh.md) | 根据第一条合格的人类消息生成会话标题 | 注册到 `ctx.sessionTitle` |
+| [`session-title-all-prompts-llm/`](session-title-all-prompts-llm/README.zh.md) | 根据所有合格的人类消息生成会话标题 | 注册到 `ctx.sessionTitle` |
部署可以注册一个模型驱动提供方;未注册时,服务仍保留确定性回退机制。
## 遥测
-将会话活动投影为外发遥测,并将投递委派给配置的上报后端。[遥测决策](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)记录上报边界;[模式决策](../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md)记录即时、反馈门控与禁用投递。
+将会话活动投影为外发遥测,并将投递委派给配置的上报后端。[遥测决策](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md)记录上报边界;[模式决策](../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md)记录即时、反馈门控与禁用投递。
| 包 | 职责 |
|---|---|
-| [`session-telemetry/`](session-telemetry/README.md) | 定义捕获、脱敏、投影,以及实时或按需后端投递。 |
-| [`session-telemetry-otel/`](session-telemetry-otel/README.md) | 通过 OpenTelemetry 日志以 `FULL`、`FEEDBACK_ONLY` 或 `DISABLED` 模式投递遥测。 |
+| [`session-telemetry/`](session-telemetry/README.zh.md) | 定义捕获、脱敏、投影,以及实时或按需后端投递。 |
+| [`session-telemetry-otel/`](session-telemetry-otel/README.zh.md) | 通过 OpenTelemetry 日志以 `FULL`、`FEEDBACK_ONLY` 或 `DISABLED` 模式投递遥测。 |
-子系统参考:[persistence.md](../../docs/subsystems/persistence.md)、[session-projection.md](../../docs/subsystems/session-projection.md)、[session-title.md](../../docs/subsystems/session-title.md) 与 [session-telemetry.md](../../docs/subsystems/session-telemetry.md)。同一时间只允许一个标题提供方注册;demo 主干挂载回退服务,两个模型提供方都留在默认组合之外。
+子系统参考:[persistence.md](../../docs/subsystems/persistence.zh.md)、[session-projection.md](../../docs/subsystems/session-projection.zh.md)、[session-title.md](../../docs/subsystems/session-title.zh.md) 与 [session-telemetry.md](../../docs/subsystems/session-telemetry.zh.md)。同一时间只允许一个标题提供方注册;demo 主干挂载回退服务,两个模型提供方都留在默认组合之外。
diff --git a/packages/session/session-checkpoint-policy/package.json b/packages/session/session-checkpoint-policy/package.json
index 1273bf3ce3..396669044d 100644
--- a/packages/session/session-checkpoint-policy/package.json
+++ b/packages/session/session-checkpoint-policy/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-session-checkpoint-policy",
"description": "Semantic session durability checkpoints before model requests and tool side effects",
- "version": "0.1.0-rc.7",
+ "version": "0.1.0-rc.8",
"publishConfig": {
"access": "public"
},
diff --git a/packages/session/session-persistence-jsonl/README.i18n.yaml b/packages/session/session-persistence-jsonl/README.i18n.yaml
index 2184f491c1..f9ff2ed252 100644
--- a/packages/session/session-persistence-jsonl/README.i18n.yaml
+++ b/packages/session/session-persistence-jsonl/README.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session/session-persistence-jsonl/README.md
README.md: 4cff3215cdb083d2fdb7c4a8f1b60e8c4028ba84
-README.zh.md: 38b41cb9f7fa0f144923feced06152a185254c0a
+README.zh.md: 1675f14a6b9fd2fedf50cca0484aff68ab06f975
diff --git a/packages/session/session-persistence-jsonl/README.zh.md b/packages/session/session-persistence-jsonl/README.zh.md
index 38b41cb9f7..1675f14a6b 100644
--- a/packages/session/session-persistence-jsonl/README.zh.md
+++ b/packages/session/session-persistence-jsonl/README.zh.md
@@ -16,7 +16,7 @@ JSONL 持久会话存储后端:`SessionPersistence` 的一个具体实现(`d
- 第一个逻辑行是不可变的 `SessionHeader`,标记为 `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, origin?, delegationDepth, agentPreset? }`。`delegationDepth` 在磁盘上必需,顶层会话为 `0`;缺失或无效值会拒绝日志。`agentPreset` 必须持久化,因为它决定了被恢复会话的工具与提示词——恢复成另一套组装,就会回放模型已无法据以行动的历史。后续每个逻辑行是一条存储记录;`assistant/chunk` 事件绝不丢弃,且 `seq` 在解码日志中保持连续(`events[i].seq === i`)。
- 存储记录是原样 `SessionEvent` JSON,或在 `packChunks` 已启用且连续段符合条件时写入的**打包分片行**(`text-chunks` / `reasoning-chunks` / `tool-call-chunks`;像 header 的 `session` 一样不带斜杠,因此行 tag 不会与事件类型混淆):一行保存至少 3 个连续同 block `assistant/chunk` delta 事件,`seq0`/`time0` 和各成员的 `dt` 间隔精确重建每个成员的 `seq`/`time`。无损 codec 位于 `@deepseek-ai/dsh-session`(`packChunkRuns`/`decodeStorageRecord`),并使用精确形态 allowlist:任何未识别内容原样存储。读取与布局无关:`load` 始终解码行,因此打包、非打包和混合文件加载结果一致。
-- 项目目录保留规范化 cwd 的可读形式,便于导航,并限制在文件系统组件上限内。分隔符替换和截断刻意有损,因此规范化相同的 cwd 字符串共享项目目录;会话 id 仍选择不同会话目录。在不区分大小写的文件系统上,只有文件系统规范化将两种写法解析到同一 transcript(文本记录)时,身份验证才接受备选路径写法。配置根仍由部署控制:可以是项目本地、共享、临时或集中式。[项目会话目录决策](../../../.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md) 记录这项取舍。
+- 项目目录保留规范化 cwd 的可读形式,便于导航,并限制在文件系统组件上限内。分隔符替换和截断刻意有损,因此规范化相同的 cwd 字符串共享项目目录;会话 id 仍选择不同会话目录。在不区分大小写的文件系统上,只有文件系统规范化将两种写法解析到同一 transcript(文本记录)时,身份验证才接受备选路径写法。配置根仍由部署控制:可以是项目本地、共享、临时或集中式。[项目会话目录决策](../../../.agents/notes/implemented/architecture/2026-07-24-project-session-directories.zh.md) 记录这项取舍。
- 会话 id 是未验证的带品牌类型的字符串,因此在使用前单射转义为一个安全路径段(无遍历、无冲突)。结果目录保留给其他会话自有产物;发现只读取固定 transcript 文件名。
## 配置
@@ -33,7 +33,7 @@ JSONL 持久会话存储后端:`SessionPersistence` 的一个具体实现(`d
## 物理编码
-默认产物是独立 [Zstandard frame](../../../.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md) 的标准拼接:一个仅包含 header 行的带 checksum frame,后跟每个持久 append 批次一个带 checksum frame。后端使用 Node 内置 Zstandard API 和默认压缩级别,不提供级别开关。列表只读取并验证 header frame。`compression: 'none'` 在原始表示中保留相同逻辑行。
+默认产物是独立 [Zstandard frame](../../../.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.zh.md) 的标准拼接:一个仅包含 header 行的带 checksum frame,后跟每个持久 append 批次一个带 checksum frame。后端使用 Node 内置 Zstandard API 和默认压缩级别,不提供级别开关。列表只读取并验证 header frame。`compression: 'none'` 在原始表示中保留相同逻辑行。
一个根只属于一种编码。启动发现和定向查找会拒绝相反 suffix,错误会命名不兼容产物,并指示调用方选择匹配 mode 或独立根。平铺 `/.jsonl*` 产物也会被拒绝,而不是忽略。不提供迁移、混合根回退或双写。
@@ -42,7 +42,7 @@ JSONL 持久会话存储后端:`SessionPersistence` 的一个具体实现(`d
- **绑定存储身份。** 查找要求可读项目目录中只有一个匹配会话目录,然后验证 header id 等于请求 id,且 header id/cwd 派生所选 transcript 路径。列表应用同一路径检查,并拒绝重复 id。身份失败发生在修复或 append 前。
- **延迟实体化。**`create(meta)` 不写入;第一次 `append` 将编码 header 和第一批写入临时文件并执行 `fsync`。POSIX 通过硬链接无覆盖发布,并对父目录 `fsync`。Windows 通过 `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` 无覆盖发布,并通过同一 write-through pattern 创建缺失目录。已创建但从未 append 的会话不留下磁盘内容,不在 `list` 中。
- **仅追加。** 已 flush 事件绝不重写。后续原始批次 append 行;压缩批次 append 一个 frame。两条路径都执行 `fsync`,并在捕获到写入或同步失败时回滚到之前字节长度。
-- **崩溃恢复:保留有效尾部工作。**`load` 验证每个完整压缩 frame,并扫描解压 JSONL。最后 frame 结构不完整时,读取器保留其完整解码记录,从 frame 开头截断,并使用共享[持久化约定](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md) 需要的合成工具、步骤和轮次 closer 重新编码这些记录。原始 mode 从第一个不完整行截断。已经存在却没有完整 header frame 的压缩工件、完整 frame 中的 checksum/解压失败,或位于最后已提交的 `turn/end` 处或之前的缺陷都属于损坏,会被拒绝。
+- **崩溃恢复:保留有效尾部工作。**`load` 验证每个完整压缩 frame,并扫描解压 JSONL。最后 frame 结构不完整时,读取器保留其完整解码记录,从 frame 开头截断,并使用共享[持久化约定](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md) 需要的合成工具、步骤和轮次 closer 重新编码这些记录。原始 mode 从第一个不完整行截断。已经存在却没有完整 header frame 的压缩工件、完整 frame 中的 checksum/解压失败,或位于最后已提交的 `turn/end` 处或之前的缺陷都属于损坏,会被拒绝。
- **非修改式检查。**`inspect()` 返回不可变、平衡的逻辑视图,并可在内存中合成恢复 closer,但不会截断不完整尾部或更改轻量修订。
- **连续 seq。**`append` 拒绝第一个 `seq` 不继续已存储日志的批次,并拒绝无法 JSON 序列化的 `event.data`,同时命名违规事件类型。
- **轻量修订。**`listSnapshots(signal?)` 使用 device、inode、size 和纳秒时间戳标识日志,避免解析完整日志;该标识会在 append、修复、替换或存储变更后改变。完整前缀读取要求读取字节前后的身份一致,`readStoredRevision()` 使用同一身份校验保留的 preparation,而不加载日志。快照列表通过产物发现原样转发该信号,并在每个 `stat` 前后检查取消;由于文件系统 `stat` 不可中断,取消会等待活动调用完成,然后在不启动另一次调用的情况下拒绝。
diff --git a/packages/session/session-persistence-jsonl/package.json b/packages/session/session-persistence-jsonl/package.json
index 0b27dfa1d3..4044e46022 100644
--- a/packages/session/session-persistence-jsonl/package.json
+++ b/packages/session/session-persistence-jsonl/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-session-persistence-jsonl",
"description": "JSONL durable session persistence backend for the DeepSeek Harness",
- "version": "0.1.0-rc.7",
+ "version": "0.1.0-rc.8",
"publishConfig": {
"access": "public"
},
diff --git a/packages/session/session-persistence-sqlite/README.i18n.yaml b/packages/session/session-persistence-sqlite/README.i18n.yaml
index 8fd46fa101..b331013806 100644
--- a/packages/session/session-persistence-sqlite/README.i18n.yaml
+++ b/packages/session/session-persistence-sqlite/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/session/session-persistence-sqlite/README.md
-README.md: ec42419a132a26c1f23ab99ab3da1db97a5483b0
-README.zh.md: 67c6bcfbec92f8f5b91150fb108c65a287906db9
+README.md: ba005d79771bcbc2c0c1632da77d694aa3a18c07
+README.zh.md: 96a396d237a8abf263c50c46c8c7b23054d6e7aa
diff --git a/packages/session/session-persistence-sqlite/README.md b/packages/session/session-persistence-sqlite/README.md
index ec42419a13..ba005d7977 100644
--- a/packages/session/session-persistence-sqlite/README.md
+++ b/packages/session/session-persistence-sqlite/README.md
@@ -2,40 +2,39 @@
English | [中文](README.zh.md)
-A SQLite durable session-persistence backend — a second `SessionPersistence` provider ([session persistence](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)) satisfying the same contract as `dsh-session-persistence-jsonl` (append-only, contiguous-seq, lazy materialization, interrupted-turn close on load), expressed over `node:sqlite` rows instead of file bytes.
+An opt-in SQLite `SessionPersistence` provider. It stores eligible `assistant/chunk` runs in packed physical rows, selectively Zstandard-compresses large payloads, and delta-encodes provenance sequences while restoring the exact logical `SessionEvent[]`. No shipped composition selects it; deployments mount this package explicitly and provide its database path.
-`locate(meta)` returns `undefined`: all sessions share one database, so there is no honest independent per-session transcript path.
+`locate(meta)` returns `undefined` because every session shares one database. The provider exposes no per-session raw artifact.
## Storage model
-Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data, source_event_seqs, surface_op)` — `data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). The two `TEXT` columns `source_event_seqs` and `surface_op` are nullable; they store the event's optional surface-metadata fields (see [session surface](../../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`), a per-materialization incarnation id, and a monotonic per-log revision live in a `sessions` row; `createdAt` is a non-negative safe integer stored in a strict `INTEGER` column. A singleton state row carries the immutable store id. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`list` reports exactly the sessions that have a row).
+Schema 17 keeps ordinary ROWID tables and the composite `events(session_id, seq)` primary-key index. Scalar rows store one logical event. Packed rows use `text-chunks`, `reasoning-chunks`, or `tool-call-chunks` as the physical `type`; `seq` and `time` identify the first represented event, and `data` holds the shared packed-chunk payload. Packed rows set `ignorable=0` as a physical discriminator and leave `source_event_seqs` and `surface_op` as `NULL`; scalar rows use `ignorable=1` only for logical ignorable events and `NULL` otherwise. A future ignorable logical event may therefore reuse a storage-tag name without being decoded as a packed row. These tags are storage records, not `SessionEventMap` members.
-The repository's Node range supports unflagged `node:sqlite`. The database enables foreign keys and uses the configured journal mode (`wal` by default; use a rollback mode where WAL shared-memory files are unsuitable). `PRAGMA application_id` identifies the canonical persistence database, and `PRAGMA user_version` stores its layout version. A fresh database must have no application identity or user-defined schema objects; initialization creates every table and stamps both pragmas in one transaction. Non-pristine unversioned databases, foreign application identities, and every non-current version reject before journal-mode mutation because this unreleased format has no migrations.
+Schema 17 owns its codec locally rather than importing another persistence format's mutable implementation. Only exact, consecutive same-block text, reasoning, or tool-call delta forms pack. Unknown fields, surface metadata, sequence gaps, incompatible block/call identity, and unsafe timestamps remain scalar. A packed row represents at most 1,024 events and at most 1 MiB of uncompressed UTF-8 `data`; longer runs are partitioned without changing logical events. Reads reconstruct every original sequence number, timestamp, token boundary, argument fragment, and payload before returning data to the persistence coordinator.
-On filesystems with POSIX modes, the backend requests mode `0700` for missing directories and exclusively creates a missing database with mode `0600` before SQLite opens it; the process umask may further restrict both. New WAL, shared-memory, and persistent rollback-journal sidecars receive the database's resulting owner-only mode. Existing directories, database files, and sidecars keep their modes; filesystem setup errors other than an existing database fail initialization. These defaults prevent incidental exposure through a permissive process umask, but do not protect database confidentiality or integrity when another principal can replace the database entry in its parent directory.
+Serialized `data` smaller than 4 KiB stays as SQLite `TEXT`. At or above that threshold, the writer uses Zstandard level 3 and stores a `BLOB` only when the frame is smaller than the original text; the reader decompresses it before UTF-8 validation and JSON parsing. `source_event_seqs` remains the complete ordered provenance array. Its first sequence is an unsigned varint and each subsequent sequence is a signed delta encoded with ZigZag varints, stored as a `BLOB`; no source is omitted or converted to a range.
-## Contract semantics over rows
+Each append holds `BEGIN IMMEDIATE`, validates the bounded physical tail, packs only the new durable batch, inserts those records, and increments the session revision once. Normal appends never delete or replace an earlier event row. The default 200 ms write-behind window therefore compresses high-frequency streams while the physical write volume stays proportional to newly durable batches rather than repeatedly rewriting a growing packed value. A storage-level logical-tail check rejects a stale writer before mutation.
-- **Append = a transaction.** `append` runs `BEGIN`/`COMMIT` around the batch: it materializes the `sessions` row (if still lazy) and INSERTs every event, asserting the contiguous-seq contract first (the first event's `seq` must equal the stored next-seq). A mid-batch failure (a UNIQUE violation on a duplicated seq) rolls back entirely, so the stored log and the in-memory cursor stay consistent. (`load()` already balanced the stored log, so `append` never has to repair a crash tail.)
-- **Lazy materialization.** `create()` records intent in memory only — no row is written until the first `append`. A created-but-never-appended session has no `sessions` row, so it is absent from `list()` (which reports exactly the sessions that have a row).
-- **Interrupted-turn close on load.** `load()` implements the shared [crash-recovery contract](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md): preserve the valid interrupted turn, append its synthetic closing events in one transaction, and remove only a torn tail row. Committed parse errors or sequence gaps make the session unloadable. Because recovery mutates stored rows, the next append starts from a balanced log and accurate cursor.
-- **Non-mutating inspection.** `inspect()` returns an immutable balanced logical view and may synthesize recovery closers in memory, without deleting a torn tail row, appending recovery rows, or changing the lightweight revision.
-- **Lightweight revisions.** `listSnapshots(signal?)` combines the immutable store and database-file identity, a per-materialization incarnation id, and a per-session counter incremented in each mutating transaction. A full-prefix read captures that revision and its event rows in one read transaction, while `readStoredRevision()` queries only the session row to validate retained preparations. This keeps unchanged observations stable without parsing event rows and distinguishes independent stores and recreated same-id logs. It checks cancellation before and after shared readiness and the synchronous metadata query; the query itself is non-preemptible.
+Full reads scan physical rows in first-logical-sequence order. A reverse pass finds the last valid `turn/end` without retaining decoded copies of every physical row; the forward pass decodes and validates one physical row at a time into the returned logical event array. `readFrom(id, fromSeq)` examines packed predecessors only within the maximum row span and anchors the suffix at the earliest one that may contain `fromSeq`; this includes an event range that starts inside a packed row, detects overlapping physical corruption, and does not parse unrelated earlier scalar rows. A malformed packed row is all-or-nothing: committed corruption rejects, while a torn final row is deleted from its physical base during mutating recovery. Repair re-reads the tail under the write lock and rejects a stale marker before deleting anything. Packed `data` that exceeds the schema byte limit rejects before JSON parsing.
+
+## Schema compatibility
+
+A pristine database initializes directly at schema 17. Older schemas, foreign application identities, non-pristine unversioned databases, and incompatible schema objects reject; this pre-release provider supplies no migration. Every statement and fixed pragma lives in a packaged `.sql` resource; values use SQLite parameters and runtime code never assembles query text.
## Configuration (schemastery)
```ts
interface Config {
- path: string // SQLite database file path, or ':memory:' for an in-process DB
- journalMode?: 'wal' | 'delete' | 'truncate' | 'persist' // journal_mode pragma; default 'wal'
- preparedSessionCacheSize?: number // positive integer; default 5
- writeBatchMaxDelayMs?: number // positive integer; default 200; maximum 2_147_483_647
+ path: string
+ journalMode?: 'wal' | 'delete' | 'truncate' | 'persist'
+ busyTimeoutMs?: number
+ preparedSessionCacheSize?: number
+ writeBatchMaxDelayMs?: number
}
```
-## Write path
-
-Like the JSONL backend, the plugin copies each frozen `session/event` into one controller per live session. The first pending event starts the configured fixed batching window, and later events join without resetting it. Expiry starts one transaction; events admitted during that write form a separately bounded follow-up batch. `session/flush` cancels the wait and drains current and pending batches. The controller persists a fork's seed once, keeps a write cursor so resume never re-appends stored events, and seeds live sessions on apply because HMR does not replay `session/created`. Dispose drains every retained controller before closing the database. Every event remains a separate SQLite row; batching only groups more INSERTs into one transaction and revision increment.
+`journalMode` defaults to `wal`, `busyTimeoutMs` defaults to `5,000`, `preparedSessionCacheSize` defaults to `5`, and `writeBatchMaxDelayMs` defaults to `200`. The timeout bounds each synchronous SQLite lock wait. Because SQLite may return `SQLITE_BUSY` immediately while changing journal mode, cold open yields between attempts and starts no further attempt after an open-relative retry cutoff. An in-progress synchronous SQLite call may finish after that cutoff. The provider disables trusted schemas and memory-mapped I/O on every connection, then reads both settings back. The selected journal mode is also read back and must match; in-memory databases explicitly accept SQLite's `memory` result. After selecting the journal, the provider pins `synchronous=FULL` and verifies it so SQLite build defaults cannot weaken committed-append durability. On POSIX, the database parent and file must be owned by the current user, the parent must not be group/world-writable, and the file must have no group/world permissions. Symbolic links and non-regular files reject. Windows also rejects symbolic links and non-regular files, but deployments remain responsible for restricting the directory and file ACLs to the harness user. Path and ownership failures reject plugin initialization. Node SQLite loads lazily on the first persistence operation; the import suppresses only Node 22's exact SQLite `ExperimentalWarning`. Store-identity and schema failures reject that operation before data is exposed or mutated.
## Model Experience
@@ -43,20 +42,22 @@ Like the JSONL backend, the plugin copies each frozen `session/event` into one c
#### What the model sees
-SQLite storage contributes no live prompt or schema. Loading restores the same surface history as JSONL and preserves prior headers for reconstruction; the new loop composes its current envelope. Recovery balances an assistant request without a durable call with `TOOL_NOT_STARTED`; a durable call without a result becomes `TOOL_OUTCOME_UNKNOWN`, which tells the model to retry only read-only or idempotent work and to verify possible side effects or ask the user. Row metadata and raw chunks are not messages.
+Nothing specific to SQLite. Resume restores the same logical events and derived messages as JSONL; physical packed tags never reach prompts, tools, replay, or live `session/event` delivery.
#### Token effect
-Zero live-request tokens. Resume restores retained history and pays the current envelope, plus the quoted repair result for each interrupted call.
+Zero live-request tokens. Resume pays only for the retained logical history and current request envelope.
#### KV Cache effect
-SQLite storage does not mutate live request prefixes. A resumed loop can reuse provider cache only when its reconstructed history, current envelope, and model route match; crash-repair results append.
+Physical packing does not mutate request prefixes. Provider cache reuse depends on the reconstructed history, current envelope, and model route exactly as with other persistence backends.
## Known Limitations and Deferred Work
-- **`DatabaseSync` is synchronous** — every append transaction blocks the event loop for its duration; acceptable for local stores, a throughput ceiling for busy multi-session servers.
-- **Write contention has no wait or retry policy** — the backend sets no busy timeout and retries no locked-database error, so another connection holding a write transaction makes the operation reject immediately.
-- **Only a pristine new database or the current owned `SCHEMA_VERSION` opens** — unversioned schema objects, foreign application identities, and every other schema version are rejected rather than migrated (unreleased software; no persisted user data to preserve).
-- **Nothing deletes stored sessions** — rows accumulate until removed externally (the seam has no deletion API; `ON DELETE CASCADE` is wired for such out-of-band cleanup).
-- **TODO:** this backend talks to `node:sqlite` directly. If a cordis database service (`cordis/db` / a `@cordisjs` SQL driver plugin) is adopted, route through that instead of holding a raw `DatabaseSync` here — the contract surface (`SessionPersistence`) would not change, only the storage driver.
+- **Interim SQLite-specific design** — This efficiency-focused implementation is informed by [morlay/session-persistence-rdb](https://github.com/morlay/session-persistence-rdb). A unified relational-database design with multiple backends and configurable schemas is deferred; neither schema stability nor migration support is guaranteed during pre-release development.
+- **Packing follows durable batch boundaries** — compatible runs split by the write-behind window or an explicit flush remain separate physical records; this avoids rewriting prior rows at the cost of a timing-dependent packing ratio.
+- **Synchronous compression** — Node's SQLite and Zstandard calls block the JavaScript thread; the 4 KiB threshold limits per-frame work for small records.
+- **`DatabaseSync` blocks the event loop** — physical row reduction does not make SQLite operations asynchronous.
+- **Busy waits block the event loop** — SQLite waits inside synchronous `DatabaseSync` calls; only a busy journal-mode transition yields between attempts, and the open-relative cutoff prevents another attempt rather than interrupting an active call.
+- **External SQL readers must understand physical tags** — supported consumers read through this provider rather than treating every `events.type` as a logical event type.
+- **No deletion or background historical compaction** — normal appends are insert-only.
diff --git a/packages/session/session-persistence-sqlite/README.zh.md b/packages/session/session-persistence-sqlite/README.zh.md
index 67c6bcfbec..96a396d237 100644
--- a/packages/session/session-persistence-sqlite/README.zh.md
+++ b/packages/session/session-persistence-sqlite/README.zh.md
@@ -2,61 +2,62 @@
[English](README.md) | 中文
-SQLite 持久会话存储后端:第二个 `SessionPersistence` 提供方(见[会话持久化](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)),满足与 `dsh-session-persistence-jsonl` 相同的约定(仅追加、连续 seq、延迟实体化、在 load 时关闭中断轮次),但用 `node:sqlite` 行而非文件字节表达。
+一个可选启用的 SQLite `SessionPersistence` 提供方。它将符合条件的 `assistant/chunk` 连续段存入打包后的物理行,对大型 payload 选择性应用 Zstandard 压缩,并对来源序列进行 delta 编码,同时恢复完全一致的逻辑 `SessionEvent[]`。随产品交付的组合均不选择它;部署方需显式挂载本包并提供数据库路径。
-`locate(meta)` 返回 `undefined`:所有会话共享一个数据库,因此不存在真实、独立的逐会话 transcript(文本记录)路径。
+`locate(meta)` 返回 `undefined`,因为所有会话共享同一个数据库。该提供方不暴露逐会话原始产物。
## 存储模型
-每个 `SessionEvent` 1:1 映射到 `events` 表中的一行 `(session_id, seq, type, time, data, source_event_seqs, surface_op)`;`data` 是作为 JSON 文本的事件 payload,因此行结构就是原始事件本身(包括 `assistant/chunk`,保持 `seq` 连续)。两个 `TEXT` 列 `source_event_seqs` 和 `surface_op` 可为空,存储事件可选接口元数据字段(见[会话接口](../../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md))。日志外元数据(`SessionHeader`)、每实体化 incarnation id 和每日志单调修订位于 `sessions` 行;`createdAt` 是存储在 strict `INTEGER` 列中的非负安全整数。单例状态行携带不可变存储 id。`sessions` 行只由第一次 `append` 写入,其存在性是延迟实体化信号(`list` 精确报告有行的会话)。
+Schema 17 保留普通 ROWID 表以及复合主键索引 `events(session_id, seq)`。标量行存储一个逻辑事件。打包行把 `text-chunks`、`reasoning-chunks` 或 `tool-call-chunks` 用作物理 `type`;`seq` 与 `time` 标识所表示的第一个事件,`data` 保存共享的分片打包 payload。打包行把 `ignorable=0` 用作物理判别值,并让 `source_event_seqs` 与 `surface_op` 保持 `NULL`;标量行仅在逻辑事件可忽略时使用 `ignorable=1`,否则使用 `NULL`。因此,未来的可忽略逻辑事件即使复用了某个存储标签名称,也不会被解码为打包行。这些标签属于存储记录,而不是 `SessionEventMap` 成员。
-仓库支持的 Node 范围可不加 flag 使用 `node:sqlite`。数据库启用外键,并使用已配置 journal mode(默认 `wal`;WAL 共享内存文件不适用时使用 rollback mode)。`PRAGMA application_id` 标识规范持久化数据库,`PRAGMA user_version` 存储布局版本。新数据库必须没有 application identity 或用户定义 schema 对象;初始化在一个事务中创建全部表并盖上两个 pragma。非 pristine 无版本数据库、外部 application identity 和所有非当前版本在 journal-mode 变更前均会被拒绝,因为该未发布格式无迁移。
+Schema 17 在本包内拥有 codec,不导入其他持久化格式中可变的实现。只有字段完全匹配、连续且属于同一分片块的文本、推理或工具调用 delta 才会打包。未知字段、surface 元数据、序列缺口、不兼容的块/调用身份以及不安全时间戳仍以标量行存储。一个打包行最多表示 1,024 个事件,未压缩 UTF-8 `data` 最多 1 MiB;更长的连续段会在不改变逻辑事件的前提下分割。读取会在向持久化协调器返回数据前,重建每个原始序列号、时间戳、token 边界、参数片段和 payload。
-在具有 POSIX mode 的文件系统上,后端为缺失目录请求 mode `0700`,并在 SQLite 打开前以 mode `0600` 排他创建缺失数据库;进程 umask 可进一步限制两者。新 WAL、共享内存和持久 rollback-journal sidecar 获得数据库最终的仅所有者 mode。现有目录、数据库文件和 sidecar 保留原 mode;除已存在数据库外的文件系统设置错误会使初始化失败。这些默认值防止宽松进程 umask 造成的意外暴露,但当其他 principal 能替换父目录中的数据库条目时,不保护数据库机密性或完整性。
+序列化后的 `data` 小于 4 KiB 时保持为 SQLite `TEXT`。达到或超过该阈值时,写入方会使用 Zstandard level 3,并且只在 frame 小于原文本的情况下存储 `BLOB`;读取方会先解压,再执行 UTF-8 校验和 JSON 解析。`source_event_seqs` 仍是完整且有序的来源数组。第一个序列使用无符号 varint,后续序列使用 ZigZag varint 编码的有符号差值,并存为 `BLOB`;不会省略任何来源,也不会把数组转换成范围。
-## 行上的约定语义
+每次追加持有 `BEGIN IMMEDIATE`,验证有界物理尾部,只打包新的持久批次,插入这些记录,并把会话 revision 递增一次。普通追加绝不删除或替换既有事件行。默认 200 毫秒写后缓冲窗口因此仍能压缩高频流,而物理写入量与新增持久批次成正比,不会反复改写不断增长的打包值。存储层逻辑尾部检查会在陈旧写入方执行变更前拒绝该写入。
-- **Append = 事务。**`append` 围绕批次运行 `BEGIN`/`COMMIT`:它实体化 `sessions` 行(如果仍未实体化),并 INSERT 每个事件,首先断言连续 seq 约定(第一个事件 `seq` 必须等于已存储 next-seq)。批次中失败(重复 seq 上的 UNIQUE 违规)会完全回滚,使已存储日志和内存游标保持一致。(`load()` 已平衡已存储日志,因此 `append` 不必修复崩溃尾部。)
-- **延迟实体化。**`create()` 只在内存记录意图,第一次 `append` 前不写行。已创建但从未 append 的会话没有 `sessions` 行,因此不在 `list()` 中(它精确报告有行的会话)。
-- **在 load 时关闭中断轮次。**`load()` 实现共享[崩溃恢复约定](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md):保留有效中断轮次,在一个事务中追加合成关闭事件,并只移除撕裂尾部行。已提交解析错误或序列缺口使会话无法加载。恢复会变更已存储行,因此下一次 append 从平衡日志和准确游标开始。
-- **非修改式检查。**`inspect()` 返回不可变、平衡的逻辑视图,并可在内存中合成恢复 closer,但不会删除撕裂尾部行、追加恢复行或更改轻量修订。
-- **轻量修订。**`listSnapshots(signal?)` 组合不可变存储与数据库文件身份、每实体化 incarnation id,以及在每个变更事务中递增的每会话计数器。完整前缀读取在同一个读事务中捕获该 revision 及其事件行,`readStoredRevision()` 则只查询 session 行来校验保留的 preparation。它在不解析事件行的情况下保持未变观察稳定,并区分独立存储和重建的同 id 日志。它在共享就绪和同步元数据查询前后检查取消;查询本身不可抢占。
+完整读取按首个逻辑序列号的顺序扫描物理行。反向扫描会定位最后一个有效 `turn/end`,但不会保留每个物理行的解码副本;正向扫描则逐行解码并校验,写入最终返回的逻辑事件数组。`readFrom(id, fromSeq)` 只检查最大行跨度内的打包前驱,并把后缀锚定在可能包含 `fromSeq` 的最早前驱;这样既可包含从打包行内部开始的事件范围,也能检测相互重叠的物理损坏,而不会解析无关的更早标量行。畸形打包行按全有或全无处理:已提交区域中的损坏会拒绝读取,最终撕裂行则在可变恢复期间从其物理起点删除。修复会在持有写锁时重新读取尾部,并在删除任何数据前拒绝陈旧 marker。打包 `data` 超出 schema 字节上限时,会在解析 JSON 前拒绝。
+
+## Schema 兼容性
+
+全新数据库直接初始化为 schema 17。旧 schema、外部 application identity、非空未版本化数据库以及不兼容 schema 对象都会被拒绝;这个预发布提供方不提供迁移。每条语句和固定 pragma 都位于随包发布的 `.sql` 资源中;值使用 SQLite 参数,运行时代码不会拼装查询文本。
## 配置(schemastery)
```ts
interface Config {
- path: string // SQLite database file path, or ':memory:' for an in-process DB
- journalMode?: 'wal' | 'delete' | 'truncate' | 'persist' // journal_mode pragma; default 'wal'
- preparedSessionCacheSize?: number // positive integer; default 5
- writeBatchMaxDelayMs?: number // positive integer; default 200; maximum 2_147_483_647
+ path: string
+ journalMode?: 'wal' | 'delete' | 'truncate' | 'persist'
+ busyTimeoutMs?: number
+ preparedSessionCacheSize?: number
+ writeBatchMaxDelayMs?: number
}
```
-## 写入路径
-
-与 JSONL 后端一样,插件将每个冻结的 `session/event` 复制到对应活动会话的 controller 中,每个活动会话各有一个 controller。第一个待处理事件会开启配置的固定批处理窗口,后续事件会加入但不会重置截止时间。窗口到期后会启动一个事务;该次写入期间接纳的事件会形成另一个独立有界的后续批次。`session/flush` 会取消等待并排空当前与待处理批次。Controller 会持久化一次 fork 种子,并保留写入游标,使恢复操作绝不重新 append 已存储事件;它还会在 apply 时为活动会话设置初始状态,因为 HMR(热模块替换)不回放 `session/created`。dispose(资源释放)会在关闭数据库前排空每个保留的 controller。每个事件仍各占一行 SQLite 记录;批处理只把更多 INSERT 归入同一个事务和同一次修订版本递增。
+`journalMode` 默认为 `wal`,`busyTimeoutMs` 默认为 `5,000`,`preparedSessionCacheSize` 默认为 `5`,`writeBatchMaxDelayMs` 默认为 `200`。该超时限制每次同步 SQLite 锁等待的时长。SQLite 在切换 journal mode 时可能立即返回 `SQLITE_BUSY`,因此冷打开会在尝试之间让出执行,并在从打开时开始计算的重试截止点后不再发起新尝试。正在执行的同步 SQLite 调用可能在该截止点之后才完成。提供方会在每个连接上禁用可信 schema 与内存映射 I/O,然后读回这两项设置。提供方还会读回所选 journal mode 并要求它匹配;内存数据库显式接受 SQLite 返回的 `memory`。选择 journal 后,提供方会把 `synchronous` 固定为 `FULL` 并验证该设置,避免 SQLite 构建默认值削弱已提交追加的持久性。在 POSIX 上,数据库父目录和文件必须归当前用户所有,父目录不得允许组或其他用户写入,文件不得授予组或其他用户任何权限。符号链接和非普通文件会被拒绝。Windows 同样拒绝符号链接与非普通文件,但部署方仍负责把目录和文件 ACL 限制给 harness 用户。路径与所有权错误会拒绝插件初始化。Node SQLite 在第一次持久化操作时才加载;导入时只抑制 Node 22 精确的 SQLite `ExperimentalWarning`。存储身份与 schema 错误会在暴露或变更数据前拒绝该操作。
## 模型体验
### 恢复的对话历史
-#### 模型看到的内容
+#### 模型看到什么
-SQLite 存储不会向当前请求提供提示词或 schema。加载会恢复与 JSONL 相同的呈现历史,并保留之前的 header 用于重建;新 loop 组合当前 envelope。恢复会用 `TOOL_NOT_STARTED` 平衡没有已持久化调用的 assistant 请求;已有持久化调用但无结果时则变为 `TOOL_OUTCOME_UNKNOWN`,它要求模型只重试只读或幂等工作,并验证可能的副作用或询问用户。行元数据和原始分片不会成为消息。
+没有 SQLite 特有内容。恢复得到与 JSONL 相同的逻辑事件和派生消息;物理打包标签绝不会进入 prompt、工具、回放或实时 `session/event` 投递。
#### Token 影响
-SQLite 存储不会增加当前请求的 token 用量。恢复会还原已保留的历史,并产生当前 envelope 以及每个中断调用所附、以引用形式呈现的修复结果文本所产生的 token 开销。
+实时请求增加零 token。恢复只为保留的逻辑历史和当前请求 envelope 付出 token。
#### KV Cache 影响
-SQLite 存储不修改当前请求前缀。只有重建历史、当前 envelope 和模型路由匹配时,恢复 loop 才能重用提供方缓存;崩溃修复结果会追加到末尾。
+物理打包不会改变请求前缀。与其他持久化后端相同,提供方 cache 复用取决于重建历史、当前 envelope 和模型路由。
-## 已知限制与暂缓事项
+## 已知限制与延期工作
-- **`DatabaseSync` 是同步的**:每个 append 事务在整个期间阻塞事件循环;对本地存储可接受,对繁忙多会话服务器是吞吐上限。
-- **写入争用无等待或重试策略**:后端不设置 busy timeout,也不重试 locked-database 错误,因此其他连接持有写事务时操作立即拒绝。
-- **只有 pristine 新数据库或当前自有 `SCHEMA_VERSION` 才能打开**:无版本 schema 对象、外部 application identity 和所有其他 schema 版本被拒绝,而不是迁移(未发布软件,无持久用户数据需要保留)。
-- **不删除已存储会话**:行会累积,直到外部移除(seam 无删除接口;`ON DELETE CASCADE` 已为这种带外清理配置)。
-- **TODO:** 该后端直接调用 `node:sqlite`。如果采用 Cordis 数据库服务(`cordis/db` / `@cordisjs` SQL driver 插件),应改为通过该服务路由,而不在此直接持有 `DatabaseSync`;约定接口(`SessionPersistence`)不会变,只更换存储驱动。
+- **过渡性的 SQLite 专用设计**——这一以效率为重点的实现参考了 [morlay/session-persistence-rdb](https://github.com/morlay/session-persistence-rdb)。支持多种后端与可配置 schema 的统一关系数据库设计尚待后续完善;预发布开发阶段不保证 schema 稳定性或迁移支持。
+- **打包服从持久批次边界**——被写后缓冲窗口或显式 flush 分开的兼容连续段会保留为不同物理记录;这以打包率受时序影响为代价,避免改写既有行。
+- **同步压缩**——Node 的 SQLite 与 Zstandard 调用都会阻塞 JavaScript 线程;4 KiB 阈值限制了小型记录的逐 frame 工作。
+- **`DatabaseSync` 会阻塞事件循环**——减少物理行不会使 SQLite 操作变为异步。
+- **繁忙等待会阻塞事件循环**——SQLite 会在同步 `DatabaseSync` 调用内等待;只有繁忙的 journal-mode 切换会在两次尝试之间让出执行,而且从打开时计算的截止点只阻止新尝试,不会中断正在执行的调用。
+- **外部 SQL 读取方必须理解物理标签**——受支持的消费方通过本提供方读取,而不是把每个 `events.type` 都当作逻辑事件类型。
+- **没有删除或后台历史压缩**——普通追加只做插入。
diff --git a/packages/session/session-persistence-sqlite/package.json b/packages/session/session-persistence-sqlite/package.json
index 0335901faf..17f16fd5ca 100644
--- a/packages/session/session-persistence-sqlite/package.json
+++ b/packages/session/session-persistence-sqlite/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-session-persistence-sqlite",
- "description": "SQLite durable session persistence backend for the DeepSeek Harness",
- "version": "0.1.0-rc.7",
+ "description": "SQLite durable session persistence with physical chunk-row packing",
+ "version": "0.1.0-rc.8",
"publishConfig": {
"access": "public"
},
@@ -28,11 +28,13 @@
"files": [
"lib/index.js",
"lib/invariant.js",
+ "resources/sql/**/*.sql",
"lib/types/**/*.d.ts"
],
"license": "MIT",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
+ "@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
@@ -41,9 +43,13 @@
"@deepseek-ai/schemastery": "workspace:^"
},
"devDependencies": {
+ "@deepseek-ai/cordis-plugin-include": "workspace:^",
+ "@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
+ "@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
- "@deepseek-ai/cordis": "workspace:^"
+ "@deepseek-ai/cordis": "workspace:^",
+ "typescript": "^6.0.3"
}
}
diff --git a/packages/session/session-persistence-sqlite/resources/sql/begin-immediate.sql b/packages/session/session-persistence-sqlite/resources/sql/begin-immediate.sql
new file mode 100644
index 0000000000..67edb1dd06
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/begin-immediate.sql
@@ -0,0 +1 @@
+BEGIN IMMEDIATE;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/begin.sql b/packages/session/session-persistence-sqlite/resources/sql/begin.sql
new file mode 100644
index 0000000000..1775571fa7
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/begin.sql
@@ -0,0 +1 @@
+BEGIN;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/commit.sql b/packages/session/session-persistence-sqlite/resources/sql/commit.sql
new file mode 100644
index 0000000000..87ef767444
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/commit.sql
@@ -0,0 +1 @@
+COMMIT;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/delete-events-from.sql b/packages/session/session-persistence-sqlite/resources/sql/delete-events-from.sql
new file mode 100644
index 0000000000..ab5f81d89a
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/delete-events-from.sql
@@ -0,0 +1,2 @@
+DELETE FROM events
+WHERE session_id = ? AND seq >= ?;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/foreign-keys-on.sql b/packages/session/session-persistence-sqlite/resources/sql/foreign-keys-on.sql
new file mode 100644
index 0000000000..c8ccb1392a
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/foreign-keys-on.sql
@@ -0,0 +1 @@
+PRAGMA foreign_keys = ON;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/insert-event.sql b/packages/session/session-persistence-sqlite/resources/sql/insert-event.sql
new file mode 100644
index 0000000000..92b4de310d
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/insert-event.sql
@@ -0,0 +1,3 @@
+INSERT INTO events
+ (session_id, seq, type, time, data, source_event_seqs, surface_op, ignorable)
+VALUES (?, ?, ?, ?, ?, ?, ?, ?);
diff --git a/packages/session/session-persistence-sqlite/resources/sql/insert-persistence-state.sql b/packages/session/session-persistence-sqlite/resources/sql/insert-persistence-state.sql
new file mode 100644
index 0000000000..f516014d93
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/insert-persistence-state.sql
@@ -0,0 +1,2 @@
+INSERT INTO persistence_state (singleton, store_id)
+VALUES (1, ?);
diff --git a/packages/session/session-persistence-sqlite/resources/sql/journal-mode-delete.sql b/packages/session/session-persistence-sqlite/resources/sql/journal-mode-delete.sql
new file mode 100644
index 0000000000..0f4c37efca
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/journal-mode-delete.sql
@@ -0,0 +1 @@
+PRAGMA journal_mode = DELETE;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/journal-mode-persist.sql b/packages/session/session-persistence-sqlite/resources/sql/journal-mode-persist.sql
new file mode 100644
index 0000000000..02445b260b
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/journal-mode-persist.sql
@@ -0,0 +1 @@
+PRAGMA journal_mode = PERSIST;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/journal-mode-truncate.sql b/packages/session/session-persistence-sqlite/resources/sql/journal-mode-truncate.sql
new file mode 100644
index 0000000000..d119c32bec
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/journal-mode-truncate.sql
@@ -0,0 +1 @@
+PRAGMA journal_mode = TRUNCATE;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/journal-mode-wal.sql b/packages/session/session-persistence-sqlite/resources/sql/journal-mode-wal.sql
new file mode 100644
index 0000000000..2d30d8af9f
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/journal-mode-wal.sql
@@ -0,0 +1 @@
+PRAGMA journal_mode = WAL;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/mmap-off.sql b/packages/session/session-persistence-sqlite/resources/sql/mmap-off.sql
new file mode 100644
index 0000000000..22bcd0ea34
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/mmap-off.sql
@@ -0,0 +1 @@
+PRAGMA mmap_size = 0;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/rollback.sql b/packages/session/session-persistence-sqlite/resources/sql/rollback.sql
new file mode 100644
index 0000000000..3b18e77376
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/rollback.sql
@@ -0,0 +1 @@
+ROLLBACK;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/schema.sql b/packages/session/session-persistence-sqlite/resources/sql/schema.sql
new file mode 100644
index 0000000000..41d247dba7
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/schema.sql
@@ -0,0 +1,30 @@
+CREATE TABLE persistence_state (
+ singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
+ store_id TEXT NOT NULL
+) STRICT;
+
+CREATE TABLE sessions (
+ id TEXT PRIMARY KEY,
+ version INTEGER NOT NULL,
+ created_at INTEGER NOT NULL,
+ cwd TEXT,
+ parent_session TEXT,
+ seed_length INTEGER,
+ origin TEXT,
+ delegation_depth INTEGER,
+ agent_preset TEXT,
+ incarnation TEXT NOT NULL,
+ revision INTEGER NOT NULL
+) STRICT;
+
+CREATE TABLE events (
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
+ seq INTEGER NOT NULL,
+ type TEXT NOT NULL,
+ time INTEGER NOT NULL,
+ data ANY NOT NULL,
+ source_event_seqs ANY,
+ surface_op TEXT,
+ ignorable INTEGER CHECK (ignorable IS NULL OR ignorable IN (0, 1)),
+ PRIMARY KEY (session_id, seq)
+) STRICT;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-application-id.sql b/packages/session/session-persistence-sqlite/resources/sql/select-application-id.sql
new file mode 100644
index 0000000000..3de4abbb88
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/select-application-id.sql
@@ -0,0 +1 @@
+PRAGMA application_id;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-events-from.sql b/packages/session/session-persistence-sqlite/resources/sql/select-events-from.sql
new file mode 100644
index 0000000000..a5748dd974
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/select-events-from.sql
@@ -0,0 +1,4 @@
+SELECT seq, type, time, data, source_event_seqs, surface_op, ignorable
+FROM events
+WHERE session_id = ? AND seq >= ?
+ORDER BY seq;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-events.sql b/packages/session/session-persistence-sqlite/resources/sql/select-events.sql
new file mode 100644
index 0000000000..76437f8eaf
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/select-events.sql
@@ -0,0 +1,4 @@
+SELECT seq, type, time, data, source_event_seqs, surface_op, ignorable
+FROM events
+WHERE session_id = ?
+ORDER BY seq;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-mmap-size.sql b/packages/session/session-persistence-sqlite/resources/sql/select-mmap-size.sql
new file mode 100644
index 0000000000..58e55155f4
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/select-mmap-size.sql
@@ -0,0 +1 @@
+PRAGMA mmap_size;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-packed-predecessors.sql b/packages/session/session-persistence-sqlite/resources/sql/select-packed-predecessors.sql
new file mode 100644
index 0000000000..54a52180fc
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/select-packed-predecessors.sql
@@ -0,0 +1,6 @@
+SELECT seq, type, time, data, source_event_seqs, surface_op, ignorable
+FROM events
+WHERE session_id = ? AND seq >= ? AND seq < ?
+ AND type IN ('text-chunks', 'reasoning-chunks', 'tool-call-chunks')
+ AND ignorable = 0
+ORDER BY seq;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-schema-objects.sql b/packages/session/session-persistence-sqlite/resources/sql/select-schema-objects.sql
new file mode 100644
index 0000000000..216925f35a
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/select-schema-objects.sql
@@ -0,0 +1,4 @@
+SELECT type, name, tbl_name, sql
+FROM sqlite_schema
+WHERE name NOT GLOB 'sqlite_*'
+ORDER BY type, name;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-session.sql b/packages/session/session-persistence-sqlite/resources/sql/select-session.sql
new file mode 100644
index 0000000000..456cba91c3
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/select-session.sql
@@ -0,0 +1,4 @@
+SELECT id, version, created_at, cwd, parent_session, seed_length, origin,
+ delegation_depth, agent_preset, incarnation, revision
+FROM sessions
+WHERE id = ?;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-sessions.sql b/packages/session/session-persistence-sqlite/resources/sql/select-sessions.sql
new file mode 100644
index 0000000000..37a1f22e36
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/select-sessions.sql
@@ -0,0 +1,3 @@
+SELECT id, version, created_at, cwd, parent_session, seed_length, origin,
+ delegation_depth, agent_preset, incarnation, revision
+FROM sessions;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-store-id.sql b/packages/session/session-persistence-sqlite/resources/sql/select-store-id.sql
new file mode 100644
index 0000000000..168e09cb80
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/select-store-id.sql
@@ -0,0 +1,3 @@
+SELECT store_id
+FROM persistence_state
+WHERE singleton = 1;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-synchronous.sql b/packages/session/session-persistence-sqlite/resources/sql/select-synchronous.sql
new file mode 100644
index 0000000000..b41be01714
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/select-synchronous.sql
@@ -0,0 +1 @@
+PRAGMA synchronous;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-tail-events.sql b/packages/session/session-persistence-sqlite/resources/sql/select-tail-events.sql
new file mode 100644
index 0000000000..2d958de7c3
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/select-tail-events.sql
@@ -0,0 +1,5 @@
+SELECT seq, type, time, data, source_event_seqs, surface_op, ignorable
+FROM events
+WHERE session_id = ?
+ORDER BY seq DESC
+LIMIT ?;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-trusted-schema.sql b/packages/session/session-persistence-sqlite/resources/sql/select-trusted-schema.sql
new file mode 100644
index 0000000000..d304b1f850
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/select-trusted-schema.sql
@@ -0,0 +1 @@
+PRAGMA trusted_schema;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-user-object-count.sql b/packages/session/session-persistence-sqlite/resources/sql/select-user-object-count.sql
new file mode 100644
index 0000000000..60665bb66e
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/select-user-object-count.sql
@@ -0,0 +1,3 @@
+SELECT COUNT(*) AS count
+FROM sqlite_schema
+WHERE name NOT GLOB 'sqlite_*';
diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-user-version.sql b/packages/session/session-persistence-sqlite/resources/sql/select-user-version.sql
new file mode 100644
index 0000000000..4edeca1a4d
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/select-user-version.sql
@@ -0,0 +1 @@
+PRAGMA user_version;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/set-application-id.sql b/packages/session/session-persistence-sqlite/resources/sql/set-application-id.sql
new file mode 100644
index 0000000000..617d10cab3
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/set-application-id.sql
@@ -0,0 +1 @@
+PRAGMA application_id = 1146308688;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/set-user-version-17.sql b/packages/session/session-persistence-sqlite/resources/sql/set-user-version-17.sql
new file mode 100644
index 0000000000..5aac576e8c
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/set-user-version-17.sql
@@ -0,0 +1 @@
+PRAGMA user_version = 17;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/synchronous-full.sql b/packages/session/session-persistence-sqlite/resources/sql/synchronous-full.sql
new file mode 100644
index 0000000000..b0380b1197
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/synchronous-full.sql
@@ -0,0 +1 @@
+PRAGMA synchronous = FULL;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/trusted-schema-off.sql b/packages/session/session-persistence-sqlite/resources/sql/trusted-schema-off.sql
new file mode 100644
index 0000000000..973c6d1def
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/trusted-schema-off.sql
@@ -0,0 +1 @@
+PRAGMA trusted_schema = OFF;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/update-session-revision.sql b/packages/session/session-persistence-sqlite/resources/sql/update-session-revision.sql
new file mode 100644
index 0000000000..2cfbcb2b82
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/update-session-revision.sql
@@ -0,0 +1,3 @@
+UPDATE sessions
+SET revision = revision + 1
+WHERE id = ?;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/upsert-session.sql b/packages/session/session-persistence-sqlite/resources/sql/upsert-session.sql
new file mode 100644
index 0000000000..c1000e4daa
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/upsert-session.sql
@@ -0,0 +1,13 @@
+INSERT INTO sessions
+ (id, version, created_at, cwd, parent_session, seed_length, origin,
+ delegation_depth, agent_preset, incarnation, revision)
+VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
+ON CONFLICT(id) DO UPDATE SET
+ version = excluded.version,
+ created_at = excluded.created_at,
+ cwd = excluded.cwd,
+ parent_session = excluded.parent_session,
+ seed_length = excluded.seed_length,
+ origin = excluded.origin,
+ delegation_depth = excluded.delegation_depth,
+ agent_preset = excluded.agent_preset;
diff --git a/packages/session/session-persistence-sqlite/src/codec.ts b/packages/session/session-persistence-sqlite/src/codec.ts
new file mode 100644
index 0000000000..5ee2126c6e
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/src/codec.ts
@@ -0,0 +1,343 @@
+/**
+ * Schema-17 physical chunk-row codec. This package owns the durable tags,
+ * validation, and row-size limits independently from other persistence formats.
+ * @module @deepseek-ai/dsh-session-persistence-sqlite/codec
+ */
+
+import type { StreamChunk } from '@deepseek-ai/dsh-llm'
+import type { SessionEvent } from '@deepseek-ai/dsh-session'
+
+/* jscpd:ignore-start -- schema 17 deliberately owns a frozen physical codec;
+ * importing or sharing the JSONL codec would let that format mutate this database interpreter. */
+type DeltaKind = 'text-delta' | 'reasoning-delta' | 'tool-call-delta'
+type DeltaEvent = SessionEvent<'assistant/chunk'>
+
+interface RunDataBase {
+ readonly turn: number
+ readonly step: number
+ readonly index: number
+ readonly dt: number[]
+}
+
+interface TextRunData extends RunDataBase {
+ readonly texts: string[]
+}
+
+interface ToolCallRunData extends RunDataBase {
+ readonly id: Extract['id']
+ readonly name?: string
+ readonly args: string[]
+}
+
+/** One schema-17 packed physical record. */
+export type ChunkRow =
+ | { readonly type: 'text-chunks'; readonly seq0: number; readonly time0: number; readonly data: TextRunData }
+ | { readonly type: 'reasoning-chunks'; readonly seq0: number; readonly time0: number; readonly data: TextRunData }
+ | { readonly type: 'tool-call-chunks'; readonly seq0: number; readonly time0: number; readonly data: ToolCallRunData }
+
+/** One scalar event or schema-17 packed physical record. */
+export type StorageRecord = SessionEvent | ChunkRow
+
+/** Minimum eligible members in a packed physical record. */
+export const MIN_PACKED_ROW_MEMBERS = 3
+/** Maximum logical members represented by one packed physical record. */
+export const MAX_PACKED_ROW_MEMBERS = 1_024
+/** Maximum UTF-8 bytes in one packed physical record's data column. */
+export const MAX_PACKED_DATA_BYTES = 1_048_576
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null
+}
+
+function hasExactKeys(value: object, keys: readonly string[]): boolean {
+ return Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key))
+}
+
+function classify(event: SessionEvent): DeltaKind | undefined {
+ if (event.type !== 'assistant/chunk') return undefined
+ if (!hasExactKeys(event, ['type', 'seq', 'time', 'data'])) return undefined
+ if (!Number.isSafeInteger(event.seq) || event.seq < 0 || !Number.isSafeInteger(event.time)) return undefined
+ const data: unknown = event.data
+ if (!isRecord(data) || !hasExactKeys(data, ['turn', 'step', 'chunk'])) return undefined
+ if (typeof data.turn !== 'number' || typeof data.step !== 'number') return undefined
+ const chunk = data.chunk
+ if (!isRecord(chunk) || typeof chunk.index !== 'number') return undefined
+ switch (chunk.type) {
+ case 'text-delta':
+ case 'reasoning-delta':
+ return hasExactKeys(chunk, ['type', 'index', 'text']) && typeof chunk.text === 'string'
+ ? chunk.type
+ : undefined
+ case 'tool-call-delta': {
+ const validKeys = hasExactKeys(chunk, ['type', 'index', 'id', 'argumentsDelta'])
+ || (hasExactKeys(chunk, ['type', 'index', 'id', 'name', 'argumentsDelta'])
+ && typeof chunk.name === 'string')
+ return validKeys && typeof chunk.id === 'string' && typeof chunk.argumentsDelta === 'string'
+ ? chunk.type
+ : undefined
+ }
+ default:
+ return undefined
+ }
+}
+
+function toolCallOf(event: DeltaEvent): { readonly id: string; readonly name?: string } {
+ return event.data.chunk as { readonly id: string; readonly name?: string }
+}
+
+function indexOf(event: DeltaEvent): number {
+ return (event.data.chunk as { readonly index: number }).index
+}
+
+function continues(previous: DeltaEvent, next: DeltaEvent, kind: DeltaKind): boolean {
+ if (next.seq !== previous.seq + 1 || !Number.isSafeInteger(next.time - previous.time)) return false
+ if (next.data.turn !== previous.data.turn || next.data.step !== previous.data.step) return false
+ if (indexOf(next) !== indexOf(previous)) return false
+ if (kind !== 'tool-call-delta') return true
+ const left = toolCallOf(previous)
+ const right = toolCallOf(next)
+ return left.id === right.id
+ && Object.hasOwn(left, 'name') === Object.hasOwn(right, 'name')
+ && left.name === right.name
+}
+
+function buildRow(kind: DeltaKind, run: readonly DeltaEvent[]): ChunkRow {
+ const first = run[0] as DeltaEvent
+ const base = {
+ turn: first.data.turn,
+ step: first.data.step,
+ index: indexOf(first),
+ dt: run.slice(1).map((event, index) => event.time - (run[index] as DeltaEvent).time),
+ }
+ const envelope = { seq0: first.seq, time0: first.time }
+ if (kind === 'tool-call-delta') {
+ const call = toolCallOf(first)
+ return {
+ type: 'tool-call-chunks',
+ ...envelope,
+ data: {
+ ...base,
+ id: call.id as Extract['id'],
+ ...Object.hasOwn(call, 'name') ? { name: call.name as string } : {},
+ args: run.map(event => (event.data.chunk as { readonly argumentsDelta: string }).argumentsDelta),
+ },
+ }
+ }
+ const data = {
+ ...base,
+ texts: run.map(event => (event.data.chunk as { readonly text: string }).text),
+ }
+ return kind === 'text-delta'
+ ? { type: 'text-chunks', ...envelope, data }
+ : { type: 'reasoning-chunks', ...envelope, data }
+}
+
+function packedDataBytes(row: ChunkRow): number {
+ return Buffer.byteLength(JSON.stringify(row.data))
+}
+
+function emitBoundedRun(out: StorageRecord[], kind: DeltaKind, completeRun: readonly DeltaEvent[]): void {
+ let offset = 0
+ while (completeRun.length - offset >= MIN_PACKED_ROW_MEMBERS) {
+ let low = MIN_PACKED_ROW_MEMBERS
+ let high = Math.min(completeRun.length - offset, MAX_PACKED_ROW_MEMBERS)
+ const largest = buildRow(kind, completeRun.slice(offset, offset + high))
+ if (packedDataBytes(largest) <= MAX_PACKED_DATA_BYTES) {
+ out.push(largest)
+ offset += high
+ continue
+ }
+ high -= 1
+ let accepted = 0
+ let acceptedRow: ChunkRow | undefined
+ while (low <= high) {
+ const middle = Math.floor((low + high) / 2)
+ const candidate = buildRow(kind, completeRun.slice(offset, offset + middle))
+ if (packedDataBytes(candidate) <= MAX_PACKED_DATA_BYTES) {
+ accepted = middle
+ acceptedRow = candidate
+ low = middle + 1
+ } else {
+ high = middle - 1
+ }
+ }
+ if (accepted === 0) {
+ out.push(completeRun[offset] as DeltaEvent)
+ offset += 1
+ continue
+ }
+ /* v8 ignore next -- accepted is set only with its same-branch candidate. */
+ out.push(acceptedRow ?? malformed(kind, 'bounded encoder lost its accepted row'))
+ offset += accepted
+ }
+ out.push(...completeRun.slice(offset))
+}
+
+/**
+ * Pack eligible logical chunk runs into bounded schema-17 records.
+ * @param events - logical events in sequence order.
+ * @returns scalar and packed physical records in equivalent order.
+ */
+export function packChunkRuns(events: readonly SessionEvent[]): StorageRecord[] {
+ const out: StorageRecord[] = []
+ let kind: DeltaKind | undefined
+ let run: DeltaEvent[] = []
+ const flush = (): void => {
+ if (kind === undefined) out.push(...run)
+ else emitBoundedRun(out, kind, run)
+ kind = undefined
+ run = []
+ }
+ for (const event of events) {
+ const nextKind = classify(event)
+ if (nextKind === undefined) {
+ flush()
+ out.push(event)
+ continue
+ }
+ const delta = event as DeltaEvent
+ const previous = run.at(-1)
+ if (nextKind === kind && previous !== undefined && continues(previous, delta, nextKind)) {
+ run.push(delta)
+ continue
+ }
+ flush()
+ kind = nextKind
+ run = [delta]
+ }
+ flush()
+ return out
+}
+
+function malformed(tag: string, reason: string): never {
+ throw new Error(`malformed ${tag} storage row: ${reason}`)
+}
+
+function validateRunData(
+ tag: string,
+ data: Record,
+ payloadKey: 'texts' | 'args',
+ serializedBytes?: number,
+): string[] {
+ if (typeof data.turn !== 'number' || typeof data.step !== 'number' || typeof data.index !== 'number') {
+ malformed(tag, 'turn/step/index must be numbers')
+ }
+ const payload = data[payloadKey]
+ if (!Array.isArray(payload)
+ || payload.length < MIN_PACKED_ROW_MEMBERS
+ || payload.length > MAX_PACKED_ROW_MEMBERS
+ || payload.some(member => typeof member !== 'string')) {
+ malformed(tag, `${payloadKey} must contain ${MIN_PACKED_ROW_MEMBERS}..${MAX_PACKED_ROW_MEMBERS} strings`)
+ }
+ const gaps = data.dt
+ if (!Array.isArray(gaps) || gaps.some(gap => !Number.isSafeInteger(gap))) {
+ malformed(tag, 'dt must be an array of safe integers')
+ }
+ if (gaps.length !== payload.length - 1) malformed(tag, 'dt length must match the member count')
+ if ((serializedBytes ?? Buffer.byteLength(JSON.stringify(data))) > MAX_PACKED_DATA_BYTES) {
+ malformed(tag, `data exceeds ${MAX_PACKED_DATA_BYTES} UTF-8 bytes`)
+ }
+ return payload as string[]
+}
+
+function validateRow(
+ value: Record,
+ tag: ChunkRow['type'],
+ serializedBytes?: number,
+): ChunkRow {
+ if (!hasExactKeys(value, ['type', 'seq0', 'time0', 'data'])) malformed(tag, 'invalid envelope fields')
+ if (!Number.isSafeInteger(value.seq0) || (value.seq0 as number) < 0) malformed(tag, 'seq0 must be non-negative')
+ if (!Number.isSafeInteger(value.time0)) malformed(tag, 'time0 must be a safe integer')
+ const data = value.data
+ if (!isRecord(data)) malformed(tag, 'data must be an object')
+ let payload: string[]
+ if (tag === 'tool-call-chunks') {
+ const withName = hasExactKeys(data, ['turn', 'step', 'index', 'id', 'name', 'dt', 'args'])
+ if (!withName && !hasExactKeys(data, ['turn', 'step', 'index', 'id', 'dt', 'args'])) {
+ malformed(tag, 'invalid tool-call data fields')
+ }
+ if (typeof data.id !== 'string' || (withName && typeof data.name !== 'string')) {
+ malformed(tag, 'id and optional name must be strings')
+ }
+ payload = validateRunData(tag, data, 'args', serializedBytes)
+ } else {
+ if (!hasExactKeys(data, ['turn', 'step', 'index', 'dt', 'texts'])) malformed(tag, 'invalid text data fields')
+ payload = validateRunData(tag, data, 'texts', serializedBytes)
+ }
+ if (!Number.isSafeInteger((value.seq0 as number) + payload.length - 1)) malformed(tag, 'member seqs exceed safe integers')
+ let time = value.time0 as number
+ for (const gap of data.dt as number[]) {
+ time += gap
+ if (!Number.isSafeInteger(time)) malformed(tag, 'member times exceed safe integers')
+ }
+ return value as unknown as ChunkRow
+}
+
+function expandRow(row: ChunkRow): SessionEvent[] {
+ const members = row.type === 'tool-call-chunks' ? row.data.args : row.data.texts
+ const events: SessionEvent[] = []
+ let time = row.time0
+ for (let index = 0; index < members.length; index += 1) {
+ if (index > 0) time += row.data.dt[index - 1] as number
+ let chunk: StreamChunk
+ switch (row.type) {
+ case 'text-chunks':
+ chunk = { type: 'text-delta', index: row.data.index, text: members[index] as string }
+ break
+ case 'reasoning-chunks':
+ chunk = { type: 'reasoning-delta', index: row.data.index, text: members[index] as string }
+ break
+ case 'tool-call-chunks':
+ chunk = {
+ type: 'tool-call-delta',
+ index: row.data.index,
+ id: row.data.id,
+ ...Object.hasOwn(row.data, 'name') ? { name: row.data.name as string } : {},
+ argumentsDelta: members[index] as string,
+ }
+ break
+ }
+ events.push({
+ type: 'assistant/chunk',
+ seq: row.seq0 + index,
+ time,
+ data: { turn: row.data.turn, step: row.data.step, chunk },
+ })
+ }
+ return events
+}
+
+/**
+ * Decode one scalar or packed schema-17 record.
+ * @param value - parsed physical-record value.
+ * @returns the represented logical events.
+ */
+export function decodeStorageRecord(value: unknown): SessionEvent[] {
+ if (!isRecord(value)) return [value as SessionEvent]
+ const tag = value.type
+ if (tag !== 'text-chunks' && tag !== 'reasoning-chunks' && tag !== 'tool-call-chunks') {
+ return [value as SessionEvent]
+ }
+ return expandRow(validateRow(value, tag))
+}
+
+/**
+ * Decode one packed row from its exact uncompressed data value. The byte bound
+ * rejects oversized input before JSON parsing and avoids serializing it again.
+ * @param tag - validated packed physical type.
+ * @param seq0 - first represented logical sequence number.
+ * @param time0 - first represented logical timestamp.
+ * @param serializedData - decoded SQLite data-column text.
+ * @returns the represented logical events.
+ */
+export function decodeSerializedChunkRow(
+ tag: ChunkRow['type'],
+ seq0: number,
+ time0: number,
+ serializedData: string,
+): SessionEvent[] {
+ const bytes = Buffer.byteLength(serializedData)
+ if (bytes > MAX_PACKED_DATA_BYTES) malformed(tag, `data exceeds ${MAX_PACKED_DATA_BYTES} UTF-8 bytes`)
+ return expandRow(validateRow({ type: tag, seq0, time0, data: JSON.parse(serializedData) as unknown }, tag, bytes))
+}
+/* jscpd:ignore-end */
diff --git a/packages/session/session-persistence-sqlite/src/compression.ts b/packages/session/session-persistence-sqlite/src/compression.ts
new file mode 100644
index 0000000000..272b099a37
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/src/compression.ts
@@ -0,0 +1,276 @@
+/**
+ * Fixed physical-record compression for SQLite. Schema-owned functions
+ * encode logical events and decode tagged rows before persistence consumers
+ * observe them.
+ * @module @deepseek-ai/dsh-session-persistence-sqlite/compression
+ */
+
+import { TextDecoder } from 'node:util'
+import { constants, zstdCompressSync, zstdDecompressSync } from 'node:zlib'
+import type { SessionEvent, SurfaceEventType } from '@deepseek-ai/dsh-session'
+import {
+ decodeSerializedChunkRow,
+ type ChunkRow,
+ MAX_PACKED_DATA_BYTES,
+ type StorageRecord,
+} from './codec.ts'
+import type { EventRow } from './schema.ts'
+
+/** One physical row ready for SQLite parameter binding. */
+export interface BoundRecord {
+ readonly seq: number
+ readonly type: string
+ readonly time: number
+ readonly data: string | Uint8Array
+ readonly sourceEventSeqs: Uint8Array | null
+ readonly surfaceOp: string | null
+ readonly ignorable: number | null
+}
+
+/** Small values stay as SQLite text to avoid per-frame CPU and byte overhead. */
+export const ZSTD_DATA_THRESHOLD_BYTES = 4_096
+
+const MAX_SAFE_INTEGER = BigInt(Number.MAX_SAFE_INTEGER)
+const MAX_ZIGZAG_INTEGER = MAX_SAFE_INTEGER * 2n
+const UTF8_DECODER = new TextDecoder('utf-8', { fatal: true })
+const ZSTD_COMPRESSION_LEVEL = 3
+const PACKED_ROW_SENTINEL = 0
+
+const CHUNK_TAGS = ['text-chunks', 'reasoning-chunks', 'tool-call-chunks'] as const
+type ChunkTag = typeof CHUNK_TAGS[number]
+
+function isChunkTag(value: string): value is ChunkTag {
+ return (CHUNK_TAGS as readonly string[]).includes(value)
+}
+
+/**
+ * Decode one physical SQLite row into its complete logical event span.
+ * @param row - detached SQLite event row.
+ * @returns every logical event represented by the row.
+ */
+export function decodeRow(row: EventRow): SessionEvent[] {
+ if (row.ignorable !== PACKED_ROW_SENTINEL) return [decodeScalarRow(row)]
+ if (!isChunkTag(row.type)) {
+ throw new Error(`malformed ${row.type} storage row: packed discriminator requires a chunk tag`)
+ }
+ if (row.source_event_seqs !== null || row.surface_op !== null) {
+ throw new Error(`malformed ${row.type} storage row: packed surface fields must be null`)
+ }
+ return decodeSerializedChunkRow(
+ row.type,
+ row.seq,
+ row.time,
+ decodeData(row.data, MAX_PACKED_DATA_BYTES),
+ )
+}
+
+/**
+ * Convert a storage record to SQLite column values.
+ * @param record - scalar event or packed chunk record.
+ * @returns column values for one physical insert.
+ */
+export function bindRecord(record: StorageRecord): BoundRecord {
+ if (isChunkRow(record)) {
+ return {
+ seq: record.seq0,
+ type: record.type,
+ time: record.time0,
+ data: encodeData(JSON.stringify(record.data)),
+ sourceEventSeqs: null,
+ surfaceOp: null,
+ ignorable: PACKED_ROW_SENTINEL,
+ }
+ }
+ const event = record
+ const surface = event as SessionEvent
+ return {
+ seq: event.seq,
+ type: event.type,
+ time: event.time,
+ data: encodeData(JSON.stringify(event.data)),
+ sourceEventSeqs: surface.sourceEventSeqs === undefined
+ ? null
+ : encodeSourceEventSeqs(surface.sourceEventSeqs),
+ surfaceOp: surface.surfaceOp === undefined ? null : JSON.stringify(surface.surfaceOp),
+ ignorable: event.ignorable === true ? 1 : null,
+ }
+}
+
+function encodeData(serialized: string): string | Uint8Array {
+ const bytes = Buffer.from(serialized)
+ if (bytes.length < ZSTD_DATA_THRESHOLD_BYTES) return serialized
+ const compressed = zstdCompressSync(bytes, {
+ params: { [constants.ZSTD_c_compressionLevel]: ZSTD_COMPRESSION_LEVEL },
+ })
+ return compressed.length < bytes.length ? compressed : serialized
+}
+
+function decodeData(value: string | Uint8Array, maxOutputLength?: number): string {
+ if (typeof value === 'string') return value
+ const decoded = maxOutputLength === undefined
+ ? zstdDecompressSync(value)
+ : zstdDecompressSync(value, { maxOutputLength })
+ return UTF8_DECODER.decode(decoded)
+}
+
+function encodeSourceEventSeqs(values: readonly number[]): Uint8Array {
+ const bytes: number[] = []
+ let previous = 0n
+ for (let index = 0; index < values.length; index += 1) {
+ const sourceSeq = values[index] as number
+ if (!Number.isSafeInteger(sourceSeq) || sourceSeq < 0) {
+ throw new TypeError('sourceEventSeqs must contain non-negative safe integers')
+ }
+ const value = BigInt(sourceSeq)
+ const encoded = index === 0
+ ? value
+ : value >= previous
+ ? (value - previous) * 2n
+ : ((previous - value) * 2n) - 1n
+ appendVarint(bytes, encoded)
+ previous = value
+ }
+ return Buffer.from(bytes)
+}
+
+function appendVarint(bytes: number[], value: bigint): void {
+ let remaining = value
+ while (remaining >= 0x80n) {
+ bytes.push(Number(remaining & 0x7fn) | 0x80)
+ remaining >>= 7n
+ }
+ bytes.push(Number(remaining))
+}
+
+function decodeSourceEventSeqs(bytes: Uint8Array): number[] {
+ const values: number[] = []
+ let previous = 0n
+ let offset = 0
+ while (offset < bytes.length) {
+ const first = values.length === 0
+ const decoded = readVarint(bytes, offset, first ? MAX_SAFE_INTEGER : MAX_ZIGZAG_INTEGER)
+ offset = decoded.offset
+ const delta = first
+ ? decoded.value
+ : (decoded.value & 1n) === 0n
+ ? decoded.value / 2n
+ : -((decoded.value + 1n) / 2n)
+ const value = first ? delta : previous + delta
+ if (value < 0n || value > MAX_SAFE_INTEGER) {
+ throw new Error('malformed source_event_seqs storage value: decoded seq is out of range')
+ }
+ values.push(Number(value))
+ previous = value
+ }
+ return values
+}
+
+function readVarint(
+ bytes: Uint8Array,
+ offset: number,
+ limit: bigint,
+): { readonly value: bigint; readonly offset: number } {
+ let value = 0n
+ let shift = 0n
+ while (offset < bytes.length) {
+ const byte = bytes[offset] as number
+ offset += 1
+ value |= BigInt(byte & 0x7f) << shift
+ if ((byte & 0x80) === 0) {
+ if (shift > 0n && (byte & 0x7f) === 0) {
+ throw new Error('malformed source_event_seqs storage value: non-canonical varint')
+ }
+ if (value > limit) {
+ throw new Error('malformed source_event_seqs storage value: varint is out of range')
+ }
+ return { value, offset }
+ }
+ shift += 7n
+ if (shift > 56n) {
+ throw new Error('malformed source_event_seqs storage value: varint is out of range')
+ }
+ }
+ throw new Error('malformed source_event_seqs storage value: truncated varint')
+}
+
+function isChunkRow(record: StorageRecord): record is ChunkRow {
+ return isChunkTag(record.type) && 'seq0' in record && !('seq' in record)
+}
+
+function decodeScalarRow(row: EventRow): SessionEvent {
+ const surfaceFields = {
+ ...row.source_event_seqs === null
+ ? {}
+ : { sourceEventSeqs: decodeSourceEventSeqs(row.source_event_seqs) },
+ ...row.surface_op === null
+ ? {}
+ : { surfaceOp: JSON.parse(row.surface_op) as SessionEvent['surfaceOp'] },
+ }
+ return {
+ type: row.type as SessionEvent['type'],
+ seq: row.seq,
+ time: row.time,
+ data: JSON.parse(decodeData(row.data)) as SessionEvent['data'],
+ ...surfaceFields,
+ ...row.ignorable === 1 ? { ignorable: true as const } : {},
+ } as SessionEvent
+}
+
+/**
+ * Validate and flatten physical rows into their logical prefix. A malformed
+ * row or logical gap is committed corruption when a later valid turn end
+ * exists; otherwise it starts a removable physical tail.
+ * @param rows - physical rows ordered by their first logical sequence.
+ * @param base - logical sequence expected from the first selected row.
+ * @returns the contiguous logical prefix and optional physical deletion base.
+ */
+export function scanRows(
+ rows: readonly EventRow[],
+ base = 0,
+): { preserved: SessionEvent[]; tornFrom?: number } {
+ let lastTurnEndRow = -1
+ for (let index = rows.length - 1; index >= 0; index -= 1) {
+ try {
+ if (decodeRow(rows[index] as EventRow).some(event => event.type === 'turn/end')) {
+ lastTurnEndRow = index
+ break
+ }
+ } catch {
+ // A malformed row cannot prove that an earlier physical prefix committed.
+ }
+ }
+
+ const preserved: SessionEvent[] = []
+ let expected = base
+ for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) {
+ const physical = rows[rowIndex] as EventRow
+ let logicalEvents: SessionEvent[] | undefined
+ try {
+ logicalEvents = decodeRow(physical)
+ } catch {
+ // The committed-prefix rule below owns whether this invalid row is fatal or repairable.
+ }
+ if (logicalEvents === undefined) {
+ if (rowIndex <= lastTurnEndRow) {
+ throw new Error(`corrupt session log: invalid committed physical row at seq ${physical.seq}`)
+ }
+ return { preserved, tornFrom: physical.seq }
+ }
+ let contiguous = true
+ for (const event of logicalEvents) {
+ if (event.seq !== expected) {
+ contiguous = false
+ break
+ }
+ expected += 1
+ }
+ if (!contiguous) {
+ if (rowIndex <= lastTurnEndRow) {
+ throw new Error(`corrupt session log: invalid committed physical row at seq ${physical.seq}`)
+ }
+ return { preserved, tornFrom: physical.seq }
+ }
+ preserved.push(...logicalEvents)
+ }
+ return { preserved }
+}
diff --git a/packages/session/session-persistence-sqlite/src/index.ts b/packages/session/session-persistence-sqlite/src/index.ts
index 969f1f5fac..5d6c3bb674 100644
--- a/packages/session/session-persistence-sqlite/src/index.ts
+++ b/packages/session/session-persistence-sqlite/src/index.ts
@@ -1,90 +1,45 @@
/**
- * SQLite durable session-persistence backend. It maps each session header and
- * event to rows, and delegates write-path orchestration to
- * {@link PersistenceCoordinator}. It has no independent per-session artifact,
- * so its locator returns `undefined`.
+ * Opt-in SQLite persistence provider. Logical sessions remain unchanged;
+ * the physical backend packs eligible chunk runs into schema-17 rows.
* @module @deepseek-ai/dsh-session-persistence-sqlite
*/
-import { Context } from '@deepseek-ai/cordis'
+import { Context, Service } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
-import { randomUUID } from 'node:crypto'
-import { statSync } from 'node:fs'
-import { DatabaseSync } from 'node:sqlite'
-import { mkdir, open } from 'node:fs/promises'
-import { dirname, resolve } from 'node:path'
+import type {
+ SessionEvent,
+ SessionHeader,
+ SessionId,
+ SessionPreparation,
+} from '@deepseek-ai/dsh-session'
import {
- DEFAULT_PREPARED_SESSION_CACHE_SIZE, DEFAULT_WRITE_BATCH_MAX_DELAY_MS, MAX_WRITE_BATCH_DELAY_MS,
- SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator,
- type PersistenceBackend, type SessionLocation, type SessionPersistenceSnapshot,
- type SessionInspection, type SessionPersistenceRevision as PersistenceRevision,
- type StoredPrefix, type StoredSuffix,
+ DEFAULT_PREPARED_SESSION_CACHE_SIZE,
+ DEFAULT_WRITE_BATCH_MAX_DELAY_MS,
+ MAX_WRITE_BATCH_DELAY_MS,
+ PersistenceCoordinator,
+ SessionPersistence,
+ type SessionInspection,
+ type SessionLocation,
+ type SessionPersistenceSnapshot,
} from '@deepseek-ai/dsh-session-persistence'
-import type { SessionEvent, SurfaceEventType, SessionId, SessionHeader, SessionPreparation } from '@deepseek-ai/dsh-session'
-import {
- type JournalMode, openDatabase, rowToMeta, scanRows, type EventRow, type SessionRow,
-} from './schema.ts'
+import type { JournalMode } from './schema.ts'
+import { SqliteStore } from './store.ts'
export { SCHEMA_VERSION } from './schema.ts'
-/**
- * Serialize an event's optional envelope fields for SQL binding. The surface
- * fields are nullable TEXT columns — null when the event has no surface
- * metadata (non-surface events, events written before surface support); the
- * ignorable marker is a nullable INTEGER column — `1` iff the envelope carries
- * `ignorable: true`.
- */
-function envelopeBindings(event: SessionEvent): [string | null, string | null, number | null] {
- const se = event as SessionEvent
- return [
- se.sourceEventSeqs ? JSON.stringify(se.sourceEventSeqs) : null,
- se.surfaceOp !== undefined ? JSON.stringify(se.surfaceOp) : null,
- event.ignorable === true ? 1 : null,
- ]
-}
-
-/** Build the source-qualified revision shared by full and lightweight reads. */
-function sqliteRevision(storeIdentity: string, row: SessionRow): PersistenceRevision {
- return SessionPersistenceRevision(
- `${storeIdentity}:incarnation:${row.incarnation}:revision:${row.revision}`,
- )
-}
-
-/**
- * Exclusively create a missing database file with owner-only permissions.
- * Existing files retain their modes, and errors other than `EEXIST` propagate.
- * `DatabaseSync` reopens by path, so this does not protect confidentiality or
- * integrity when another principal can replace the database entry in its parent
- * directory.
- */
-async function createDatabaseFile(path: string): Promise {
- try {
- const handle = await open(path, 'wx', 0o600)
- await handle.close()
- } catch (error) {
- if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
- }
-}
+/** Default wait for another SQLite connection's write reservation. */
+export const DEFAULT_BUSY_TIMEOUT_MS = 5_000
+/** Largest busy timeout accepted by SQLite's signed millisecond interface. */
+export const MAX_BUSY_TIMEOUT_MS = 2_147_483_647
/** Plugin configuration. */
export interface Config {
- /**
- * Filesystem path to the SQLite database file. The special value `:memory:`
- * opens an in-process database (tests). On filesystems with POSIX modes,
- * missing directories and databases are created owner-only; existing path
- * modes are preserved. Filesystem setup errors other than an existing database
- * fail initialization. The backend does not protect confidentiality or
- * integrity when another principal can replace the database entry in its
- * parent directory.
- */
+ /** SQLite database path, or `:memory:` for an in-process database. */
path: string
- /**
- * SQLite `journal_mode` pragma. `wal` (the default) is the recorded
- * durability model; pick a rollback-journal mode (`delete`/`truncate`/
- * `persist`) on filesystems where WAL's shared-memory files do not work
- * (network mounts). See {@link JournalMode}.
- */
+ /** Durable SQLite journal mode; defaults to `wal`. */
journalMode?: JournalMode
+ /** Maximum wait for another SQLite connection's lock; defaults to 5,000 ms. */
+ busyTimeoutMs?: number
/** Maximum cold Session preparations retained for history-to-resume reuse. */
preparedSessionCacheSize?: number
/** Fixed live-event coalescing window; not a backend completion deadline. */
@@ -92,84 +47,49 @@ export interface Config {
}
/**
- * The SQLite persistence backend. Load as a plugin; it registers as
- * `ctx.sessionPersistence` and (via the coordinator) installs the write-path
- * listeners. Its torn-tail marker is the seq to delete from.
+ * SQLite `SessionPersistence` provider with a schema-owned physical codec.
*/
-export class SqliteSessionPersistence extends SessionPersistence implements PersistenceBackend {
+export class SqliteSessionPersistence extends SessionPersistence {
override readonly supportsRawArtifacts = false
+ override readonly name = 'session-persistence-sqlite'
static inject = ['sessions']
static Config: z = z.object({
path: z.string().required(),
journalMode: z.union(['wal', 'delete', 'truncate', 'persist'] as const).default('wal'),
+ busyTimeoutMs: z.number().step(1).min(0).max(MAX_BUSY_TIMEOUT_MS).default(DEFAULT_BUSY_TIMEOUT_MS),
preparedSessionCacheSize: z.number().step(1).min(1).default(DEFAULT_PREPARED_SESSION_CACHE_SIZE),
writeBatchMaxDelayMs: z.number().step(1).min(1).max(MAX_WRITE_BATCH_DELAY_MS)
.default(DEFAULT_WRITE_BATCH_MAX_DELAY_MS),
})
- /**
- * Backend label for the coordinator's dispose diagnostics. Intentionally
- * shadows cordis `Service.name` (set to `'sessionPersistence'` by the base);
- * see the JSONL backend for why this does not affect service resolution.
- */
- override readonly name = 'session-persistence-sqlite'
-
- private db!: DatabaseSync
- private storeIdentity!: string
- private ready: Promise
- private coordinator: PersistenceCoordinator
+ private readonly store: SqliteStore
+ private readonly coordinator: PersistenceCoordinator
constructor(ctx: Context, public config: Config) {
super(ctx)
- // Programmatic wrappers may construct the backend without Schemastery normalization.
const preparedSessionCacheSize = config.preparedSessionCacheSize
?? DEFAULT_PREPARED_SESSION_CACHE_SIZE
const writeBatchMaxDelayMs = config.writeBatchMaxDelayMs
?? DEFAULT_WRITE_BATCH_MAX_DELAY_MS
- // Open asynchronously so directory creation does not block plugin apply;
- // every storage hook awaits the same readiness promise.
- this.ready = this.openDb(config.path, (config as Required).journalMode)
- this.coordinator = new PersistenceCoordinator(this.ctx, this, {
+ this.store = new SqliteStore({
+ path: config.path,
+ journalMode: config.journalMode ?? 'wal',
+ busyTimeoutMs: config.busyTimeoutMs ?? DEFAULT_BUSY_TIMEOUT_MS,
+ })
+ this.coordinator = new PersistenceCoordinator(this.ctx, this.store, {
preparedSessionCacheSize,
writeBatchMaxDelayMs,
})
}
- private async openDb(path: string, journalMode: JournalMode): Promise {
- const actual = path === ':memory:' ? path : resolve(path)
- if (actual !== ':memory:') {
- await mkdir(dirname(actual), { recursive: true, mode: 0o700 })
- await createDatabaseFile(actual)
- }
- this.db = openDatabase(actual, journalMode)
- try {
- const row = this.db.prepare(
- 'SELECT store_id FROM persistence_state WHERE singleton = 1',
- ).get() as { store_id: string } | undefined
- /* v8 ignore next -- openDatabase inserts the singleton before returning. */
- if (row === undefined) {
- throw new Error(`session database at "${actual}" has no store identity`)
- }
- if (row.store_id.length === 0) {
- throw new Error(`session database at "${actual}" has no valid store identity`)
- }
- if (actual !== ':memory:') {
- const identity = statSync(actual, { bigint: true })
- this.storeIdentity = `file:${identity.dev}:${identity.ino}:${identity.birthtimeNs}:store:${row.store_id}`
- } else {
- this.storeIdentity = `memory:store:${row.store_id}`
- }
- } catch (error: unknown) {
- this.db.close()
- throw error
- }
+ /** Reject self-contained path and ownership failures without loading Node SQLite. */
+ protected async [Service.init](): Promise {
+ await this.store.validatePath()
}
- // --- SessionPersistence service API (delegated to the coordinator) ---
-
- /** SQLite has one database, not an independent local artifact per session. */
+ /** SQLite has one database, not an independent per-session artifact. */
locate(_meta: SessionHeader): SessionLocation | undefined {
return undefined
}
@@ -194,220 +114,20 @@ export class SqliteSessionPersistence extends SessionPersistence implements Pers
return this.coordinator.inspect(id, signal)
}
- readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
+ readFrom(
+ id: SessionId,
+ fromSeq: number,
+ signal?: AbortSignal,
+ ): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
return this.coordinator.readFrom(id, fromSeq, signal)
}
- // One method serves both public `list` and the backend hook; delegating it to
- // the coordinator would call this hook recursively.
-
- // --- PersistenceBackend hooks (the SQLite storage primitives) ---
-
- /** Read a stored prefix by id (ids are globally unique — no scope to scan). */
- loadStored(id: SessionId, signal?: AbortSignal): Promise | undefined> {
- return this.readPrefix(id, signal)
+ list(signal?: AbortSignal): Promise {
+ return this.store.list(signal)
}
- /** Read one row's revision without loading its events. */
- async readStoredRevision(id: SessionId, signal?: AbortSignal): Promise {
- signal?.throwIfAborted()
- await this.ready
- signal?.throwIfAborted()
- const row = this.rowFor(id)
- return row === undefined ? undefined : sqliteRevision(this.storeIdentity, row)
- }
-
- /**
- * Seek-capable suffix read: SQL selects `seq >= fromSeq` directly, so the
- * read scales with the suffix, not the log. Torn rows past the preserved
- * region are dropped, never repaired (non-mutating read).
- */
- async loadStoredFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise {
- signal?.throwIfAborted()
- await this.ready
- signal?.throwIfAborted()
- const row = this.rowFor(id)
- if (row === undefined) return undefined
- const meta = rowToMeta(row)
- const eventRows = this.db
- .prepare('SELECT seq, type, time, data, source_event_seqs, surface_op, ignorable FROM events WHERE session_id = ? AND seq >= ? ORDER BY seq')
- .all(id, fromSeq) as unknown as EventRow[]
- signal?.throwIfAborted()
- const { preserved } = scanRows(eventRows, fromSeq)
- return { meta, events: preserved }
- }
-
- /**
- * Read a session's row + ordered events into a {@link StoredPrefix}. The
- * torn-tail marker is the seq from which a never-committed tail must be deleted
- * (`scanRows` already returns it as `number | undefined`).
- */
- private async readPrefix(id: SessionId, signal?: AbortSignal): Promise | undefined> {
- signal?.throwIfAborted()
- await this.ready
- signal?.throwIfAborted()
- this.db.exec('BEGIN')
- let snapshot: { row: SessionRow; eventRows: EventRow[] } | undefined
- try {
- const row = this.rowFor(id)
- if (row !== undefined) {
- const eventRows = this.db
- .prepare('SELECT seq, type, time, data, source_event_seqs, surface_op, ignorable FROM events WHERE session_id = ? ORDER BY seq')
- .all(id) as unknown as EventRow[]
- snapshot = { row, eventRows }
- }
- this.db.exec('COMMIT')
- } catch (error: unknown) {
- /* v8 ignore start -- synchronous read failures only need transaction cleanup before propagation. */
- this.db.exec('ROLLBACK')
- throw error
- /* v8 ignore stop */
- }
- signal?.throwIfAborted()
- if (snapshot === undefined) return undefined
- const { row, eventRows } = snapshot
- const { preserved, tornFrom } = scanRows(eventRows)
- return {
- meta: rowToMeta(row),
- events: preserved,
- revision: sqliteRevision(this.storeIdentity, row),
- ...tornFrom !== undefined ? { tornMarker: tornFrom } : {},
- }
- }
-
- /**
- * Durably append a batch in ONE transaction: materialize the sessions row (if
- * lazy) and INSERT every event, or roll back entirely. The transaction is the
- * atomicity + durability boundary, so a mid-batch failure (a UNIQUE violation
- * on a duplicated seq) leaves the stored log untouched.
- */
- async appendBatch(meta: SessionHeader, events: readonly SessionEvent[], isMaterialized: boolean): Promise {
- await this.ready
- const insertEvent = this.db.prepare(
- 'INSERT INTO events (session_id, seq, type, time, data, source_event_seqs, surface_op, ignorable) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
- )
- this.db.exec('BEGIN')
- try {
- if (!isMaterialized) this.writeRow(meta)
- for (const event of events) {
- const [surfaceSeqs, surfaceOp, ignorable] = envelopeBindings(event)
- insertEvent.run(meta.id, event.seq, event.type, event.time, JSON.stringify(event.data), surfaceSeqs, surfaceOp, ignorable)
- }
- this.db.prepare('UPDATE sessions SET revision = revision + 1 WHERE id = ?').run(meta.id)
- this.db.exec('COMMIT')
- } catch (error) {
- this.db.exec('ROLLBACK')
- throw error
- }
- }
-
- /**
- * Make a crash repair durable in ONE transaction: DELETE the torn tail (from
- * `tornMarker`) and INSERT the synthetic `closers`. After COMMIT the stored rows
- * == the balanced log.
- */
- async commitRepair(meta: SessionHeader, tornMarker: number | undefined, closers: readonly SessionEvent[]): Promise {
- await this.ready
- this.db.exec('BEGIN')
- try {
- if (tornMarker !== undefined) {
- this.db.prepare('DELETE FROM events WHERE session_id = ? AND seq >= ?').run(meta.id, tornMarker)
- }
- if (closers.length > 0) {
- const insertEvent = this.db.prepare(
- 'INSERT INTO events (session_id, seq, type, time, data, source_event_seqs, surface_op, ignorable) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
- )
- for (const event of closers) {
- const [surfaceSeqs, surfaceOp, ignorable] = envelopeBindings(event)
- insertEvent.run(meta.id, event.seq, event.type, event.time, JSON.stringify(event.data), surfaceSeqs, surfaceOp, ignorable)
- }
- }
- if (tornMarker !== undefined || closers.length > 0) {
- this.db.prepare('UPDATE sessions SET revision = revision + 1 WHERE id = ?').run(meta.id)
- }
- this.db.exec('COMMIT')
- } catch (error) {
- // The DELETE+INSERT cannot collide (a row at a closer's seq is preserved or
- // deleted as torn first); this rolls back a DB-level failure (disk full,
- // etc.), unreachable in test.
- /* v8 ignore start */
- this.db.exec('ROLLBACK')
- throw error
- /* v8 ignore stop */
- }
- }
-
- /** List all materialized sessions' metadata (every row is a materialized session). */
- async list(signal?: AbortSignal): Promise {
- signal?.throwIfAborted()
- await this.ready
- signal?.throwIfAborted()
- const rows = this.db
- .prepare('SELECT * FROM sessions')
- .all() as unknown as SessionRow[]
- signal?.throwIfAborted()
- return rows.map(rowToMeta)
- }
-
- /** List metadata with a source-qualified monotonic revision per session. */
- async listSnapshots(signal?: AbortSignal): Promise {
- signal?.throwIfAborted()
- await this.ready
- signal?.throwIfAborted()
- const rows = this.db.prepare('SELECT * FROM sessions').all() as unknown as SessionRow[]
- signal?.throwIfAborted()
- return rows.map(row => ({
- header: rowToMeta(row),
- revision: SessionPersistenceRevision(
- `${this.storeIdentity}:incarnation:${row.incarnation}:revision:${row.revision}`,
- ),
- }))
- }
-
- /** Close the database handle (awaited by the coordinator's dispose, post-drain). */
- async close(): Promise {
- await this.ready
- this.db.close()
- }
-
- // --- row helpers ---
-
- /** Fetch a session's row, or undefined if absent. */
- private rowFor(id: SessionId): SessionRow | undefined {
- return this.db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as unknown as SessionRow | undefined
- }
-
- /**
- * Insert-or-replace a session's metadata row. The only caller is the first
- * materializing `appendBatch`, so writing the row IS the materialization (its
- * existence is the signal `list` reads).
- */
- private writeRow(meta: SessionHeader): void {
- this.db.prepare(`
- INSERT INTO sessions
- (id, version, created_at, cwd, parent_session, seed_length, origin, delegation_depth, agent_preset, incarnation, revision)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
- ON CONFLICT(id) DO UPDATE SET
- version = excluded.version,
- created_at = excluded.created_at,
- cwd = excluded.cwd,
- parent_session = excluded.parent_session,
- seed_length = excluded.seed_length,
- origin = excluded.origin,
- delegation_depth = excluded.delegation_depth,
- agent_preset = excluded.agent_preset
- `).run(
- meta.id,
- meta.version,
- meta.createdAt,
- meta.cwd ?? null,
- meta.parentSession ?? null,
- meta.seedLength ?? null,
- meta.origin ?? null,
- meta.delegationDepth ?? null,
- meta.agentPreset ?? null,
- randomUUID(),
- )
+ listSnapshots(signal?: AbortSignal): Promise {
+ return this.store.listSnapshots(signal)
}
}
diff --git a/packages/session/session-persistence-sqlite/src/invariant.ts b/packages/session/session-persistence-sqlite/src/invariant.ts
index 7a5e905e30..8436d5fbe5 100644
--- a/packages/session/session-persistence-sqlite/src/invariant.ts
+++ b/packages/session/session-persistence-sqlite/src/invariant.ts
@@ -15,8 +15,8 @@ export const name = 'session-persistence-sqlite-invariant'
export const inject = ['invariants']
/**
- * No runtime invariant: persistence correctness requires backend round-trip and crash-tail tests;
- * this package exposes no continuously observable in-process relation.
+ * No runtime invariant: physical packing is observable only by database
+ * round-trip and row-count checks, not a continuous in-process relation.
*/
const install: InvariantInstaller = () => {}
diff --git a/packages/session/session-persistence-sqlite/src/schema.ts b/packages/session/session-persistence-sqlite/src/schema.ts
index c7402d7d4b..39fc00504b 100644
--- a/packages/session/session-persistence-sqlite/src/schema.ts
+++ b/packages/session/session-persistence-sqlite/src/schema.ts
@@ -1,87 +1,86 @@
/**
- * Schema + load-time helpers for the SQLite session-persistence backend: the
- * DDL (a store-identity row, `sessions` metadata, and a 1:1 `events` row per
- * `SessionEvent`), the database open/configure step, and the last-`turn/end`
- * cut that gives the SQLite backend the SAME crash-tail-on-load semantics as
- * the JSONL backend.
- *
- * @module dsh-session-persistence-sqlite/schema
+ * SQLite schema ownership and durable-row validation.
+ * @module @deepseek-ai/dsh-session-persistence-sqlite/schema
*/
import { randomUUID } from 'node:crypto'
-import { DatabaseSync } from 'node:sqlite'
-import type { SessionEvent, SessionId, SessionHeader, SurfaceOp } from '@deepseek-ai/dsh-session'
+import { isAbsolute } from 'node:path'
+import { performance } from 'node:perf_hooks'
+import type { DatabaseSync } from 'node:sqlite'
+import { setTimeout as delay } from 'node:timers/promises'
+import {
+ SessionId,
+ type SessionHeader,
+} from '@deepseek-ai/dsh-session'
+import { sql } from './sql.ts'
-/**
- * The on-disk schema version. Bumped only on a breaking change to the table
- * layout; orthogonal to a session's own `version` (which versions the EVENT
- * vocabulary, stored per session in the `sessions` row).
- */
-export const SCHEMA_VERSION = 15
-
-/** SQLite application id protecting unrelated databases from persistence writes. */
+/** Current physical-record schema with packed and compressed event rows. */
+export const SCHEMA_VERSION = 17
+/** Application id reserved for DeepSeek Harness SQLite session databases. */
export const SESSION_PERSISTENCE_SQLITE_APPLICATION_ID = 0x44534850
-/**
- * A row of the `sessions` table — the out-of-log metadata ({@link SessionHeader}).
- * The row's EXISTENCE is the materialization signal: it is written only by the
- * first `append` (lazy materialization), so a created-but-never-appended
- * session has no row and is absent from `list`, mirroring the JSONL
- * backend's "no file until first append".
- */
+/** A materialized session's metadata and monotonic revision. */
export interface SessionRow {
- id: string
- version: number
- created_at: number
- cwd: string | null
- parent_session: string | null
- seed_length: number | null
- origin: 'subagent' | null
- /** Stable identity assigned when this log is materialized. */
- incarnation: string
- /** Monotonic log-change token incremented in each mutating transaction. */
- revision: number
- delegation_depth: number | null
- agent_preset: string | null
+ readonly id: string
+ readonly version: number
+ readonly created_at: number
+ readonly cwd: string | null
+ readonly parent_session: string | null
+ readonly seed_length: number | null
+ readonly origin: 'subagent' | null
+ readonly incarnation: string
+ readonly revision: number
+ readonly delegation_depth: number | null
+ readonly agent_preset: string | null
}
-/** An `events` table row: one `SessionEvent` mapped 1:1 (`data` is JSON text). */
+/** One physical event row; packed rows may represent multiple logical events. */
export interface EventRow {
- seq: number
- type: string
- time: number
- data: string
- /** JSON-encoded `number[]` — the event's sourceEventSeqs, or null. */
- source_event_seqs: string | null
- /** JSON-encoded `SurfaceOp` — how the event entered the surface, or null. */
- surface_op: string | null
- /** `1` iff the event carries the envelope's `ignorable: true` marker, else null. */
- ignorable: number | null
+ readonly seq: number
+ readonly type: string
+ readonly time: number
+ readonly data: string | Uint8Array
+ readonly source_event_seqs: Uint8Array | null
+ readonly surface_op: string | null
+ readonly ignorable: number | null
}
-/**
- * Journal modes the backend will run under. `wal` is the default and the
- * durability model the persistence ADR records; the rollback-journal modes
- * (`delete`/`truncate`/`persist`) exist for filesystems where WAL's
- * shared-memory files do not work (network mounts). `memory`/`off` are
- * excluded: dropping journal durability silently contradicts what this
- * backend promises.
- */
+/** Durable journal modes accepted by the backend. */
export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
+interface SchemaObjectRow {
+ readonly type: string
+ readonly name: string
+ readonly tbl_name: string
+ readonly sql: string
+}
+
+const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu
+const JOURNAL_BUSY_RETRY_INTERVAL_MS = 10
+type DatabaseSyncConstructor = typeof import('node:sqlite')['DatabaseSync']
+
/**
- * Open the database and apply its schema and pragmas. An empty database with a
- * zero `user_version` is initialized at {@link SCHEMA_VERSION}; a nonempty
- * unversioned database and every other non-current version reject rather than
- * being migrated in place.
- * @param path - the SQLite database file to open (created when absent).
+ * Open and validate a SQLite session database.
+ * @param Database - lazily imported Node SQLite constructor.
+ * @param path - SQLite path, including `:memory:`.
* @param journalMode - validated journal pragma.
- * @returns the open handle with pragmas applied and all three tables ensured.
+ * @param busyTimeoutMs - validated maximum wait for a competing SQLite lock.
+ * @returns the configured database handle.
+ * @throws when connection settings, schema ownership, or SQLite setup cannot be validated.
*/
-export function openDatabase(path: string, journalMode: JournalMode): DatabaseSync {
- const db = new DatabaseSync(path)
+export async function openDatabase(
+ Database: DatabaseSyncConstructor,
+ path: string,
+ journalMode: JournalMode,
+ busyTimeoutMs: number,
+): Promise {
+ const deadline = performance.now() + busyTimeoutMs
+ const db = new Database(path, { timeout: busyTimeoutMs })
try {
- configureDatabase(db, path, journalMode)
+ configureConnectionSecurity(db, path)
+ configureDatabase(Database, db, path)
+ await selectJournalMode(db, path, journalMode, deadline)
+ configureDurability(db, path)
return db
} catch (error: unknown) {
db.close()
@@ -89,182 +88,337 @@ export function openDatabase(path: string, journalMode: JournalMode): DatabaseSy
}
}
-function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalMode): void {
- db.exec('PRAGMA foreign_keys = ON')
+function configureConnectionSecurity(db: DatabaseSync, path: string): void {
+ db.exec(sql('trusted-schema-off'))
+ const trustedSchema = integerField(db.prepare(sql('select-trusted-schema')).get(), 'trusted_schema')
+ /* v8 ignore next 3 -- supported SQLite versions return the fixed setting. */
+ if (trustedSchema !== 0) {
+ throw new Error(`session database at "${path}" retained trusted_schema=${trustedSchema}, expected 0`)
+ }
+ db.exec(sql('mmap-off'))
+ if (path === ':memory:') return
+ const mmapSize = integerField(db.prepare(sql('select-mmap-size')).get(), 'mmap_size')
+ /* v8 ignore next 3 -- supported file-backed SQLite connections return the fixed setting. */
+ if (mmapSize !== 0) {
+ throw new Error(`session database at "${path}" retained mmap_size=${mmapSize}, expected 0`)
+ }
+}
+
+function configureDatabase(
+ Database: DatabaseSyncConstructor,
+ db: DatabaseSync,
+ path: string,
+): void {
+ db.exec(sql('foreign-keys-on'))
let began = false
try {
- db.exec('BEGIN IMMEDIATE')
+ db.exec(sql('begin-immediate'))
began = true
- // Validate while holding the write lock so no other connection can change
- // schema ownership between inspection and initialization.
- const { user_version: onDisk } = db.prepare('PRAGMA user_version').get() as { user_version: number }
- const { application_id: applicationId } = db.prepare('PRAGMA application_id').get() as { application_id: number }
- const { count: userObjectCount } = db.prepare(
- "SELECT COUNT(*) AS count FROM sqlite_schema WHERE name NOT GLOB 'sqlite_*'",
- ).get() as { count: number }
+ const onDisk = integerField(db.prepare(sql('select-user-version')).get(), 'user_version')
+ const applicationId = integerField(db.prepare(sql('select-application-id')).get(), 'application_id')
+ const userObjectCount = integerField(db.prepare(sql('select-user-object-count')).get(), 'count')
if (onDisk === 0 && (applicationId !== 0 || userObjectCount > 0)) {
throw new Error(`session database at "${path}" has an unversioned schema or application identity`)
}
if (onDisk !== 0 && onDisk !== SCHEMA_VERSION) {
- throw new Error(`session database at "${path}" has schema version ${onDisk}, incompatible with this build (${SCHEMA_VERSION})`)
+ throw new Error(
+ `session database at "${path}" has schema version ${onDisk}, incompatible with this build (${SCHEMA_VERSION})`,
+ )
}
- if (onDisk === SCHEMA_VERSION && applicationId !== SESSION_PERSISTENCE_SQLITE_APPLICATION_ID) {
+ if (onDisk !== 0 && applicationId !== SESSION_PERSISTENCE_SQLITE_APPLICATION_ID) {
throw new Error(
`session database at "${path}" has application id ${applicationId}, expected ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID}`,
)
}
- db.exec(`
- CREATE TABLE IF NOT EXISTS persistence_state (
- singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
- store_id TEXT NOT NULL
- ) STRICT;
-
- CREATE TABLE IF NOT EXISTS sessions (
- id TEXT PRIMARY KEY,
- version INTEGER NOT NULL,
- created_at INTEGER NOT NULL,
- cwd TEXT,
- parent_session TEXT,
- seed_length INTEGER,
- origin TEXT,
- delegation_depth INTEGER,
- agent_preset TEXT,
- incarnation TEXT NOT NULL,
- revision INTEGER NOT NULL
- ) STRICT;
-
- CREATE TABLE IF NOT EXISTS events (
- session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
- seq INTEGER NOT NULL,
- type TEXT NOT NULL,
- time INTEGER NOT NULL,
- data TEXT NOT NULL,
- source_event_seqs TEXT,
- surface_op TEXT,
- ignorable INTEGER,
- PRIMARY KEY (session_id, seq)
- ) STRICT
- `)
- db.prepare(
- 'INSERT OR IGNORE INTO persistence_state (singleton, store_id) VALUES (1, ?)',
- ).run(randomUUID())
- if (onDisk === 0) {
- db.exec(`PRAGMA application_id = ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID}`)
- db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`)
- }
- db.exec('COMMIT')
+ if (onDisk === 0) initializeDatabase(db)
+ validateRequiredSchema(Database, db, path)
+ db.exec(sql('commit'))
began = false
} catch (error: unknown) {
- /* v8 ignore next -- a BEGIN failure leaves no transaction to roll back. */
+ /* v8 ignore else -- a failed begin leaves no transaction to roll back. */
if (began) {
- /* v8 ignore next 5 -- preserve the original schema failure if SQLite also refuses rollback. */
+ /* v8 ignore next 5 -- retain the original ownership failure if rollback fails too. */
try {
- db.exec('ROLLBACK')
+ db.exec(sql('rollback'))
} catch {
- // The original SQLite failure remains the actionable cause.
+ // The original database-ownership failure remains actionable.
}
}
throw error
}
- // The validated union is safe to interpolate into a non-bindable PRAGMA.
- // Apply it only after ownership validation and initialization commit.
- db.exec(`PRAGMA journal_mode = ${journalMode.toUpperCase()}`)
}
-/**
- * Reconstruct the {@link SessionHeader} from a `sessions` row.
- * @param row - the `sessions` table row.
- * @returns the header, `NULL` columns mapped to omitted optional fields.
- */
-export function rowToMeta(row: SessionRow): SessionHeader {
- if (!Number.isSafeInteger(row.created_at) || row.created_at < 0) {
- throw new Error('stored session createdAt must be a non-negative safe integer')
- }
- return {
- version: row.version,
- id: row.id as SessionId,
- createdAt: row.created_at,
- ...row.cwd !== null ? { cwd: row.cwd } : {},
- ...row.parent_session !== null ? { parentSession: row.parent_session as SessionId } : {},
- ...row.seed_length !== null ? { seedLength: row.seed_length } : {},
- ...row.origin !== null ? { origin: row.origin } : {},
- ...row.delegation_depth !== null ? { delegationDepth: row.delegation_depth } : {},
- ...row.agent_preset !== null ? { agentPreset: row.agent_preset } : {},
- }
-}
-
-/**
- * Reconstruct a {@link SessionEvent} from an `events` row (parses `data`).
- * @param row - the `events` table row; `data` and the surface columns hold JSON text.
- * @returns the reconstructed event; throws when a JSON column fails to parse
- * ({@link scanRows} treats that as a hole, not corruption, in the tail).
- */
-export function rowToEvent(row: EventRow): SessionEvent {
- // Surface-metadata fields are conditional on the event type in the type
- // system; spread them so each variant gets only the fields it declares.
- const surfaceFields = {
- ...row.source_event_seqs !== null ? { sourceEventSeqs: JSON.parse(row.source_event_seqs) as number[] } : {},
- ...row.surface_op !== null ? { surfaceOp: JSON.parse(row.surface_op) as SurfaceOp } : {},
- }
- const ignorableField = row.ignorable === 1 ? { ignorable: true as const } : {}
- return {
- type: row.type as SessionEvent['type'],
- seq: row.seq,
- time: row.time,
- data: JSON.parse(row.data) as SessionEvent['data'],
- ...surfaceFields,
- ...ignorableField,
- } as SessionEvent
-}
-
-/**
- * Find the preserved prefix of ordered event rows. Fully written rows in an
- * interrupted final turn remain in the prefix. The first unparsable row or seq
- * gap after the last `turn/end` marks a tolerated torn tail; the same hole in
- * the committed region rejects.
- *
- * @param rows - one session's event rows, ordered by seq ascending.
- * @param base - the seq the first row is expected to carry; `0` for a whole
- * log, the requested `fromSeq` for a suffix read (`loadStoredFrom`).
- * @returns the preserved event prefix, plus `tornFrom` — the seq the physical
- * delete starts at — when a torn tail exists.
- */
-export function scanRows(rows: readonly EventRow[], base = 0): { preserved: SessionEvent[]; tornFrom?: number } {
- // Pass 1: parse each row's data; a row whose data is not valid JSON is a hole.
- // (The seq/type COLUMNS are always present even when `data` is corrupt.)
- interface Parsed { ok: boolean; event?: SessionEvent }
- const parsed: Parsed[] = rows.map((row) => {
+async function selectJournalMode(
+ db: DatabaseSync,
+ path: string,
+ journalMode: JournalMode,
+ deadline: number,
+): Promise {
+ let result: unknown
+ while (true) {
try {
- return { ok: true, event: rowToEvent(row) }
- } catch {
- return { ok: false }
+ result = db.prepare(sql(journalResource(journalMode))).get()
+ break
+ } catch (error: unknown) {
+ const remainingMs = Math.max(0, Math.ceil(deadline - performance.now()))
+ if (!isSqliteBusy(error) || remainingMs === 0) throw error
+ await delay(Math.min(JOURNAL_BUSY_RETRY_INTERVAL_MS, remainingMs))
+ if (performance.now() >= deadline) throw error
+ }
+ }
+ const selected = stringField(result, 'journal_mode').toLowerCase()
+ const expected = path === ':memory:' ? 'memory' : journalMode
+ /* v8 ignore next 3 -- SQLite returns the selected mode from these fixed, valid pragmas. */
+ if (selected !== expected) {
+ throw new Error(`session database at "${path}" selected journal mode ${selected}, expected ${expected}`)
+ }
+}
+
+function configureDurability(db: DatabaseSync, path: string): void {
+ db.exec(sql('synchronous-full'))
+ const synchronous = integerField(db.prepare(sql('select-synchronous')).get(), 'synchronous')
+ /* v8 ignore next 3 -- supported SQLite versions return the fixed setting. */
+ if (synchronous !== 2) {
+ throw new Error(`session database at "${path}" retained synchronous=${synchronous}, expected FULL (2)`)
+ }
+}
+
+function isSqliteBusy(error: unknown): boolean {
+ return typeof error === 'object'
+ && error !== null
+ && Reflect.get(error, 'errcode') === 5
+}
+
+function journalResource(mode: JournalMode):
+ | 'journal-mode-wal'
+ | 'journal-mode-delete'
+ | 'journal-mode-truncate'
+ | 'journal-mode-persist' {
+ switch (mode) {
+ case 'wal': return 'journal-mode-wal'
+ case 'delete': return 'journal-mode-delete'
+ case 'truncate': return 'journal-mode-truncate'
+ case 'persist': return 'journal-mode-persist'
+ }
+}
+
+function initializeDatabase(db: DatabaseSync): void {
+ db.exec(sql('schema'))
+ db.prepare(sql('insert-persistence-state')).run(randomUUID())
+ db.exec(sql('set-application-id'))
+ db.exec(sql('set-user-version-17'))
+}
+
+let canonicalSchema: readonly SchemaObjectRow[] | undefined
+
+function expectedSchema(Database: DatabaseSyncConstructor): readonly SchemaObjectRow[] {
+ if (canonicalSchema !== undefined) return canonicalSchema
+ const reference = new Database(':memory:')
+ try {
+ reference.exec(sql('foreign-keys-on'))
+ reference.exec(sql('schema'))
+ canonicalSchema = schemaObjects(reference)
+ return canonicalSchema
+ } finally {
+ reference.close()
+ }
+}
+
+function schemaObjects(db: DatabaseSync): SchemaObjectRow[] {
+ return db.prepare(sql('select-schema-objects')).all().map((value) => {
+ const row = record(value, 'schema object')
+ return {
+ type: stringField(row, 'type'),
+ name: stringField(row, 'name'),
+ tbl_name: stringField(row, 'tbl_name'),
+ sql: normalizeSql(stringField(row, 'sql')),
}
})
+}
- // The last index that is a valid `turn/end` — holes through a closed turn
- // are always committed corruption.
- let lastTurnEnd = -1
- for (let i = parsed.length - 1; i >= 0; i--) {
- if (parsed[i]?.ok && rows[i]?.type === 'turn/end') { lastTurnEnd = i; break }
+function normalizeSql(value: string): string {
+ return value.replaceAll(/\s+/gu, ' ').trim()
+}
+
+function validateRequiredSchema(
+ Database: DatabaseSyncConstructor,
+ db: DatabaseSync,
+ path: string,
+): void {
+ if (JSON.stringify(schemaObjects(db)) !== JSON.stringify(expectedSchema(Database))) {
+ throw new Error(`session database at "${path}" does not contain the required schema objects`)
+ }
+}
+
+/**
+ * Recheck schema ownership inside the caller's mutation transaction.
+ * @param Database - constructor used to validate the canonical schema.
+ * @param db - open owned database with an active immediate transaction.
+ * @param path - database location used in ownership diagnostics.
+ * @throws when another writer changed the application identity, schema, or version.
+ */
+export function validateSchemaForMutation(
+ Database: DatabaseSyncConstructor,
+ db: DatabaseSync,
+ path: string,
+): void {
+ const version = integerField(db.prepare(sql('select-user-version')).get(), 'user_version')
+ const applicationId = integerField(db.prepare(sql('select-application-id')).get(), 'application_id')
+ if (applicationId !== SESSION_PERSISTENCE_SQLITE_APPLICATION_ID) {
+ throw new Error(
+ `session database application id changed before mutation (expected ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID}, got ${applicationId})`,
+ )
+ }
+ validateRequiredSchema(Database, db, path)
+ if (version !== SCHEMA_VERSION) {
+ throw new Error(`session database schema changed before mutation (expected ${SCHEMA_VERSION}, got ${version})`)
+ }
+}
+
+/**
+ * Decode and validate one durable session row.
+ * @param value - value returned by SQLite.
+ * @returns a validated session row.
+ */
+export function decodeSessionRow(value: unknown): SessionRow {
+ const row = record(value, 'stored session metadata')
+ const id = nonemptyStringField(row, 'id')
+ const version = safeIntegerField(row, 'version')
+ const cwd = nullableStringField(row, 'cwd')
+ if (cwd !== null && !isAbsolute(cwd)) throw new Error('stored session cwd must be absolute')
+ const parent = nullableStringField(row, 'parent_session')
+ const origin = nullableStringField(row, 'origin')
+ if (origin !== null && origin !== 'subagent') throw new Error('stored session origin must be subagent or null')
+ const incarnation = nonemptyStringField(row, 'incarnation')
+ if (!UUID.test(incarnation)) throw new Error('stored session incarnation must be a UUID')
+ return {
+ id,
+ version,
+ created_at: nonnegativeSafeIntegerField(row, 'created_at'),
+ cwd,
+ parent_session: parent,
+ seed_length: nullableNonnegativeSafeIntegerField(row, 'seed_length'),
+ origin,
+ delegation_depth: nullableNonnegativeSafeIntegerField(row, 'delegation_depth'),
+ agent_preset: nullableStringField(row, 'agent_preset'),
+ incarnation,
+ revision: nonnegativeSafeIntegerField(row, 'revision'),
+ }
+}
+
+/**
+ * Decode and validate one durable event row before JSON interpretation.
+ * @param value - value returned by SQLite.
+ * @returns a validated physical event row.
+ */
+export function decodeEventRow(value: unknown): EventRow {
+ const row = record(value, 'stored event')
+ const ignorable = nullableSafeIntegerField(row, 'ignorable')
+ if (ignorable !== null && ignorable !== 0 && ignorable !== 1) {
+ throw new Error('stored event ignorable must be 0, 1, or null')
}
+ return {
+ seq: nonnegativeSafeIntegerField(row, 'seq'),
+ type: nonemptyStringField(row, 'type'),
+ time: safeIntegerField(row, 'time'),
+ data: stringOrBlobField(row, 'data'),
+ source_event_seqs: nullableBlobField(row, 'source_event_seqs'),
+ surface_op: nullableStringField(row, 'surface_op'),
+ ignorable,
+ }
+}
+
+/**
+ * Validate the singleton identity read from durable storage.
+ * @param value - value returned by SQLite.
+ * @returns the UUID store identity.
+ */
+export function decodeStoreIdentity(value: unknown): string {
+ const identity = nonemptyStringField(value, 'store_id')
+ if (!UUID.test(identity)) throw new Error('stored store_id must be a UUID')
+ return identity
+}
- // Preserve the contiguous prefix, including a complete interrupted turn;
- // holes through the last committed boundary throw, while later holes stop.
- const preserved: SessionEvent[] = []
- for (let i = 0; i < rows.length; i++) {
- const p = parsed[i]
- if (!p?.ok || p.event === undefined) {
- if (i <= lastTurnEnd) throw new Error(`corrupt session log: unparsable committed event at seq ${rows[i]?.seq}`)
- break // torn tail fragment after the last turn/end — stop, tolerate
- }
- if (p.event.seq !== base + i) {
- if (i <= lastTurnEnd) throw new Error(`corrupt session log: seq gap in committed region (expected ${base + i}, got ${p.event.seq})`)
- break // gap after the last turn/end — torn tail, stop
- }
- preserved.push(p.event)
+/**
+ * Reconstruct an immutable session header from a validated metadata row.
+ * @param row - validated stored metadata row.
+ * @returns the session header.
+ */
+export function rowToMeta(row: SessionRow): SessionHeader {
+ return {
+ version: row.version,
+ id: SessionId(row.id),
+ createdAt: row.created_at,
+ ...row.cwd === null ? {} : { cwd: row.cwd },
+ ...row.parent_session === null ? {} : { parentSession: SessionId(row.parent_session) },
+ ...row.seed_length === null ? {} : { seedLength: row.seed_length },
+ ...row.origin === null ? {} : { origin: row.origin },
+ ...row.delegation_depth === null ? {} : { delegationDepth: row.delegation_depth },
+ ...row.agent_preset === null ? {} : { agentPreset: row.agent_preset },
}
+}
+
+function record(value: unknown, label: string): Record {
+ if (typeof value !== 'object' || value === null) throw new Error(`${label} must be an object`)
+ return value as Record
+}
+
+function stringField(value: unknown, key: string): string {
+ const field = record(value, 'SQLite row')[key]
+ if (typeof field !== 'string') throw new Error(`stored ${key} must be a string`)
+ return field
+}
+
+function nonemptyStringField(value: unknown, key: string): string {
+ const field = stringField(value, key)
+ if (field.length === 0) throw new Error(`stored ${key} must not be empty`)
+ return field
+}
+
+function nullableStringField(value: unknown, key: string): string | null {
+ const field = record(value, 'SQLite row')[key]
+ if (field === null) return null
+ if (typeof field !== 'string') throw new Error(`stored ${key} must be a string or null`)
+ return field
+}
+
+function stringOrBlobField(value: unknown, key: string): string | Uint8Array {
+ const field = record(value, 'SQLite row')[key]
+ if (typeof field === 'string' || field instanceof Uint8Array) return field
+ throw new Error(`stored ${key} must be a string or blob`)
+}
+
+function nullableBlobField(value: unknown, key: string): Uint8Array | null {
+ const field = record(value, 'SQLite row')[key]
+ if (field === null || field instanceof Uint8Array) return field
+ throw new Error(`stored ${key} must be a blob or null`)
+}
+
+function integerField(value: unknown, key: string): number {
+ const field = record(value, 'SQLite row')[key]
+ if (!Number.isSafeInteger(field)) throw new Error(`stored ${key} must be a safe integer`)
+ return field as number
+}
+
+function safeIntegerField(value: unknown, key: string): number {
+ return integerField(value, key)
+}
+
+function nonnegativeSafeIntegerField(value: unknown, key: string): number {
+ const field = integerField(value, key)
+ if (field < 0) throw new Error(`stored ${key} must be non-negative`)
+ return field
+}
+
+function nullableSafeIntegerField(value: unknown, key: string): number | null {
+ const field = record(value, 'SQLite row')[key]
+ if (field === null) return null
+ if (!Number.isSafeInteger(field)) throw new Error(`stored ${key} must be a safe integer or null`)
+ return field as number
+}
- // Any rows past the preserved prefix are a never-committed torn tail; their
- // first seq is the deletion point for load's physical repair.
- return preserved.length < rows.length ? { preserved, tornFrom: base + preserved.length } : { preserved }
+function nullableNonnegativeSafeIntegerField(value: unknown, key: string): number | null {
+ const field = nullableSafeIntegerField(value, key)
+ if (field !== null && field < 0) throw new Error(`stored ${key} must be non-negative or null`)
+ return field
}
diff --git a/packages/session/session-persistence-sqlite/src/sql.ts b/packages/session/session-persistence-sqlite/src/sql.ts
new file mode 100644
index 0000000000..5d14855723
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/src/sql.ts
@@ -0,0 +1,65 @@
+/**
+ * Closed, package-owned SQL resource loading for SQLite.
+ * @module @deepseek-ai/dsh-session-persistence-sqlite/sql
+ */
+
+import { readFileSync } from 'node:fs'
+import { fileURLToPath } from 'node:url'
+
+const SQL_RESOURCES = [
+ 'begin',
+ 'begin-immediate',
+ 'commit',
+ 'delete-events-from',
+ 'foreign-keys-on',
+ 'insert-event',
+ 'insert-persistence-state',
+ 'journal-mode-delete',
+ 'journal-mode-persist',
+ 'journal-mode-truncate',
+ 'journal-mode-wal',
+ 'mmap-off',
+ 'rollback',
+ 'schema',
+ 'select-application-id',
+ 'select-events',
+ 'select-events-from',
+ 'select-mmap-size',
+ 'select-packed-predecessors',
+ 'select-schema-objects',
+ 'select-session',
+ 'select-sessions',
+ 'select-store-id',
+ 'select-synchronous',
+ 'select-tail-events',
+ 'select-trusted-schema',
+ 'select-user-object-count',
+ 'select-user-version',
+ 'set-application-id',
+ 'set-user-version-17',
+ 'synchronous-full',
+ 'trusted-schema-off',
+ 'update-session-revision',
+ 'upsert-session',
+] as const
+
+/** A resource basename selected exclusively by package code. */
+export type SqlResourceName = typeof SQL_RESOURCES[number]
+
+const cache = new Map()
+
+/**
+ * Load an immutable SQL statement by closed resource name.
+ * @param name - package-owned resource basename.
+ * @returns the resource text.
+ */
+export function sql(name: SqlResourceName): string {
+ const cached = cache.get(name)
+ if (cached !== undefined) return cached
+ const statement = readFileSync(
+ fileURLToPath(new URL(`../resources/sql/${name}.sql`, import.meta.url)),
+ 'utf8',
+ )
+ cache.set(name, statement)
+ return statement
+}
diff --git a/packages/session/session-persistence-sqlite/src/store.ts b/packages/session/session-persistence-sqlite/src/store.ts
new file mode 100644
index 0000000000..8e3e578cce
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/src/store.ts
@@ -0,0 +1,470 @@
+/**
+ * SQLite storage primitives: transactional append-batch packing, physical
+ * reads, schema validation, revisions, repair, and lifecycle closure.
+ * @module @deepseek-ai/dsh-session-persistence-sqlite/store
+ */
+
+import { randomUUID } from 'node:crypto'
+import { statSync } from 'node:fs'
+import { lstat, mkdir, open } from 'node:fs/promises'
+import { dirname, resolve } from 'node:path'
+import type { DatabaseSync, StatementSync } from 'node:sqlite'
+import {
+ type SessionEvent,
+ type SessionHeader,
+ type SessionId,
+} from '@deepseek-ai/dsh-session'
+import {
+ SessionPersistenceRevision,
+ type PersistenceBackend,
+ type SessionPersistenceRevision as PersistenceRevision,
+ type SessionPersistenceSnapshot,
+ type StoredPrefix,
+ type StoredSuffix,
+} from '@deepseek-ai/dsh-session-persistence'
+import {
+ MAX_PACKED_ROW_MEMBERS,
+ packChunkRuns,
+} from './codec.ts'
+import {
+ bindRecord,
+ decodeRow,
+ scanRows,
+ type BoundRecord,
+} from './compression.ts'
+import {
+ type EventRow,
+ type JournalMode,
+ decodeEventRow,
+ decodeSessionRow,
+ decodeStoreIdentity,
+ openDatabase,
+ validateSchemaForMutation,
+ rowToMeta,
+ type SessionRow,
+} from './schema.ts'
+import { sql } from './sql.ts'
+
+/** Storage options resolved by the service provider. */
+export interface SqliteStoreOptions {
+ readonly path: string
+ readonly journalMode: JournalMode
+ readonly busyTimeoutMs: number
+}
+
+/** SQLite implementation of the coordinator's physical backend hooks. */
+export class SqliteStore implements PersistenceBackend {
+ readonly name = 'session-persistence-sqlite'
+ private db!: DatabaseSync
+ private databaseConstructor!: typeof import('node:sqlite')['DatabaseSync']
+ private storeIdentity!: string
+ private databasePath!: string
+ private opened = false
+ private pathReady: Promise | undefined
+ private ready: Promise | undefined
+
+ constructor(private readonly options: SqliteStoreOptions) {}
+
+ /**
+ * Validate filesystem ownership without importing or opening Node SQLite.
+ * @returns settlement of the store's one path-validation operation.
+ */
+ validatePath(): Promise {
+ this.pathReady ??= this.preparePath(this.options.path)
+ return this.pathReady
+ }
+
+ /**
+ * Lazily open and validate the database on first persistence use.
+ * @returns settlement of the store's one database-open operation.
+ */
+ open(): Promise {
+ this.ready ??= this.openDb()
+ return this.ready
+ }
+
+ private async preparePath(path: string): Promise {
+ const actual = path === ':memory:' ? path : resolve(path)
+ if (actual !== ':memory:') {
+ await mkdir(dirname(actual), { recursive: true, mode: 0o700 })
+ await validateParentDirectory(dirname(actual))
+ await validateDatabaseFileIfPresent(actual)
+ }
+ this.databasePath = actual
+ }
+
+ private async openDb(): Promise {
+ await this.validatePath()
+ if (this.databasePath !== ':memory:') {
+ await createDatabaseFile(this.databasePath)
+ await validateDatabaseFile(this.databasePath)
+ }
+ const { DatabaseSync } = await loadNodeSqlite()
+ this.databaseConstructor = DatabaseSync
+ this.db = await openDatabase(
+ DatabaseSync,
+ this.databasePath,
+ this.options.journalMode,
+ this.options.busyTimeoutMs,
+ )
+ try {
+ const row = this.db.prepare(sql('select-store-id')).get()
+ if (row === undefined) {
+ throw new Error(`session database at "${this.databasePath}" has no valid store identity`)
+ }
+ let storeId: string
+ try {
+ storeId = decodeStoreIdentity(row)
+ } catch (error: unknown) {
+ throw new Error(`session database at "${this.databasePath}" has no valid store identity`, { cause: error })
+ }
+ if (this.databasePath === ':memory:') {
+ this.storeIdentity = `memory:store:${storeId}`
+ } else {
+ const identity = statSync(this.databasePath, { bigint: true })
+ this.storeIdentity = `file:${identity.dev}:${identity.ino}:${identity.birthtimeNs}:store:${storeId}`
+ }
+ this.opened = true
+ } catch (error: unknown) {
+ this.db.close()
+ throw error
+ }
+ }
+
+ async loadStored(id: SessionId, signal?: AbortSignal): Promise | undefined> {
+ await this.observe(signal)
+ const snapshot = this.readTransaction(() => {
+ const row = this.rowFor(id)
+ if (row === undefined) return undefined
+ const eventRows = this.db.prepare(sql('select-events')).all(id).map(decodeEventRow)
+ return { row, eventRows }
+ })
+ signal?.throwIfAborted()
+ if (snapshot === undefined) return undefined
+ const scanned = scanRows(snapshot.eventRows)
+ return {
+ meta: rowToMeta(snapshot.row),
+ events: scanned.preserved,
+ revision: sqliteRevision(this.storeIdentity, snapshot.row),
+ ...scanned.tornFrom === undefined ? {} : { tornMarker: scanned.tornFrom },
+ }
+ }
+
+ async readStoredRevision(id: SessionId, signal?: AbortSignal): Promise {
+ await this.observe(signal)
+ const row = this.rowFor(id)
+ signal?.throwIfAborted()
+ return row === undefined ? undefined : sqliteRevision(this.storeIdentity, row)
+ }
+
+ async loadStoredFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise {
+ await this.observe(signal)
+ const snapshot = this.readTransaction(() => {
+ const row = this.rowFor(id)
+ if (row === undefined) return undefined
+ return { row, ...this.physicalSpanFrom(id, fromSeq) }
+ })
+ signal?.throwIfAborted()
+ if (snapshot === undefined) return undefined
+ const { preserved } = scanRows(snapshot.eventRows, snapshot.base)
+ return { meta: rowToMeta(snapshot.row), events: preserved.filter(event => event.seq >= fromSeq) }
+ }
+
+ async appendBatch(
+ meta: SessionHeader,
+ events: readonly SessionEvent[],
+ isMaterialized: boolean,
+ ): Promise {
+ await this.open()
+ if (events.length === 0) return
+ this.db.exec(sql('begin-immediate'))
+ try {
+ validateSchemaForMutation(this.databaseConstructor, this.db, this.databasePath)
+ const tailRows = this.tailRows(meta.id)
+ const currentLast = this.logicalLastEvent(meta.id, tailRows)
+ const expected = currentLast === undefined ? 0 : currentLast.seq + 1
+ const first = events[0] as SessionEvent
+ if (first.seq !== expected) {
+ throw new Error(`session ${meta.id} append starts at seq ${first.seq}, stored next seq is ${expected}`)
+ }
+ if (!isMaterialized) this.writeRow(meta)
+
+ const insert = this.insertStatement()
+ for (const record of packChunkRuns(events)) this.insertRecord(insert, meta.id, bindRecord(record))
+ this.incrementRevision(meta.id)
+ this.db.exec(sql('commit'))
+ } catch (error: unknown) {
+ this.rollback(error, 'append')
+ }
+ }
+
+ async commitRepair(
+ meta: SessionHeader,
+ tornMarker: number | undefined,
+ closers: readonly SessionEvent[],
+ ): Promise {
+ await this.open()
+ if (tornMarker === undefined && closers.length === 0) return
+ this.db.exec(sql('begin-immediate'))
+ try {
+ validateSchemaForMutation(this.databaseConstructor, this.db, this.databasePath)
+ const row = this.rowFor(meta.id)
+ if (row === undefined) throw new Error(`session ${meta.id} metadata row is missing`)
+ const currentRows = this.db.prepare(sql('select-events')).all(meta.id).map(decodeEventRow)
+ const current = scanRows(currentRows)
+ if (tornMarker !== undefined) {
+ if (current.tornFrom !== tornMarker) {
+ throw new Error(`session ${meta.id} repair is stale: physical tail no longer starts at seq ${tornMarker}`)
+ }
+ this.db.prepare(sql('delete-events-from'))
+ .run(meta.id, tornMarker)
+ } else if (current.tornFrom !== undefined) {
+ throw new Error(`session ${meta.id} repair omitted current torn tail at seq ${current.tornFrom}`)
+ }
+ if (closers.length > 0) {
+ const expected = current.preserved.at(-1)?.seq === undefined
+ ? 0
+ : (current.preserved.at(-1) as SessionEvent).seq + 1
+ if (closers[0]?.seq !== expected) {
+ throw new Error(`session ${meta.id} repair is stale: closer starts at seq ${closers[0]?.seq}, stored next seq is ${expected}`)
+ }
+ const insert = this.insertStatement()
+ for (const closer of closers) this.insertRecord(insert, meta.id, bindRecord(closer))
+ }
+ this.incrementRevision(meta.id)
+ this.db.exec(sql('commit'))
+ } catch (error: unknown) {
+ this.rollback(error, 'repair')
+ }
+ }
+
+ async list(signal?: AbortSignal): Promise {
+ await this.observe(signal)
+ const rows = this.sessionRows()
+ signal?.throwIfAborted()
+ return rows.map(rowToMeta)
+ }
+
+ /**
+ * Return every materialized header with its source-qualified revision.
+ * @param signal - optional cancellation before or after the metadata query.
+ * @returns stored headers and revisions without loading event rows.
+ */
+ async listSnapshots(signal?: AbortSignal): Promise {
+ await this.observe(signal)
+ const rows = this.sessionRows()
+ signal?.throwIfAborted()
+ return rows.map(row => ({
+ header: rowToMeta(row),
+ revision: sqliteRevision(this.storeIdentity, row),
+ }))
+ }
+
+ async close(): Promise {
+ if (this.ready === undefined) {
+ if (this.pathReady !== undefined) await Promise.allSettled([this.pathReady])
+ return
+ }
+ await Promise.allSettled([this.ready])
+ if (!this.opened) return
+ this.opened = false
+ this.db.close()
+ }
+
+ private rowFor(id: SessionId): SessionRow | undefined {
+ const value = this.db.prepare(sql('select-session')).get(id)
+ return value === undefined ? undefined : decodeSessionRow(value)
+ }
+
+ private async observe(signal: AbortSignal | undefined): Promise {
+ signal?.throwIfAborted()
+ await this.open()
+ signal?.throwIfAborted()
+ }
+
+ private readTransaction(read: () => T): T {
+ this.db.exec(sql('begin'))
+ try {
+ const value = read()
+ this.db.exec(sql('commit'))
+ return value
+ } catch (error: unknown) {
+ this.rollback(error, 'read')
+ }
+ }
+
+ private sessionRows(): SessionRow[] {
+ return this.db.prepare(sql('select-sessions')).all().map(decodeSessionRow)
+ }
+
+ private rollback(error: unknown, operation: string): never {
+ try {
+ this.db.exec(sql('rollback'))
+ } catch (rollbackError: unknown) {
+ /* v8 ignore next -- requires SQLite to fail both an operation and its immediate rollback. */
+ throw new AggregateError([error, rollbackError], `${this.name} ${operation} failed and rollback also failed`)
+ }
+ throw error
+ }
+
+ private incrementRevision(id: SessionId): void {
+ const updated = this.db.prepare(sql('update-session-revision'))
+ .run(id)
+ /* v8 ignore next -- materialized writes follow coordinator create(); other writes upsert in this transaction. */
+ if (Number(updated.changes) !== 1) throw new Error(`session ${id} metadata row is missing`)
+ }
+
+ private tailRows(id: SessionId): EventRow[] {
+ const tail = this.db.prepare(sql('select-tail-events')).all(id, 2).map(decodeEventRow).reverse()
+ if (tail.length === 0) return []
+ return this.physicalSpanFrom(id, (tail[0] as EventRow).seq).eventRows
+ }
+
+ /** Select the bounded physical span that may represent `fromSeq`. */
+ private physicalSpanFrom(
+ id: SessionId,
+ fromSeq: number,
+ ): { readonly base: number; readonly eventRows: EventRow[] } {
+ const packedFloor = Math.max(0, fromSeq - MAX_PACKED_ROW_MEMBERS + 1)
+ const packedPredecessors = this.db.prepare(sql('select-packed-predecessors'))
+ .all(id, packedFloor, fromSeq)
+ .map(decodeEventRow)
+ let base = fromSeq
+ for (const predecessor of packedPredecessors) {
+ try {
+ const last = decodeRow(predecessor).at(-1)
+ if (last !== undefined && last.seq >= fromSeq) base = Math.min(base, predecessor.seq)
+ } catch {
+ // A malformed bounded predecessor may cover fromSeq; include it so the scanner fails closed.
+ base = Math.min(base, predecessor.seq)
+ }
+ }
+ const eventRows = this.db.prepare(sql('select-events-from')).all(id, base).map(decodeEventRow)
+ return { base, eventRows }
+ }
+
+ private logicalLastEvent(id: SessionId, tailRows: readonly EventRow[]): SessionEvent | undefined {
+ if (tailRows.length === 0) return undefined
+ const { preserved, tornFrom } = scanRows(tailRows, (tailRows[0] as EventRow).seq)
+ if (tornFrom !== undefined) throw new Error(`session ${id} has an invalid physical tail at seq ${tornFrom}`)
+ return preserved.at(-1)
+ }
+
+ private insertStatement(): StatementSync {
+ return this.db.prepare(sql('insert-event'))
+ }
+
+ private insertRecord(insert: StatementSync, id: SessionId, record: BoundRecord): void {
+ insert.run(
+ id,
+ record.seq,
+ record.type,
+ record.time,
+ record.data,
+ record.sourceEventSeqs,
+ record.surfaceOp,
+ record.ignorable,
+ )
+ }
+
+ private writeRow(meta: SessionHeader): void {
+ this.db.prepare(sql('upsert-session')).run(
+ meta.id,
+ meta.version,
+ meta.createdAt,
+ meta.cwd ?? null,
+ meta.parentSession ?? null,
+ meta.seedLength ?? null,
+ meta.origin ?? null,
+ meta.delegationDepth ?? null,
+ meta.agentPreset ?? null,
+ randomUUID(),
+ )
+ }
+}
+
+function sqliteRevision(storeIdentity: string, row: SessionRow): PersistenceRevision {
+ return SessionPersistenceRevision(
+ `${storeIdentity}:incarnation:${row.incarnation}:revision:${row.revision}`,
+ )
+}
+
+async function createDatabaseFile(path: string): Promise {
+ try {
+ const handle = await open(path, 'wx', 0o600)
+ await handle.close()
+ } catch (error: unknown) {
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
+ }
+}
+
+async function validateParentDirectory(path: string): Promise {
+ const parent = await lstat(path)
+ if (parent.isSymbolicLink() || !parent.isDirectory()) {
+ throw new Error(`session database parent "${path}" must be a real directory`)
+ }
+ const uid = process.getuid?.()
+ /* v8 ignore start -- Windows exposes neither process.getuid nor meaningful
+ * uid/mode bits; POSIX tests cover owner and mode rejection. */
+ if (uid !== undefined && (parent.uid !== uid || (parent.mode & 0o022) !== 0)) {
+ throw new Error(`session database parent "${path}" must be owned by the current user and not group/world-writable`)
+ }
+ /* v8 ignore stop */
+}
+
+async function validateDatabaseFile(path: string): Promise {
+ const file = await lstat(path)
+ if (file.isSymbolicLink() || !file.isFile()) {
+ throw new Error(`session database "${path}" must be a regular file, not a symbolic link`)
+ }
+ const uid = process.getuid?.()
+ /* v8 ignore start -- Windows exposes neither process.getuid nor meaningful
+ * uid/mode bits; POSIX tests cover owner and mode rejection. */
+ if (uid !== undefined && (file.uid !== uid || (file.mode & 0o077) !== 0)) {
+ throw new Error(`session database "${path}" must be owned by the current user and accessible only by that user`)
+ }
+ /* v8 ignore stop */
+}
+
+async function validateDatabaseFileIfPresent(path: string): Promise {
+ try {
+ await validateDatabaseFile(path)
+ } catch (error: unknown) {
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
+ }
+}
+
+let nodeSqlite: Promise | undefined
+
+/** Load Node SQLite once so concurrent stores share one warning-filter lifetime. */
+function loadNodeSqlite(): Promise {
+ nodeSqlite ??= importNodeSqlite()
+ return nodeSqlite
+}
+
+/** Import Node 22's SQLite dependency without its process-wide experimental warning. */
+async function importNodeSqlite(): Promise {
+ const emitWarning = Reflect.get(process, 'emitWarning')
+ /* v8 ignore start -- Node 22 alone emits this warning; primary coverage runs on Node 24. */
+ const filteredEmitWarning = (warning: string | Error, ...args: unknown[]): void => {
+ const message = warning instanceof Error ? warning.message : warning
+ const first = args[0]
+ const type = warning instanceof Error
+ ? warning.name
+ : typeof first === 'string'
+ ? first
+ : typeof first === 'object' && first !== null && 'type' in first
+ ? first.type
+ : undefined
+ if (message === 'SQLite is an experimental feature and might change at any time'
+ && type === 'ExperimentalWarning') return
+ Reflect.apply(emitWarning, process, [warning, ...args])
+ }
+ Reflect.set(process, 'emitWarning', filteredEmitWarning)
+ try {
+ return await import('node:sqlite')
+ } finally {
+ Reflect.set(process, 'emitWarning', emitWarning)
+ }
+ /* v8 ignore stop */
+}
diff --git a/packages/session/session-persistence-sqlite/tests/built-package.spec.ts b/packages/session/session-persistence-sqlite/tests/built-package.spec.ts
new file mode 100644
index 0000000000..ebbe2fc691
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/built-package.spec.ts
@@ -0,0 +1,36 @@
+import { execFile } from 'node:child_process'
+import { existsSync } from 'node:fs'
+import { fileURLToPath } from 'node:url'
+import { promisify } from 'node:util'
+import { describe, expect, it } from 'vitest'
+
+const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
+const builtBundle = fileURLToPath(new URL('../lib/index.js', import.meta.url))
+const execFileAsync = promisify(execFile)
+
+const probe = String.raw`
+import { resolve } from 'node:path';
+import { pathToFileURL } from 'node:url';
+const load = path => import(pathToFileURL(resolve(path)).href);
+const [{ Context }, { default: SessionStore }, { default: Sqlite }] = await Promise.all([
+ load('vendor/cordis/lib/index.js'),
+ load('packages/core/session/lib/index.js'),
+ load('packages/session/session-persistence-sqlite/lib/index.js'),
+]);
+const ctx = new Context();
+await ctx.plugin(SessionStore);
+await ctx.plugin(Sqlite, { path: ':memory:' });
+console.log(JSON.stringify(await ctx.sessionPersistence.list()));
+await ctx.fiber.dispose();
+`
+
+describe.skipIf(!existsSync(builtBundle))('SQLite built package', () => {
+ it('loads packaged SQL resources from the published entry', async () => {
+ const { stdout, stderr } = await execFileAsync(process.execPath, ['--input-type=module', '-e', probe], {
+ cwd: repoRoot,
+ timeout: 15_000,
+ })
+ expect(stderr).toBe('')
+ expect(JSON.parse(stdout) as unknown).toEqual([])
+ })
+})
diff --git a/packages/session/session-persistence-sqlite/tests/compression-unprofitable.spec.ts b/packages/session/session-persistence-sqlite/tests/compression-unprofitable.spec.ts
new file mode 100644
index 0000000000..f0fc765393
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/compression-unprofitable.spec.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it, vi } from 'vitest'
+import type { SessionEvent } from '@deepseek-ai/dsh-session'
+
+vi.mock('node:zlib', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ zstdCompressSync: (input: ArrayBufferView) => Buffer.alloc(input.byteLength + 1),
+ }
+})
+
+import { bindRecord, ZSTD_DATA_THRESHOLD_BYTES } from '../src/compression.ts'
+
+describe('SQLite compression fallback', () => {
+ it('keeps large data as text when its Zstandard frame is not smaller', () => {
+ const event = {
+ type: 'assistant/message',
+ seq: 0,
+ time: 1,
+ data: { text: 'x'.repeat(ZSTD_DATA_THRESHOLD_BYTES) },
+ } as unknown as SessionEvent
+
+ expect(typeof bindRecord(event).data).toBe('string')
+ })
+})
diff --git a/packages/session/session-persistence-sqlite/tests/compression.spec.ts b/packages/session/session-persistence-sqlite/tests/compression.spec.ts
new file mode 100644
index 0000000000..93f4da380f
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/compression.spec.ts
@@ -0,0 +1,357 @@
+import { describe, expect, it } from 'vitest'
+import { zstdCompressSync } from 'node:zlib'
+import type { SessionEvent } from '@deepseek-ai/dsh-session'
+import { CallId, type StreamChunk } from '@deepseek-ai/dsh-llm'
+import {
+ decodeStorageRecord,
+ MAX_PACKED_DATA_BYTES,
+ MAX_PACKED_ROW_MEMBERS,
+ packChunkRuns,
+ type StorageRecord,
+} from '../src/codec.ts'
+import {
+ bindRecord,
+ decodeRow,
+ scanRows,
+ ZSTD_DATA_THRESHOLD_BYTES,
+} from '../src/compression.ts'
+import type { EventRow } from '../src/schema.ts'
+
+function chunk(seq: number, text = `token-${seq}`): SessionEvent {
+ return {
+ type: 'assistant/chunk',
+ seq,
+ time: 1_000 + seq,
+ data: {
+ turn: 1,
+ step: 1,
+ chunk: { type: 'text-delta', index: 0, text },
+ },
+ }
+}
+
+function event(seq: number, time: number, value: StreamChunk, turn = 1, step = 1): SessionEvent {
+ return { type: 'assistant/chunk', seq, time, data: { turn, step, chunk: value } }
+}
+
+function row(record: StorageRecord): EventRow {
+ const bound = bindRecord(record)
+ return {
+ seq: bound.seq,
+ type: bound.type,
+ time: bound.time,
+ data: bound.data,
+ source_event_seqs: bound.sourceEventSeqs,
+ surface_op: bound.surfaceOp,
+ ignorable: bound.ignorable,
+ }
+}
+
+describe('SQLite compression', () => {
+ it('stores a 100-member run in one row and restores every logical event', () => {
+ const events = Array.from({ length: 100 }, (_, index) => chunk(index))
+ const records = packChunkRuns(events)
+ expect(records).toHaveLength(1)
+ expect(records[0]?.type).toBe('text-chunks')
+ expect(scanRows(records.map(row)).preserved).toEqual(events)
+ })
+
+ it('partitions long and large runs within schema-owned row limits', () => {
+ const long = Array.from({ length: MAX_PACKED_ROW_MEMBERS + 3 }, (_, index) => chunk(index))
+ const longRecords = packChunkRuns(long)
+ expect(longRecords).toHaveLength(2)
+ expect(scanRows(longRecords.map(row)).preserved).toEqual(long)
+
+ const large = Array.from({ length: 4 }, (_, index) => chunk(index, 'x'.repeat(300_000)))
+ const largeRecords = packChunkRuns(large)
+ expect(largeRecords).toHaveLength(2)
+ for (const record of largeRecords) {
+ if (record.type.endsWith('-chunks')) {
+ expect(Buffer.byteLength(JSON.stringify(record.data))).toBeLessThanOrEqual(MAX_PACKED_DATA_BYTES)
+ }
+ }
+ expect(scanRows(largeRecords.map(row)).preserved).toEqual(large)
+
+ const individuallyLarge = Array.from({ length: 3 }, (_, index) => chunk(index, 'x'.repeat(400_000)))
+ expect(packChunkRuns(individuallyLarge)).toEqual(individuallyLarge)
+
+ const byteBound = Array.from({ length: 10 }, (_, index) => chunk(index, 'x'.repeat(150_000)))
+ const byteBoundRecords = packChunkRuns(byteBound)
+ expect(byteBoundRecords.length).toBeGreaterThan(1)
+ expect(scanRows(byteBoundRecords.map(row)).preserved).toEqual(byteBound)
+ })
+
+ it('packs every owned kind and preserves optional tool-call names', () => {
+ const events = [
+ ...[0, 1, 2].map(seq => event(seq, seq, { type: 'reasoning-delta', index: 1, text: `${seq}` })),
+ ...[3, 4, 5].map(seq => event(seq, seq, {
+ type: 'tool-call-delta', index: 2, id: CallId('named'), name: 'write', argumentsDelta: `${seq}`,
+ })),
+ ...[6, 7, 8].map(seq => event(seq, seq, {
+ type: 'tool-call-delta', index: 3, id: CallId('unnamed'), argumentsDelta: `${seq}`,
+ })),
+ ]
+ const records = packChunkRuns(events)
+ expect(records.map(record => record.type)).toEqual([
+ 'reasoning-chunks', 'tool-call-chunks', 'tool-call-chunks',
+ ])
+ expect(records.flatMap(decodeStorageRecord)).toEqual(events)
+ })
+
+ it('keeps every off-format delta scalar and splits incompatible runs', () => {
+ const malformed = (seq: number, data: unknown): SessionEvent => ({
+ type: 'assistant/chunk', seq, time: 10 + seq, data,
+ } as SessionEvent)
+ const values: SessionEvent[] = [
+ { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
+ { ...chunk(1), extra: true } as unknown as SessionEvent,
+ { ...chunk(-1), seq: -1 },
+ { ...chunk(3), time: 1.5 },
+ malformed(4, 'data'),
+ malformed(5, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'x' }, extra: 1 }),
+ malformed(6, { turn: '1', step: 1, chunk: { type: 'text-delta', index: 0, text: 'x' } }),
+ malformed(7, { turn: 1, step: 1, chunk: 'chunk' }),
+ malformed(8, { turn: 1, step: 1, chunk: { type: 'text-delta', index: '0', text: 'x' } }),
+ malformed(9, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 1 } }),
+ malformed(10, { turn: 1, step: 1, chunk: { type: 'tool-call-delta', index: 0, id: 1, argumentsDelta: 'x' } }),
+ malformed(11, { turn: 1, step: 1, chunk: { type: 'tool-call-delta', index: 0, id: 'id', name: 1, argumentsDelta: 'x' } }),
+ malformed(12, { turn: 1, step: 1, chunk: { type: 'usage', index: 0, totalTokens: 1 } }),
+ ]
+ expect(packChunkRuns(values)).toEqual(values)
+
+ const gap = [chunk(0), chunk(1), chunk(3)]
+ const step = [chunk(0), chunk(1), event(2, 2, { type: 'text-delta', index: 0, text: 'x' }, 1, 2)]
+ const block = [chunk(0), chunk(1), event(2, 2, { type: 'text-delta', index: 1, text: 'x' })]
+ const unsafeTime = [
+ event(0, Number.MIN_SAFE_INTEGER, { type: 'text-delta', index: 0, text: 'a' }),
+ event(1, Number.MAX_SAFE_INTEGER, { type: 'text-delta', index: 0, text: 'b' }),
+ event(2, Number.MAX_SAFE_INTEGER, { type: 'text-delta', index: 0, text: 'c' }),
+ ]
+ const toolName = [0, 1, 2].map(seq => event(seq, seq, {
+ type: 'tool-call-delta', index: 0, id: CallId('id'),
+ ...seq === 2 ? {} : { name: 'write' }, argumentsDelta: 'x',
+ }))
+ for (const events of [gap, step, block, unsafeTime, toolName]) {
+ expect(packChunkRuns(events)).toEqual(events)
+ }
+ })
+
+ it.each([
+ ['extra envelope field', { type: 'text-chunks', seq0: 0, time0: 1, data: {}, extra: true }],
+ ['negative sequence', { type: 'text-chunks', seq0: -1, time0: 1, data: {} }],
+ ['fractional time', { type: 'text-chunks', seq0: 0, time0: 1.5, data: {} }],
+ ['primitive data', { type: 'text-chunks', seq0: 0, time0: 1, data: 'bad' }],
+ ['text fields', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [], args: [] } }],
+ ['non-numeric placement', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: '1', step: 1, index: 0, dt: [0, 0], texts: ['a', 'b', 'c'] } }],
+ ['non-array members', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [0, 0], texts: 'abc' } }],
+ ['too few members', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [0], texts: ['a', 'b'] } }],
+ ['too many members', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: Array(1_024).fill(0), texts: Array(1_025).fill('a') } }],
+ ['non-string member', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [0, 0], texts: ['a', 1, 'c'] } }],
+ ['invalid gaps', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [0, 0.5], texts: ['a', 'b', 'c'] } }],
+ ['non-array gaps', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: '00', texts: ['a', 'b', 'c'] } }],
+ ['gap arity', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [0], texts: ['a', 'b', 'c'] } }],
+ ['oversized data', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [0, 0], texts: ['x'.repeat(400_000), 'x'.repeat(400_000), 'x'.repeat(400_000)] } }],
+ ['sequence overflow', { type: 'text-chunks', seq0: Number.MAX_SAFE_INTEGER, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [0, 0], texts: ['a', 'b', 'c'] } }],
+ ['time overflow', { type: 'text-chunks', seq0: 0, time0: Number.MAX_SAFE_INTEGER, data: { turn: 1, step: 1, index: 0, dt: [1, 0], texts: ['a', 'b', 'c'] } }],
+ ['tool fields', { type: 'tool-call-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [0, 0], args: ['a', 'b', 'c'] } }],
+ ['tool id', { type: 'tool-call-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, id: 1, dt: [0, 0], args: ['a', 'b', 'c'] } }],
+ ['tool name', { type: 'tool-call-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, id: 'id', name: 1, dt: [0, 0], args: ['a', 'b', 'c'] } }],
+ ])('rejects malformed packed data: %s', (_label, record) => {
+ expect(() => decodeStorageRecord(record)).toThrow(/malformed .* storage row/)
+ })
+
+ it('decodes the schema-17 row vocabulary without another package codec', () => {
+ const fixture: EventRow = {
+ seq: 7,
+ type: 'text-chunks',
+ time: 90,
+ data: JSON.stringify({ turn: 2, step: 3, index: 1, dt: [2, -1], texts: ['a', 'b', 'c'] }),
+ source_event_seqs: null,
+ surface_op: null,
+ ignorable: 0,
+ }
+ expect(decodeRow(fixture)).toEqual([
+ { ...chunk(7, 'a'), time: 90, data: { turn: 2, step: 3, chunk: { type: 'text-delta', index: 1, text: 'a' } } },
+ { ...chunk(8, 'b'), time: 92, data: { turn: 2, step: 3, chunk: { type: 'text-delta', index: 1, text: 'b' } } },
+ { ...chunk(9, 'c'), time: 91, data: { turn: 2, step: 3, chunk: { type: 'text-delta', index: 1, text: 'c' } } },
+ ])
+ expect(decodeStorageRecord('scalar')).toEqual(['scalar'])
+ expect(decodeStorageRecord(chunk(0))).toEqual([chunk(0)])
+ })
+
+ it('rejects surface columns on packed rows', () => {
+ const packed = row(packChunkRuns([chunk(0), chunk(1), chunk(2)])[0]!)
+ const invalid: EventRow[] = [
+ { ...packed, source_event_seqs: Buffer.alloc(0) },
+ { ...packed, surface_op: '"append"' },
+ ]
+ for (const candidate of invalid) {
+ expect(() => decodeRow(candidate)).toThrow(/surface fields must be null/)
+ }
+ })
+
+ it('rejects the packed discriminator on a scalar event type', () => {
+ const scalar = row({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } })
+ expect(() => decodeRow({ ...scalar, ignorable: 0 }))
+ .toThrow(/packed discriminator requires a chunk tag/)
+ })
+
+ it.each(['text-chunks', 'reasoning-chunks', 'tool-call-chunks'])(
+ 'preserves an ignorable logical event named %s as a scalar row',
+ (type) => {
+ const logical = {
+ type,
+ seq: 0,
+ time: 1,
+ data: { future: true },
+ ignorable: true,
+ } as unknown as SessionEvent
+ const physical = row(logical)
+ expect(physical.ignorable).toBe(1)
+ expect(decodeRow(physical)).toEqual([logical])
+ },
+ )
+
+ it('compresses large data and delta-encodes complete provenance arrays', () => {
+ const sources = Array.from({ length: 2_000 }, (_, index) => index + 10)
+ const event = {
+ type: 'assistant/message',
+ seq: sources.at(-1)! + 1,
+ time: 1,
+ data: { text: 'x'.repeat(ZSTD_DATA_THRESHOLD_BYTES * 2) },
+ sourceEventSeqs: sources,
+ surfaceOp: 'append',
+ } as unknown as SessionEvent
+ const bound = bindRecord(event)
+ expect(bound.data).toBeInstanceOf(Uint8Array)
+ expect(bound.sourceEventSeqs).toBeInstanceOf(Uint8Array)
+ expect(bound.sourceEventSeqs?.byteLength).toBeLessThan(Buffer.byteLength(JSON.stringify(sources)))
+ expect(decodeRow(row(event))).toEqual([event])
+
+ const small = bindRecord({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } })
+ expect(typeof small.data).toBe('string')
+ })
+
+ it('round-trips empty, descending, and maximum-safe provenance deltas', () => {
+ for (const sources of [
+ [],
+ [Number.MAX_SAFE_INTEGER - 1, 0, Number.MAX_SAFE_INTEGER - 2],
+ ]) {
+ const event = {
+ type: 'assistant/message',
+ seq: Number.MAX_SAFE_INTEGER,
+ time: 1,
+ data: {},
+ sourceEventSeqs: sources,
+ surfaceOp: 'append',
+ } as unknown as SessionEvent
+ expect(decodeRow(row(event))).toEqual([event])
+ }
+ })
+
+ it.each([-1, 0.5])('rejects invalid provenance sequence %s before encoding', (sourceSeq) => {
+ const event = {
+ type: 'assistant/message',
+ seq: 1,
+ time: 1,
+ data: {},
+ sourceEventSeqs: [sourceSeq],
+ surfaceOp: 'append',
+ } as unknown as SessionEvent
+ expect(() => bindRecord(event)).toThrow(/non-negative safe integers/)
+ })
+
+ it('rejects malformed compressed and delta-encoded values', () => {
+ const scalar = row({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } })
+ expect(() => decodeRow({ ...scalar, data: Buffer.from('not zstd') })).toThrow()
+ expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([0x80]) }))
+ .toThrow(/truncated varint/)
+ expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([0x80, 0x00]) }))
+ .toThrow(/non-canonical varint/)
+ expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([0x00, 0x01]) }))
+ .toThrow(/decoded seq is out of range/)
+ expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([
+ 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0f, 0x02,
+ ]) })).toThrow(/decoded seq is out of range/)
+ expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([
+ 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x10,
+ ]) })).toThrow(/varint is out of range/)
+ expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.alloc(9, 0x80) }))
+ .toThrow(/varint is out of range/)
+ })
+
+ it('rejects an oversized packed data column before JSON decoding', () => {
+ const oversized: EventRow = {
+ seq: 0,
+ type: 'text-chunks',
+ time: 1,
+ data: ' '.repeat(MAX_PACKED_DATA_BYTES + 1),
+ source_event_seqs: null,
+ surface_op: null,
+ ignorable: 0,
+ }
+ expect(() => decodeRow(oversized)).toThrow(/data exceeds/)
+ })
+
+ it('bounds packed data while decompressing', () => {
+ const serialized = JSON.stringify({
+ turn: 1,
+ step: 1,
+ index: 0,
+ dt: [0, 0],
+ texts: ['x'.repeat(MAX_PACKED_DATA_BYTES), 'b', 'c'],
+ })
+ const oversized: EventRow = {
+ seq: 0,
+ type: 'text-chunks',
+ time: 1,
+ data: zstdCompressSync(serialized),
+ source_event_seqs: null,
+ surface_op: null,
+ ignorable: 0,
+ }
+ expect(() => decodeRow(oversized)).toThrow(/Buffer larger than/)
+ })
+
+ it('distinguishes removable and committed physical corruption', () => {
+ const start = row({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } })
+ const skipped = row({ type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } })
+ expect(scanRows([start, skipped])).toEqual({ preserved: [
+ { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
+ ], tornFrom: 2 })
+
+ const end = row({
+ type: 'turn/end',
+ seq: 3,
+ time: 3,
+ data: { turn: 1, reason: { kind: 'completed' } },
+ })
+ expect(() => scanRows([start, skipped, end])).toThrow(/invalid committed physical row at seq 2/)
+
+ const malformed = {
+ ...row(packChunkRuns([chunk(0), chunk(1), chunk(2)])[0]!),
+ data: '{not json',
+ }
+ const committedEnd = row({
+ type: 'turn/end',
+ seq: 1,
+ time: 4,
+ data: { turn: 1, reason: { kind: 'completed' } },
+ })
+ expect(() => scanRows([malformed, committedEnd]))
+ .toThrow(/invalid committed physical row at seq 0/)
+ })
+
+ it('treats a malformed packed tail as one removable physical row', () => {
+ const malformed: EventRow = {
+ seq: 0,
+ type: 'text-chunks',
+ time: 1,
+ data: JSON.stringify({ turn: 1, step: 1, index: 0, dt: [], texts: ['a', 'b'] }),
+ source_event_seqs: null,
+ surface_op: null,
+ ignorable: 0,
+ }
+ expect(scanRows([malformed])).toEqual({ preserved: [], tornFrom: 0 })
+ })
+})
diff --git a/packages/session/session-persistence-sqlite/tests/differential.spec.ts b/packages/session/session-persistence-sqlite/tests/differential.spec.ts
new file mode 100644
index 0000000000..45ec734992
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/differential.spec.ts
@@ -0,0 +1,273 @@
+import { afterEach, describe, expect, it } from 'vitest'
+import fc from 'fast-check'
+import { Context } from '@deepseek-ai/cordis'
+import { mkdtemp, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { DatabaseSync } from 'node:sqlite'
+import { CallId, type StreamChunk } from '@deepseek-ai/dsh-llm'
+import SessionStore, { type SessionEvent } from '@deepseek-ai/dsh-session'
+import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
+import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
+import SessionPersistenceSqlite from '@deepseek-ai/dsh-session-persistence-sqlite'
+import { meta } from '../../session-persistence/tests/contract.ts'
+import { testSql } from './test-sql.ts'
+
+type BackendName = 'jsonl-zstd' | 'sqlite'
+
+interface MountedBackend {
+ readonly persistence: SessionPersistence
+ dispose(): Promise
+}
+
+const directories: string[] = []
+afterEach(async () => {
+ for (const directory of directories.splice(0)) {
+ await rm(directory, { recursive: true, force: true })
+ }
+})
+
+async function freshDirectory(prefix: string): Promise {
+ const directory = await mkdtemp(join(tmpdir(), prefix))
+ directories.push(directory)
+ return directory
+}
+
+async function mount(name: BackendName, root: string): Promise {
+ const ctx = new Context()
+ await ctx.plugin(SessionStore)
+ switch (name) {
+ case 'jsonl-zstd': {
+ const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: join(root, 'jsonl') })
+ return { persistence: ctx.sessionPersistence, dispose: async () => { await fiber.dispose() } }
+ }
+ case 'sqlite': {
+ const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: join(root, 'sessions.db') })
+ return { persistence: ctx.sessionPersistence, dispose: async () => { await fiber.dispose() } }
+ }
+ }
+}
+
+function closedChunkLog(
+ entries: readonly { readonly chunk: StreamChunk; readonly time: number; readonly ignorable?: true }[],
+): SessionEvent[] {
+ const chunks = entries.map(({ chunk, time, ignorable }, index): SessionEvent => ({
+ type: 'assistant/chunk',
+ seq: index + 2,
+ time,
+ data: { turn: 1, step: 1, chunk },
+ ...ignorable === true ? { ignorable } : {},
+ }))
+ return [
+ { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
+ { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
+ ...chunks,
+ { type: 'step/end', seq: chunks.length + 2, time: 3, data: { turn: 1, step: 1 } },
+ {
+ type: 'turn/end',
+ seq: chunks.length + 3,
+ time: 4,
+ data: { turn: 1, reason: { kind: 'completed' } },
+ },
+ ]
+}
+
+function packingMatrixLog(): SessionEvent[] {
+ const entries: { chunk: StreamChunk; time: number; ignorable?: true }[] = [
+ ...Array.from({ length: 5 }, (_, index) => ({
+ chunk: { type: 'text-delta' as const, index: 0, text: `text-${index}` },
+ time: 1_000 + index,
+ })),
+ ...Array.from({ length: 4 }, (_, index) => ({
+ chunk: { type: 'reasoning-delta' as const, index: 1, text: `reason-${index}` },
+ time: 990 - index,
+ })),
+ ...Array.from({ length: 4 }, (_, index) => ({
+ chunk: {
+ type: 'tool-call-delta' as const,
+ index: 2,
+ id: CallId('named-call'),
+ name: 'write',
+ argumentsDelta: `{${index}`,
+ },
+ time: 2_000 + index,
+ })),
+ ...Array.from({ length: 3 }, (_, index) => ({
+ chunk: {
+ type: 'tool-call-delta' as const,
+ index: 3,
+ id: CallId('unnamed-call'),
+ argumentsDelta: `${index}}`,
+ },
+ time: 3_000 + index,
+ })),
+ { chunk: { type: 'block-start', index: 4, blockType: 'text' }, time: 4_000 },
+ { chunk: { type: 'text-delta', index: 4, text: 'short-a' }, time: 4_001 },
+ { chunk: { type: 'text-delta', index: 4, text: 'short-b' }, time: 4_002 },
+ { chunk: { type: 'text-delta', index: 5, text: 'scalar-envelope' }, time: 4_003, ignorable: true },
+ { chunk: { type: 'finish', reason: { kind: 'stop' } }, time: 4_004 },
+ ]
+ return closedChunkLog(entries)
+}
+
+function storageTagCollisionLog(): SessionEvent[] {
+ return [
+ { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
+ ...['text-chunks', 'reasoning-chunks', 'tool-call-chunks'].map((type, index) => ({
+ type,
+ seq: index + 1,
+ time: index + 2,
+ data: { future: true },
+ ignorable: true as const,
+ }) as unknown as SessionEvent),
+ { type: 'turn/end', seq: 4, time: 5, data: { turn: 1, reason: { kind: 'completed' } } },
+ ]
+}
+
+function batches(events: readonly SessionEvent[], sizes: readonly number[]): SessionEvent[][] {
+ const result: SessionEvent[][] = []
+ let offset = 0
+ let index = 0
+ while (offset < events.length) {
+ const size = sizes[index % sizes.length] as number
+ result.push(events.slice(offset, offset + size))
+ offset += size
+ index += 1
+ }
+ return result
+}
+
+async function verifyBackend(
+ name: BackendName,
+ root: string,
+ events: readonly SessionEvent[],
+ sizes: readonly number[],
+): Promise {
+ const header = { ...meta('differential', '/work'), delegationDepth: 0 }
+ let mounted = await mount(name, root)
+ try {
+ await mounted.persistence.create(header)
+ for (const batch of batches(events, sizes)) {
+ await mounted.persistence.append(header.id, batch)
+ }
+ expect(await mounted.persistence.inspect(header.id), name).toEqual({ meta: header, events })
+ expect(await mounted.persistence.list(), name).toEqual([header])
+ const revision = (await mounted.persistence.listSnapshots())[0]?.revision
+ for (let fromSeq = 0; fromSeq <= events.length + 1; fromSeq += 1) {
+ expect((await mounted.persistence.readFrom(header.id, fromSeq)).events, `${name} seq ${fromSeq}`)
+ .toEqual(events.slice(fromSeq))
+ }
+ expect((await mounted.persistence.listSnapshots())[0]?.revision, name).toBe(revision)
+ } finally {
+ await mounted.dispose()
+ }
+
+ mounted = await mount(name, root)
+ try {
+ expect(await mounted.persistence.inspect(header.id), `${name} reopen`).toEqual({ meta: header, events })
+ } finally {
+ await mounted.dispose()
+ }
+}
+
+const streamChunkArbitrary: fc.Arbitrary = fc.oneof(
+ fc.record({ type: fc.constant<'text-delta'>('text-delta'), index: fc.nat(2), text: fc.string() }),
+ fc.record({ type: fc.constant<'reasoning-delta'>('reasoning-delta'), index: fc.nat(2), text: fc.string() }),
+ fc.record({
+ type: fc.constant<'tool-call-delta'>('tool-call-delta'),
+ index: fc.nat(2),
+ id: fc.constantFrom(CallId('call-1'), CallId('call-2')),
+ argumentsDelta: fc.string(),
+ }),
+ fc.record({
+ type: fc.constant<'tool-call-delta'>('tool-call-delta'),
+ index: fc.nat(2),
+ id: fc.constantFrom(CallId('call-1'), CallId('call-2')),
+ name: fc.constantFrom('read', 'write'),
+ argumentsDelta: fc.string(),
+ }),
+ fc.record({
+ type: fc.constant<'block-start'>('block-start'),
+ index: fc.nat(2),
+ blockType: fc.constant<'text'>('text'),
+ }),
+ fc.record({ type: fc.constant<'finish'>('finish'), reason: fc.constant({ kind: 'stop' as const }) }),
+)
+
+const randomWorkload = fc.record({
+ entries: fc.array(fc.record({
+ chunk: streamChunkArbitrary,
+ time: fc.oneof(
+ { weight: 4, arbitrary: fc.integer({ min: 0, max: 10_000 }) },
+ { weight: 1, arbitrary: fc.integer({ min: Number.MIN_SAFE_INTEGER, max: Number.MAX_SAFE_INTEGER }) },
+ ),
+ ignorable: fc.option(fc.constant(true), { nil: undefined }),
+ }), { maxLength: 30 }),
+ batchSizes: fc.array(fc.integer({ min: 1, max: 8 }), { minLength: 1, maxLength: 8 }),
+}).map(({ entries, batchSizes }) => ({
+ events: JSON.parse(JSON.stringify(closedChunkLog(entries.map(({ chunk, time, ignorable }) => ({
+ chunk,
+ time,
+ ...ignorable === true ? { ignorable } : {},
+ }))))) as SessionEvent[],
+ batchSizes,
+}))
+
+describe('SQLite cross-backend differential behavior', () => {
+ it('preserves ignorable logical events whose names match physical storage tags', async () => {
+ const events = storageTagCollisionLog()
+ const directory = await freshDirectory('dsh-sqlite-storage-tag-collision-')
+ const root = join(directory, 'sqlite')
+ await verifyBackend('sqlite', root, events, [2, 1])
+ const db = new DatabaseSync(join(root, 'sessions.db'), { readOnly: true })
+ try {
+ expect(db.prepare(testSql('count-physical-types')).all()).toEqual([])
+ expect(db.prepare(testSql('count-ignorable-events')).get()).toEqual({ count: 3 })
+ } finally {
+ db.close()
+ }
+ })
+
+ it('matches JSONL/Zstandard for every packed kind, scalar fallback, suffix, partition, and reopen', async () => {
+ const events = packingMatrixLog()
+ for (const [partitionIndex, sizes] of [[events.length], [1], [2, 1, 5, 3]].entries()) {
+ const directory = await freshDirectory(`dsh-sqlite-matrix-${partitionIndex}-`)
+ for (const name of ['jsonl-zstd', 'sqlite'] as const) {
+ const root = join(directory, name)
+ await verifyBackend(name, root, events, sizes)
+ if (name === 'sqlite') {
+ const db = new DatabaseSync(join(root, 'sessions.db'), { readOnly: true })
+ try {
+ expect(db.prepare(testSql('count-physical-types')).all()).toEqual([
+ [
+ { type: 'reasoning-chunks', count: 1 },
+ { type: 'text-chunks', count: 1 },
+ { type: 'tool-call-chunks', count: 2 },
+ ],
+ [],
+ [
+ { type: 'reasoning-chunks', count: 1 },
+ { type: 'text-chunks', count: 1 },
+ { type: 'tool-call-chunks', count: 1 },
+ ],
+ ][partitionIndex])
+ expect(db.prepare(testSql('count-ignorable-events')).get())
+ .toEqual({ count: 1 })
+ } finally {
+ db.close()
+ }
+ }
+ }
+ }
+ }, 30_000)
+
+ it('matches JSONL/Zstandard across randomized logical logs and append partitions', async () => {
+ await fc.assert(fc.asyncProperty(randomWorkload, async ({ events, batchSizes }) => {
+ const directory = await freshDirectory('dsh-sqlite-property-')
+ for (const name of ['jsonl-zstd', 'sqlite'] as const) {
+ await verifyBackend(name, join(directory, name), events, batchSizes)
+ }
+ }), { numRuns: 100, seed: 0x5A17E })
+ }, 60_000)
+
+})
diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/add-unexpected-column.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/add-unexpected-column.sql
new file mode 100644
index 0000000000..bc0d60887b
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/resources/sql/add-unexpected-column.sql
@@ -0,0 +1 @@
+ALTER TABLE events ADD COLUMN unexpected TEXT;
diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/count-events.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/count-events.sql
new file mode 100644
index 0000000000..b335590faa
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/resources/sql/count-events.sql
@@ -0,0 +1,2 @@
+SELECT COUNT(*) AS count
+FROM events;
diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/count-ignorable-events.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/count-ignorable-events.sql
new file mode 100644
index 0000000000..4de44570ec
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/resources/sql/count-ignorable-events.sql
@@ -0,0 +1,3 @@
+SELECT COUNT(*) AS count
+FROM events
+WHERE ignorable = 1;
diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/count-packed-events.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/count-packed-events.sql
new file mode 100644
index 0000000000..1c11b3dd0b
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/resources/sql/count-packed-events.sql
@@ -0,0 +1,3 @@
+SELECT COUNT(*) AS count
+FROM events
+WHERE type = 'text-chunks' AND ignorable = 0;
diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/count-physical-types.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/count-physical-types.sql
new file mode 100644
index 0000000000..ba5e7f9d72
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/resources/sql/count-physical-types.sql
@@ -0,0 +1,6 @@
+SELECT type, COUNT(*) AS count
+FROM events
+WHERE type IN ('text-chunks', 'reasoning-chunks', 'tool-call-chunks')
+ AND ignorable = 0
+GROUP BY type
+ORDER BY type;
diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/create-loose-schema.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/create-loose-schema.sql
new file mode 100644
index 0000000000..d63c09c540
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/resources/sql/create-loose-schema.sql
@@ -0,0 +1,14 @@
+CREATE TABLE persistence_state (singleton ANY, store_id ANY);
+CREATE TABLE sessions (
+ id ANY, version ANY, created_at ANY, cwd ANY, parent_session ANY,
+ seed_length ANY, origin ANY, delegation_depth ANY, agent_preset ANY,
+ incarnation ANY, revision ANY
+);
+CREATE TABLE events (
+ session_id ANY, seq ANY, type ANY, time ANY, data ANY,
+ source_event_seqs ANY, surface_op ANY, ignorable ANY
+);
+INSERT INTO persistence_state (singleton, store_id)
+VALUES (1, '00000000-0000-4000-8000-000000000000');
+PRAGMA application_id = 1146308688;
+PRAGMA user_version = 17;
diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/create-unrelated-table.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/create-unrelated-table.sql
new file mode 100644
index 0000000000..23c813df9a
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/resources/sql/create-unrelated-table.sql
@@ -0,0 +1 @@
+CREATE TABLE unrelated (value TEXT);
diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/delete-persistence-state.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/delete-persistence-state.sql
new file mode 100644
index 0000000000..c337450451
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/resources/sql/delete-persistence-state.sql
@@ -0,0 +1 @@
+DELETE FROM persistence_state;
diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/delete-session-events.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/delete-session-events.sql
new file mode 100644
index 0000000000..c8f86c2f8f
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/resources/sql/delete-session-events.sql
@@ -0,0 +1,2 @@
+DELETE FROM events
+WHERE session_id = ?;
diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/empty-store-id.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/empty-store-id.sql
new file mode 100644
index 0000000000..5d3a64c1e5
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/resources/sql/empty-store-id.sql
@@ -0,0 +1,3 @@
+UPDATE persistence_state
+SET store_id = ''
+WHERE singleton = 1;
diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/insert-corrupt-event.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/insert-corrupt-event.sql
new file mode 100644
index 0000000000..82eb0af005
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/resources/sql/insert-corrupt-event.sql
@@ -0,0 +1,2 @@
+INSERT INTO events (session_id, seq, type, time, data, ignorable)
+VALUES (?, ?, ?, ?, ?, ?);
diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/measure-write-traffic.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/measure-write-traffic.sql
new file mode 100644
index 0000000000..3ca0e6bd0c
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/resources/sql/measure-write-traffic.sql
@@ -0,0 +1,3 @@
+SELECT COUNT(*) AS rows,
+ COALESCE(MAX(length(CAST(data AS BLOB))), 0) AS largest
+FROM events;
diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/replace-events-with-nonstrict-table.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/replace-events-with-nonstrict-table.sql
new file mode 100644
index 0000000000..e39da0a3f4
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/resources/sql/replace-events-with-nonstrict-table.sql
@@ -0,0 +1,14 @@
+PRAGMA foreign_keys = OFF;
+ALTER TABLE events RENAME TO strict_events;
+CREATE TABLE events (
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
+ seq INTEGER NOT NULL,
+ type TEXT NOT NULL,
+ time INTEGER NOT NULL,
+ data TEXT NOT NULL,
+ source_event_seqs TEXT,
+ surface_op TEXT,
+ ignorable INTEGER,
+ PRIMARY KEY (session_id, seq)
+);
+DROP TABLE strict_events;
diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/select-event-rowids.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/select-event-rowids.sql
new file mode 100644
index 0000000000..91c3167273
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/resources/sql/select-event-rowids.sql
@@ -0,0 +1,3 @@
+SELECT seq, rowid
+FROM events
+ORDER BY seq;
diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/select-event-rows.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/select-event-rows.sql
new file mode 100644
index 0000000000..037c34641a
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/resources/sql/select-event-rows.sql
@@ -0,0 +1,4 @@
+SELECT rowid, seq, type, time, data, source_event_seqs, surface_op, ignorable
+FROM events
+WHERE session_id = ?
+ORDER BY seq;
diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/select-last-event.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/select-last-event.sql
new file mode 100644
index 0000000000..14b6e17742
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/resources/sql/select-last-event.sql
@@ -0,0 +1,5 @@
+SELECT seq, type, data
+FROM events
+WHERE session_id = ?
+ORDER BY seq DESC
+LIMIT 1;
diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/select-user-version.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/select-user-version.sql
new file mode 100644
index 0000000000..4edeca1a4d
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/resources/sql/select-user-version.sql
@@ -0,0 +1 @@
+PRAGMA user_version;
diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/set-application-id-12345.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/set-application-id-12345.sql
new file mode 100644
index 0000000000..79d31a3a57
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/resources/sql/set-application-id-12345.sql
@@ -0,0 +1 @@
+PRAGMA application_id = 12345;
diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/set-user-version-15.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/set-user-version-15.sql
new file mode 100644
index 0000000000..fa5f49e3b2
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/resources/sql/set-user-version-15.sql
@@ -0,0 +1 @@
+PRAGMA user_version = 15;
diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/set-user-version-16.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/set-user-version-16.sql
new file mode 100644
index 0000000000..0750749350
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/resources/sql/set-user-version-16.sql
@@ -0,0 +1 @@
+PRAGMA user_version = 16;
diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/set-user-version-17.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/set-user-version-17.sql
new file mode 100644
index 0000000000..5aac576e8c
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/resources/sql/set-user-version-17.sql
@@ -0,0 +1 @@
+PRAGMA user_version = 17;
diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/update-invalid-session-metadata.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/update-invalid-session-metadata.sql
new file mode 100644
index 0000000000..a4d6b530a2
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/resources/sql/update-invalid-session-metadata.sql
@@ -0,0 +1,3 @@
+UPDATE sessions
+SET origin = 'external', delegation_depth = -1, seed_length = -1
+WHERE id = ?;
diff --git a/packages/session/session-persistence-sqlite/tests/sql-resource-boundary.spec.ts b/packages/session/session-persistence-sqlite/tests/sql-resource-boundary.spec.ts
new file mode 100644
index 0000000000..8574d1c97f
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/sql-resource-boundary.spec.ts
@@ -0,0 +1,102 @@
+import { readdir, readFile } from 'node:fs/promises'
+import { fileURLToPath } from 'node:url'
+import ts from 'typescript'
+import { describe, expect, it } from 'vitest'
+
+const PACKAGE_ROOT = fileURLToPath(new URL('../', import.meta.url))
+const SQL_LITERAL = /^\s*(?:ALTER|ATTACH|BEGIN|COMMIT|CREATE|DELETE|DETACH|DROP|INSERT|PRAGMA|REINDEX|RELEASE|ROLLBACK|SAVEPOINT|SELECT|UPDATE|VACUUM|WITH)\s/iu // eslint-disable-line @stylistic/max-len
+
+async function filesUnder(path: string): Promise {
+ const entries = await readdir(path, { withFileTypes: true })
+ return (await Promise.all(entries.map(async entry => entry.isDirectory()
+ ? filesUnder(`${path}/${entry.name}`)
+ : [`${path}/${entry.name}`]))).flat()
+}
+
+function sqlLiteralText(node: ts.Node): string | undefined {
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text
+ if (node.kind === ts.SyntaxKind.TemplateHead) {
+ return (node as ts.Node & { readonly text: string }).text
+ }
+ return undefined
+}
+
+function isOwnedSqlSource(node: ts.Expression | undefined, source: ts.SourceFile): boolean {
+ if (node === undefined) return false
+ if (ts.isCallExpression(node)
+ && ts.isIdentifier(node.expression)
+ && (node.expression.text === 'sql' || node.expression.text === 'testSql')) return true
+ if (!ts.isIdentifier(node) || node.text !== 'source') return false
+ const call = node.parent
+ if (!ts.isCallExpression(call)
+ || call.arguments.length !== 1
+ || call.arguments[0] !== node
+ || !ts.isPropertyAccessExpression(call.expression)
+ || call.expression.expression.kind !== ts.SyntaxKind.SuperKeyword
+ || call.expression.name.text !== 'prepare') return false
+ let method: ts.Node | undefined = node.parent
+ while (method !== undefined && !ts.isMethodDeclaration(method)) method = method.parent
+ if (method === undefined
+ || method.name.getText(source) !== 'prepare'
+ || method.parameters.length !== 1
+ || method.parameters[0]?.name.getText(source) !== 'source') return false
+ let classNode: ts.Node | undefined = method.parent
+ while (classNode !== undefined && !ts.isClassExpression(classNode)) classNode = classNode.parent
+ if (classNode === undefined || classNode.name?.text !== 'JournalFailureDatabase') return false
+ const guard = method.body?.statements[0]
+ if (guard === undefined
+ || !ts.isIfStatement(guard)
+ || !ts.isBinaryExpression(guard.expression)
+ || guard.expression.operatorToken.kind !== ts.SyntaxKind.ExclamationEqualsEqualsToken
+ || guard.expression.left.getText(source) !== 'source'
+ || guard.expression.right.getText(source) !== "sql('journal-mode-wal')") return false
+ return ts.isReturnStatement(guard.thenStatement)
+ && guard.thenStatement.expression === call
+}
+
+describe('SQLite SQL resource boundary', () => {
+ it('keeps statements and query assembly out of TypeScript files', async () => {
+ const files = (await Promise.all([
+ filesUnder(`${PACKAGE_ROOT}/src`),
+ filesUnder(`${PACKAGE_ROOT}/tests`),
+ ])).flat().filter(path => path.endsWith('.ts'))
+ const violations: string[] = []
+ for (const path of files) {
+ const source = ts.createSourceFile(path, await readFile(path, 'utf8'), ts.ScriptTarget.Latest, true)
+ const usesNodeSqlite = source.statements.some(statement => ts.isImportDeclaration(statement)
+ && ts.isStringLiteral(statement.moduleSpecifier)
+ && statement.moduleSpecifier.text === 'node:sqlite')
+ const visit = (node: ts.Node): void => {
+ const literal = sqlLiteralText(node)
+ if (literal !== undefined && SQL_LITERAL.test(literal)) {
+ violations.push(`${path}:${source.getLineAndCharacterOfPosition(node.getStart()).line + 1}: SQL literal`)
+ }
+ // Awaited prepare() is SessionPersistence; DatabaseSync.prepare() is synchronous.
+ if (usesNodeSqlite
+ && ts.isCallExpression(node)
+ && ts.isPropertyAccessExpression(node.expression)
+ && (node.expression.name.text === 'exec'
+ || (node.expression.name.text === 'prepare' && !ts.isAwaitExpression(node.parent)))) {
+ const argument = node.arguments[0]
+ if (!isOwnedSqlSource(argument, source)) {
+ violations.push(`${path}:${source.getLineAndCharacterOfPosition(node.getStart()).line + 1}: unowned query source`)
+ }
+ }
+ ts.forEachChild(node, visit)
+ }
+ visit(source)
+ }
+ expect(violations).toEqual([])
+ })
+
+ it('keeps resource text static instead of interpolated', async () => {
+ const files = (await Promise.all([
+ filesUnder(`${PACKAGE_ROOT}/resources/sql`),
+ filesUnder(`${PACKAGE_ROOT}/tests/resources/sql`),
+ ])).flat()
+ for (const path of files) {
+ expect(path.endsWith('.sql')).toBe(true)
+ expect(await readFile(path, 'utf8')).not.toContain('${')
+ }
+ })
+})
diff --git a/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts
index 8f0c49e71f..f49d5d8e69 100644
--- a/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts
+++ b/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts
@@ -1,949 +1,833 @@
-import { createUserMessage, createMessage } from '@deepseek-ai/dsh-llm'
-import { afterEach, describe, expect, it } from 'vitest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { spawn } from 'node:child_process'
import { Context } from '@deepseek-ai/cordis'
-import { existsSync } from 'node:fs'
-import { chmod, mkdtemp, rm, stat, symlink, writeFile } from 'node:fs/promises'
+import { once } from 'node:events'
+import { chmod, mkdir, mkdtemp, rm, stat, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
-import { dirname, join } from 'node:path'
+import { join } from 'node:path'
+import { performance } from 'node:perf_hooks'
+import { pathToFileURL } from 'node:url'
import { DatabaseSync } from 'node:sqlite'
-import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
-import type { Session, SessionEvent, SurfaceEvent, SurfaceEventType } from '@deepseek-ai/dsh-session'
-import SqliteSessionPersistence, { SCHEMA_VERSION } from '@deepseek-ai/dsh-session-persistence-sqlite'
+import Loader from '@deepseek-ai/cordis-plugin-loader'
+import Include from '@deepseek-ai/cordis-plugin-include'
+import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
+import SessionPersistenceSqlite, {
+ DEFAULT_BUSY_TIMEOUT_MS,
+ SCHEMA_VERSION,
+} from '@deepseek-ai/dsh-session-persistence-sqlite'
import {
+ runCoordinatorContract,
+ type CoordinatorFixture,
+} from '../../session-persistence/tests/coordinator-contract.ts'
+import {
+ meta,
+ runPersistenceContract,
+} from '../../session-persistence/tests/contract.ts'
+import { MAX_PACKED_DATA_BYTES } from '../src/codec.ts'
+import {
+ decodeEventRow,
+ decodeSessionRow,
+ decodeStoreIdentity,
openDatabase,
- rowToEvent,
+ validateSchemaForMutation,
rowToMeta,
- scanRows,
SESSION_PERSISTENCE_SQLITE_APPLICATION_ID,
- type EventRow,
+ type SessionRow,
} from '../src/schema.ts'
-import { runPersistenceContract, meta, oneTurnLog, appendLog } from '../../session-persistence/tests/contract.ts'
-import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts'
+import { SqliteStore } from '../src/store.ts'
+import { sql } from '../src/sql.ts'
+import { testSql } from './test-sql.ts'
const dirs: string[] = []
-afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) })
+afterEach(async () => {
+ for (const directory of dirs.splice(0)) await rm(directory, { recursive: true, force: true })
+})
-async function expectFlushError(promise: Promise, message: RegExp): Promise {
- try {
- await promise
- } catch (error) {
- expect(error).toBeInstanceOf(Error)
- expect((error as Error).message).toMatch(message)
- return
- }
- throw new Error('expected flush to reject')
+async function freshDbPath(prefix = 'dsh-sqlite-'): Promise {
+ const directory = await mkdtemp(join(tmpdir(), prefix))
+ dirs.push(directory)
+ return join(directory, 'sessions.db')
}
-async function freshDbPath(): Promise {
- const dir = await mkdtemp(join(tmpdir(), 'dsh-sqlite-'))
- dirs.push(dir)
- return join(dir, 'sessions.db')
-}
-
-/** A context with the session store + SQLite backend, plus a teardown. */
-async function backend(path = ':memory:'): Promise<{ ctx: Context; dispose: () => Promise }> {
+async function backendFailure(path: string): Promise {
const ctx = new Context()
await ctx.plugin(SessionStore)
- const fiber = await ctx.plugin(SqliteSessionPersistence, { path })
- return { ctx, dispose: () => fiber.dispose() }
+ try {
+ await ctx.plugin(SessionPersistenceSqlite, { path })
+ await ctx.sessionPersistence.list()
+ return undefined
+ } catch (error: unknown) {
+ return error
+ } finally {
+ await ctx.fiber.dispose()
+ }
+}
+
+function errorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error)
+}
+
+function databaseWithJournalFailure(
+ nextFailure: () => Error | undefined,
+): typeof DatabaseSync {
+ return class JournalFailureDatabase extends DatabaseSync {
+ override prepare(source: string) {
+ if (source !== sql('journal-mode-wal')) return super.prepare(source)
+ const statement = super.prepare(sql('journal-mode-wal'))
+ const get = statement.get.bind(statement)
+ Object.defineProperty(statement, 'get', {
+ value: () => {
+ const failure = nextFailure()
+ if (failure !== undefined) throw failure
+ return get()
+ },
+ })
+ return statement
+ }
+ }
+}
+
+function chunk(seq: number, text = `token-${seq}`): SessionEvent {
+ return {
+ type: 'assistant/chunk',
+ seq,
+ time: 1_000 + seq,
+ data: {
+ turn: 1,
+ step: 1,
+ chunk: { type: 'text-delta', index: 0, text },
+ },
+ }
+}
+
+function chunkLog(count: number): SessionEvent[] {
+ return [
+ { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
+ { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
+ ...Array.from({ length: count }, (_, index) => chunk(index + 2)),
+ { type: 'step/end', seq: count + 2, time: count + 3, data: { turn: 1, step: 1 } },
+ {
+ type: 'turn/end',
+ seq: count + 3,
+ time: count + 4,
+ data: { turn: 1, reason: { kind: 'completed' } },
+ },
+ ]
+}
+
+async function measureWriteTraffic(
+ path: string,
+ events: readonly SessionEvent[],
+): Promise<{
+ readonly walBytes: number
+ readonly idleWalBytes: number
+ readonly rows: number
+ readonly largest: number
+ readonly inserted: number
+ readonly changed: number
+ readonly removed: number
+}> {
+ interface PhysicalRow {
+ readonly rowid: number
+ readonly seq: number
+ readonly type: string
+ readonly time: number
+ readonly data: string | Uint8Array
+ readonly source_event_seqs: Uint8Array | null
+ readonly surface_op: string | null
+ readonly ignorable: number | null
+ }
+ const sameValue = (left: string | Uint8Array | null, right: string | Uint8Array | null): boolean => (
+ typeof left === 'string' || left === null
+ ? left === right
+ : right instanceof Uint8Array && Buffer.from(left).equals(Buffer.from(right))
+ )
+ const sameRow = (left: PhysicalRow, right: PhysicalRow): boolean => (
+ left.rowid === right.rowid
+ && left.seq === right.seq
+ && left.type === right.type
+ && left.time === right.time
+ && sameValue(left.data, right.data)
+ && sameValue(left.source_event_seqs, right.source_event_seqs)
+ && left.surface_op === right.surface_op
+ && left.ignorable === right.ignorable
+ )
+ const ctx = new Context()
+ await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionPersistenceSqlite, { path, writeBatchMaxDelayMs: 200 })
+ try {
+ const header = meta('traffic')
+ await ctx.sessionPersistence.create(header)
+ let previous = new Map()
+ let inserted = 0
+ let changed = 0
+ let removed = 0
+ const probe = new DatabaseSync(path, { readOnly: true })
+ try {
+ const selectRows = probe.prepare(testSql('select-event-rows'))
+ for (let offset = 0; offset < events.length; offset += 40) {
+ await ctx.sessionPersistence.append(header.id, events.slice(offset, offset + 40))
+ const current = new Map((selectRows.all(header.id) as unknown as PhysicalRow[])
+ .map(row => [row.seq, row]))
+ for (const [seq, row] of current) {
+ const old = previous.get(seq)
+ if (old === undefined) inserted += 1
+ else if (!sameRow(old, row)) changed += 1
+ }
+ for (const seq of previous.keys()) if (!current.has(seq)) removed += 1
+ previous = current
+ }
+ } finally {
+ probe.close()
+ }
+ const db = new DatabaseSync(path, { readOnly: true })
+ const measured = db.prepare(testSql('measure-write-traffic')).get() as { rows: number; largest: number }
+ db.close()
+ const walBytes = (await stat(`${path}-wal`)).size
+ await new Promise(resolve => setTimeout(resolve, 250))
+ return {
+ walBytes,
+ idleWalBytes: (await stat(`${path}-wal`)).size,
+ rows: measured.rows,
+ largest: measured.largest,
+ inserted,
+ changed,
+ removed,
+ }
+ } finally {
+ await ctx.fiber.dispose()
+ }
}
-// Run the same backend-agnostic contract as JSONL to pin identical semantics.
runPersistenceContract('sqlite', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
- const fiber = await ctx.plugin(SqliteSessionPersistence, { path: ':memory:' })
+ const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: ':memory:' })
return {
persistence: ctx.sessionPersistence,
dispose: async () => { await fiber.dispose() },
}
})
-// A file-backed database lets two mounts share rows across reload. `corruptTail` inserts invalid
-// JSON past the committed seq, exercising coordinator repair against real database rows.
runCoordinatorContract('sqlite', async (): Promise => {
- const dir = await mkdtemp(join(tmpdir(), 'dsh-sqlite-coord-'))
- const path = join(dir, 'sessions.db')
+ const directory = await mkdtemp(join(tmpdir(), 'dsh-sqlite-coord-'))
+ const path = join(directory, 'sessions.db')
return {
- mount: async ctx => ctx.plugin(SqliteSessionPersistence, { path }),
+ mount: async ctx => ctx.plugin(SessionPersistenceSqlite, { path }),
corruptTail: async (id) => {
- // A row past the committed region whose `data` does not parse: scanRows
- // bounds the preserved prefix at it and returns its seq as tornFrom, which
- // the backend surfaces to the coordinator as the tornMarker to delete from.
- const db = openDatabase(path, 'wal')
- const next = (db.prepare('SELECT COALESCE(MAX(seq), -1) + 1 AS n FROM events WHERE session_id = ?')
- .get(id) as { n: number }).n
- db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, ?, ?, ?, ?)')
- .run(id, next, 'assistant/chunk', 99, '{not valid json')
+ const db = new DatabaseSync(path)
+ const last = db.prepare(testSql('select-last-event'))
+ .get(id) as { seq: number; type: string; data: string }
+ const logicalLength = last.type === 'text-chunks'
+ ? (JSON.parse(last.data) as { texts: string[] }).texts.length
+ : 1
+ const next = last.seq + logicalLength
+ db.prepare(testSql('insert-corrupt-event'))
+ .run(id, next, 'assistant/chunk', 99, '{not valid json', null)
db.close()
},
- cleanup: async () => { await rm(dir, { recursive: true, force: true }) },
+ cleanup: async () => { await rm(directory, { recursive: true, force: true }) },
}
})
-describe('scanRows', () => {
- // scanRows works off EventRows (data is a JSON string column); build them from SessionEvents
- // so the unit tests read in terms of the event vocabulary. Surface metadata is serialized to
- // its nullable columns so the conversion remains faithful.
- const rows = (events: SessionEvent[]): EventRow[] =>
- events.map((e) => {
- const se = e as SessionEvent
- return {
- seq: e.seq, type: e.type, time: e.time, data: JSON.stringify(e.data),
- source_event_seqs: se.sourceEventSeqs !== undefined ? JSON.stringify(se.sourceEventSeqs) : null,
- surface_op: se.surfaceOp !== undefined ? JSON.stringify(se.surfaceOp) : null,
- ignorable: e.ignorable === true ? 1 : null,
- }
+describe('SessionPersistenceSqlite physical packing', () => {
+ it('loads from cordis.yml and packs through the assembled service', async () => {
+ const path = await freshDbPath('dsh-sqlite-loader-')
+ const configPath = join(path, '..', 'cordis.yml')
+ await writeFile(configPath, [
+ "- name: '@deepseek-ai/dsh-session'",
+ "- name: '@deepseek-ai/dsh-session-persistence-sqlite'",
+ ' config:',
+ ` path: ${JSON.stringify(path)}`,
+ '',
+ ].join('\n'))
+
+ const ctx = new Context()
+ ctx.baseUrl = pathToFileURL(join(path, '..')).href + '/'
+ await ctx.plugin(Loader)
+ ctx.loader.builtins.include = Include
+ ctx.loader.internal = {
+ version: 'sqlite',
+ async import(specifier: string) {
+ if (specifier === '@deepseek-ai/dsh-session') return SessionStore
+ if (specifier === '@deepseek-ai/dsh-session-persistence-sqlite') {
+ return SessionPersistenceSqlite
+ }
+ throw new Error(`unexpected Loader import: ${specifier}`)
+ },
+ } as unknown as NonNullable
+ await ctx.loader.create({
+ name: 'cordis:include',
+ config: { path: pathToFileURL(configPath).href },
})
+ await ctx.loader.await()
- it('preserves the full log when it ends exactly on a turn/end (no torn tail)', () => {
- const { preserved, tornFrom } = scanRows(rows(oneTurnLog()))
- expect(preserved).toEqual(oneTurnLog())
- expect(tornFrom).toBeUndefined()
- })
-
- it('PRESERVES the real events of an interrupted turn after the last turn/end', () => {
- // turn 1 committed (0..5) + a crashed turn 2 (turn/start 6, step/start 7, no
- // close): all 8 rows are intact, so the whole prefix is preserved and there
- // is no torn fragment to delete. (load() then synthesizes the closers.)
- const withOpenTurn: SessionEvent[] = [
- ...oneTurnLog(),
- { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } },
- { type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } },
- ]
- const { preserved, tornFrom } = scanRows(rows(withOpenTurn))
- expect(preserved.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
- expect(tornFrom).toBeUndefined()
- })
-
- it('preserves the contiguous prefix and flags a torn tail at a seq gap', () => {
- // A gap after seq 0 (no committed turn/end): seq 0 is the preserved
- // interrupted-turn event; the gap bounds it and marks the torn fragment.
- const gapped: SessionEvent[] = [
- { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
- { type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }, // seq 1 missing
- ]
- const { preserved, tornFrom } = scanRows(rows(gapped))
- expect(preserved.map(e => e.seq)).toEqual([0])
- expect(tornFrom).toBe(1)
- })
-
- it('an empty log preserves nothing and has no torn tail', () => {
- expect(scanRows([])).toEqual({ preserved: [] })
- })
-
- it('throws on a seq gap inside the committed region (before the last turn/end)', () => {
- const gapped: SessionEvent[] = [
- { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
- { type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }, // seq 1 missing
- { type: 'turn/end', seq: 3, time: 3, data: { turn: 1, reason: { kind: 'completed' } } },
- ]
- expect(() => scanRows(rows(gapped))).toThrow(/seq gap in committed region/)
- })
-
- it('throws on an unparsable row inside the committed region', () => {
- const withCorruptCommitted: EventRow[] = [
- { seq: 0, type: 'turn/start', time: 1, data: '{not json', source_event_seqs: null, surface_op: null, ignorable: null }, // corrupt, sits before a turn/end
- { seq: 1, type: 'turn/end', time: 2, data: JSON.stringify({ turn: 1, reason: { kind: 'completed' } }), source_event_seqs: null, surface_op: null, ignorable: null },
- ]
- expect(() => scanRows(withCorruptCommitted)).toThrow(/unparsable committed event/)
- })
-
- it('tolerates an unparsable torn-tail row after the last turn/end', () => {
- const withCorruptTail: EventRow[] = [
- ...rows(oneTurnLog()),
- { seq: 6, type: 'turn/start', time: 7, data: '{not json', source_event_seqs: null, surface_op: null, ignorable: null }, // torn fragment, no committed turn/end after
- ]
- const { preserved, tornFrom } = scanRows(withCorruptTail)
- expect(preserved).toEqual(oneTurnLog())
- expect(tornFrom).toBe(6)
- })
-})
-
-describe('rowToMeta', () => {
- it('restores optional origin metadata', () => {
- expect(rowToMeta({
- id: 'with-origin',
- version: 0,
- created_at: 1,
- cwd: null,
- parent_session: null,
- seed_length: null,
- origin: 'subagent',
- incarnation: 'with-origin',
- revision: 1,
- delegation_depth: null,
- agent_preset: null,
- })).toMatchObject({ id: 'with-origin', origin: 'subagent' })
- })
-
- it('rejects fractional stored creation metadata', () => {
- expect(() => rowToMeta({
- id: 'fractional',
- version: 0,
- created_at: 1.5,
- cwd: null,
- parent_session: null,
- seed_length: null,
- origin: null,
- incarnation: 'fractional',
- revision: 1,
- delegation_depth: null,
- agent_preset: null,
- })).toThrow('stored session createdAt must be a non-negative safe integer')
- })
-
- it('restores the agent preset a session was composed from', () => {
- // The preset decides the resumed session's tools and prompt; a row that
- // dropped it would rebuild a composition the stored history contradicts.
- expect(rowToMeta({
- id: 'composed',
- version: 0,
- created_at: 1,
- cwd: null,
- parent_session: null,
- seed_length: null,
- origin: null,
- incarnation: 'composed',
- revision: 1,
- delegation_depth: null,
- agent_preset: 'minimal',
- })).toMatchObject({ agentPreset: 'minimal' })
- })
-})
-
-describe('SqliteSessionPersistence: durability and crash semantics', () => {
- it('rejects a stored v0 log containing a legacy request/header-delta event', async () => {
- const path = await freshDbPath()
- const m = meta('legacy-header-delta', '/legacy')
- const db = openDatabase(path, 'wal')
- db.prepare('INSERT INTO sessions (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, incarnation, revision) VALUES (?, ?, ?, ?, NULL, NULL, NULL, ?, 1)')
- .run(m.id, m.version, m.createdAt, m.cwd ?? null, 'legacy-header-delta')
- const insert = db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, ?, ?, ?, ?)')
- insert.run(m.id, 0, 'turn/start', 1, JSON.stringify({ turn: 1 }))
- insert.run(m.id, 1, 'request/header-delta', 2, JSON.stringify({ config: { model: 'legacy' } }))
- insert.run(m.id, 2, 'turn/end', 3, JSON.stringify({ turn: 1, reason: { kind: 'completed' } }))
- db.close()
-
- const mounted = await backend(path)
- await expect(mounted.ctx.sessionPersistence.load(m.id)).rejects.toThrow(/unsupported legacy request\/header-delta event at seq 1/)
- await mounted.dispose()
- })
-
- it('rejects a stored v0 full header carrying the legacy fallback reason', async () => {
- const path = await freshDbPath()
- const m = meta('legacy-header-fallback', '/legacy')
- const db = openDatabase(path, 'wal')
- db.prepare('INSERT INTO sessions (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, incarnation, revision) VALUES (?, ?, ?, ?, NULL, NULL, NULL, ?, 1)')
- .run(m.id, m.version, m.createdAt, m.cwd ?? null, 'legacy-header-fallback')
- db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, ?, ?, ?, ?)')
- .run(m.id, 0, 'request/header', 1, JSON.stringify({
- header: { config: { model: 'legacy' } },
- reason: 'fallback',
- }))
- db.close()
-
- const mounted = await backend(path)
- await expect(mounted.ctx.sessionPersistence.load(m.id))
- .rejects.toThrow(/unsupported legacy request\/header reason "fallback" at seq 0/)
- await mounted.dispose()
- })
-
- it('has no independent per-session log location', async () => {
- const { ctx, dispose } = await backend()
- expect(ctx.sessionPersistence.locate(meta('sqlite-location'))).toBeUndefined()
- await dispose()
- })
-
- it('an interrupted turn (rows after the last turn/end) is PRESERVED and closed during load', async () => {
- const path = await freshDbPath()
- const m = meta('crash')
- // Run 1: persist a complete turn, then a half-written second turn (no turn/end).
- const ctx1 = new Context()
- await ctx1.plugin(SessionStore)
- const fiber1 = await ctx1.plugin(SqliteSessionPersistence, { path })
- await ctx1.sessionPersistence.create(m)
- await ctx1.sessionPersistence.append(m.id, oneTurnLog())
- await ctx1.sessionPersistence.append(m.id, [
- { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } },
- { type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } },
- ])
- await fiber1.dispose()
-
- // Run 2: load PRESERVES the interrupted turn's real events (a turn can be huge
- // — never truncated) and closes the orphaned turn with synthetic boundary
- // events: step/end (the step was open) then turn/end {interrupted}.
- const ctx2 = new Context()
- await ctx2.plugin(SessionStore)
- const fiber2 = await ctx2.plugin(SqliteSessionPersistence, { path })
- const loaded = await ctx2.sessionPersistence.load(m.id)
- expect(loaded.events.map(e => e.type)).toEqual([
- 'turn/start', 'user/message', 'step/start', 'assistant/message', 'step/end', 'turn/end', // turn 1
- 'turn/start', 'step/start', 'step/end', 'turn/end', // turn 2: real events + synthetic closers
- ])
- expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
- const last = loaded.events.at(-1)!
- expect(last.type === 'turn/end' && last.data.reason).toEqual({ kind: 'interrupted' })
-
- // load durably closed the turn, so the next append continues at the balanced
- // length (seq 10) and a reload round-trips identically.
- await ctx2.sessionPersistence.append(m.id, [
- { type: 'turn/start', seq: 10, time: 9, data: { turn: 3 } },
- { type: 'turn/end', seq: 11, time: 10, data: { turn: 3, reason: { kind: 'completed' } } },
- ])
- const reloaded = await ctx2.sessionPersistence.load(m.id)
- expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])
- await fiber2.dispose()
- })
-
- it('load() durably closes the interrupted turn: the synthetic closers are on disk after load', async () => {
- const path = await freshDbPath()
- const m = meta('load-closes')
- const b1 = await backend(path)
- await b1.ctx.sessionPersistence.create(m)
- await b1.ctx.sessionPersistence.append(m.id, oneTurnLog()) // seqs 0..5
- await b1.dispose()
- // Hand-write an interrupted turn (turn/start seq 6, no turn/end).
- const db = openDatabase(path, 'wal')
- db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, 6, ?, 7, ?)')
- .run(m.id, 'turn/start', JSON.stringify({ turn: 2 }))
- db.close()
-
- const b2 = await backend(path)
- const loaded = await b2.ctx.sessionPersistence.load(m.id)
- // turn 2's real turn/start (seq 6) is preserved + a synthetic turn/end (seq 7).
- expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
- expect(loaded.events.at(-1)!.type).toBe('turn/end')
- // load() is mutating: the synthetic turn/end MUST be on disk so the stored log
- // is balanced and the cursor is truthful (contract: load closes, not defers).
- const probe = openDatabase(path, 'wal')
- const stored = probe.prepare('SELECT seq, type FROM events WHERE session_id = ? ORDER BY seq').all(m.id) as { seq: number; type: string }[]
- probe.close()
- expect(stored.map(r => r.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
- expect(stored.at(-1)!.type).toBe('turn/end')
- await b2.dispose()
- })
-
- it('all-tail load: a session whose only turn never closed is preserved and closed on load', async () => {
- const path = await freshDbPath()
- const m = meta('all-tail')
- const b1 = await backend(path)
- await b1.ctx.sessionPersistence.create(m)
- // A first turn that NEVER completed: turn/start + user/message, no turn/end.
- await b1.ctx.sessionPersistence.append(m.id, [
- { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
- { type: 'user/message', seq: 1, time: 2, data: createUserMessage({
- content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
- }), surfaceOp: 'append' },
- ])
- await b1.dispose()
-
- // A fresh backend loads it: the interrupted (only) turn's real events are
- // preserved and closed with a synthetic turn/end {interrupted} — NOT
- // truncated. The session was materialized, so list() reports it present.
- const b2 = await backend(path)
- const loaded = await b2.ctx.sessionPersistence.load(m.id)
- expect(loaded.events.map(e => e.type)).toEqual(['turn/start', 'user/message', 'turn/end'])
- expect(loaded.events.at(-1)!.type === 'turn/end' && loaded.events.at(-1)!.data).toMatchObject({ reason: { kind: 'interrupted' } })
- expect((await b2.ctx.sessionPersistence.list()).map(x => x.id)).toContain(m.id)
- await b2.dispose()
- })
-
- it('rejects opening a database whose schema version is not the current build (newer OR older)', async () => {
- const path = await freshDbPath()
- openDatabase(path, 'wal').close() // stamp user_version = SCHEMA_VERSION
- // Bump user_version past what this build supports.
- const dbNewer = openDatabase(path, 'wal')
- dbNewer.exec(`PRAGMA user_version = ${SCHEMA_VERSION + 1}`)
- dbNewer.close()
- expect(() => openDatabase(path, 'wal')).toThrow(/incompatible with this build/)
-
- // The immediately preceding layout lacks the required store identity and is
- // rejected rather than migrated (unreleased software, no backward-compat).
- const olderPath = await freshDbPath()
- openDatabase(olderPath, 'wal').close()
- const dbOlder = openDatabase(olderPath, 'wal')
- dbOlder.exec(`PRAGMA user_version = ${SCHEMA_VERSION - 1}`)
- dbOlder.close()
- expect(() => openDatabase(olderPath, 'wal')).toThrow(/incompatible with this build/)
- })
-
- it('rejects a table-backed unversioned database before stamping or changing journal mode', async () => {
- const path = await freshDbPath()
- const legacy = new DatabaseSync(path)
- legacy.exec('CREATE TABLE sessions (id TEXT PRIMARY KEY)')
- legacy.close()
-
- expect(() => openDatabase(path, 'wal')).toThrow(/unversioned schema or application identity/)
-
- const unchanged = new DatabaseSync(path)
- expect(unchanged.prepare('PRAGMA user_version').get()).toEqual({ user_version: 0 })
- expect(unchanged.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'delete' })
- expect(unchanged.prepare(
- "SELECT name FROM sqlite_schema WHERE type = 'table' AND name = 'sessions'",
- ).get()).toEqual({ name: 'sessions' })
- unchanged.close()
- })
-
- it('counts a sqliteX table as user-owned instead of mistaking it for SQLite metadata', async () => {
- const path = await freshDbPath()
- const unrelated = new DatabaseSync(path)
- unrelated.exec('CREATE TABLE sqliteX (value TEXT)')
- unrelated.exec("INSERT INTO sqliteX VALUES ('safe')")
- unrelated.close()
-
- expect(() => openDatabase(path, 'wal')).toThrow(/unversioned schema or application identity/)
-
- const unchanged = new DatabaseSync(path)
- expect(unchanged.prepare('SELECT value FROM sqliteX').get()).toEqual({ value: 'safe' })
- expect(unchanged.prepare('PRAGMA application_id').get()).toEqual({ application_id: 0 })
- expect(unchanged.prepare('PRAGMA user_version').get()).toEqual({ user_version: 0 })
- expect(unchanged.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'delete' })
- unchanged.close()
- })
-
- it('rejects view-only and foreign-application unversioned databases without mutation', async () => {
- const viewPath = await freshDbPath()
- const viewOnly = new DatabaseSync(viewPath)
- viewOnly.exec('CREATE VIEW foreign_view AS SELECT 1 AS value')
- viewOnly.close()
-
- expect(() => openDatabase(viewPath, 'wal')).toThrow(/unversioned schema or application identity/)
- const unchangedView = new DatabaseSync(viewPath)
- expect(unchangedView.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'delete' })
- expect(unchangedView.prepare(
- "SELECT type FROM sqlite_schema WHERE name = 'foreign_view'",
- ).get()).toEqual({ type: 'view' })
- unchangedView.close()
-
- const applicationPath = await freshDbPath()
- const foreignApplication = new DatabaseSync(applicationPath)
- foreignApplication.exec('PRAGMA application_id = 12345')
- foreignApplication.close()
-
- expect(() => openDatabase(applicationPath, 'wal')).toThrow(/unversioned schema or application identity/)
- const unchangedApplication = new DatabaseSync(applicationPath)
- expect(unchangedApplication.prepare('PRAGMA application_id').get()).toEqual({ application_id: 12345 })
- expect(unchangedApplication.prepare('PRAGMA user_version').get()).toEqual({ user_version: 0 })
- expect(unchangedApplication.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'delete' })
- unchangedApplication.close()
- })
-
- it('rejects a current-version database with a foreign application identity', async () => {
- const path = await freshDbPath()
- const foreign = new DatabaseSync(path)
- foreign.exec('PRAGMA application_id = 12345')
- foreign.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`)
- foreign.close()
-
- expect(() => openDatabase(path, 'wal')).toThrow(/has application id 12345/)
-
- const unchanged = new DatabaseSync(path)
- expect(unchanged.prepare('PRAGMA application_id').get()).toEqual({ application_id: 12345 })
- expect(unchanged.prepare('PRAGMA user_version').get()).toEqual({ user_version: SCHEMA_VERSION })
- expect(unchanged.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'delete' })
- unchanged.close()
- })
-
- it('rolls back schema objects and identity stamps when initialization fails', async () => {
- const path = await freshDbPath()
- const conflicting = new DatabaseSync(path)
- conflicting.exec(`PRAGMA application_id = ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID}`)
- conflicting.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`)
- conflicting.exec("CREATE VIEW persistence_state AS SELECT 1 AS singleton, 'foreign' AS store_id")
- conflicting.close()
-
- expect(() => openDatabase(path, 'wal')).toThrow()
-
- const unchanged = new DatabaseSync(path)
- expect(unchanged.prepare(
- "SELECT type FROM sqlite_schema WHERE name = 'persistence_state'",
- ).get()).toEqual({ type: 'view' })
- expect(unchanged.prepare(
- "SELECT type FROM sqlite_schema WHERE name = 'sessions'",
- ).get()).toBeUndefined()
- expect(unchanged.prepare(
- "SELECT type FROM sqlite_schema WHERE name = 'events'",
- ).get()).toBeUndefined()
- expect(unchanged.prepare('PRAGMA application_id').get())
- .toEqual({ application_id: SESSION_PERSISTENCE_SQLITE_APPLICATION_ID })
- expect(unchanged.prepare('PRAGMA user_version').get()).toEqual({ user_version: SCHEMA_VERSION })
- expect(unchanged.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'delete' })
- unchanged.close()
- })
-
- it('stamps the persistence application identity with the schema version', async () => {
- const path = await freshDbPath()
- openDatabase(path, 'wal').close()
+ const header = meta('loader')
+ const events = chunkLog(4)
+ await ctx.sessionPersistence.create(header)
+ await ctx.sessionPersistence.append(header.id, events)
+ expect((await ctx.sessionPersistence.inspect(header.id)).events).toEqual(events)
+ await ctx.fiber.dispose()
const db = new DatabaseSync(path)
- expect(db.prepare('PRAGMA application_id').get())
- .toEqual({ application_id: SESSION_PERSISTENCE_SQLITE_APPLICATION_ID })
- expect(db.prepare('PRAGMA user_version').get()).toEqual({ user_version: SCHEMA_VERSION })
+ expect(db.prepare(testSql('count-packed-events')).get())
+ .toEqual({ count: 1 })
db.close()
})
- it('rejects a sibling v3 database (the merge-collided version) rather than opening it against missing columns', async () => {
- // Version 3 identified two incompatible sibling layouts, so it is always rejected.
+ it('packs each append once without rewriting earlier rows and seeks inside packed rows', async () => {
const path = await freshDbPath()
- openDatabase(path, 'wal').close() // creates + stamps user_version = SCHEMA_VERSION
- const db = openDatabase(path, 'wal')
- db.exec('PRAGMA user_version = 3')
- db.close()
- expect(() => openDatabase(path, 'wal')).toThrow(/schema version 3, incompatible with this build/)
- })
-
- it('a corrupt-JSON row in the uncommitted tail is discarded on load, not unloadable', async () => {
- const path = await freshDbPath()
- const m = meta('corrupt-tail')
- const b1 = await backend(path)
- await b1.ctx.sessionPersistence.create(m)
- await b1.ctx.sessionPersistence.append(m.id, oneTurnLog()) // committed: seqs 0..5
- await b1.dispose()
-
- // A torn row after the last committed turn has invalid JSON. `scanRows` locates the boundary
- // from seq/type columns without parsing the tail, preserves the committed prefix, and load
- // deletes the row; invalid JSON inside the committed region would remain fatal.
- const db = openDatabase(path, 'wal')
- db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, 6, ?, 7, ?)')
- .run(m.id, 'turn/start', '{not valid json')
- db.close()
-
- const b2 = await backend(path)
- const loaded = await b2.ctx.sessionPersistence.load(m.id)
- expect(loaded.events).toEqual(oneTurnLog()) // torn tail discarded, committed intact (turn 1 already balanced → no closers)
- // load physically deleted the corrupt tail row, so a fresh append continues.
- await b2.ctx.sessionPersistence.append(m.id, [
- { type: 'turn/start', seq: 6, time: 8, data: { turn: 2 } },
- { type: 'turn/end', seq: 7, time: 9, data: { turn: 2, reason: { kind: 'completed' } } },
- ])
- const reloaded = await b2.ctx.sessionPersistence.load(m.id)
- expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
- await b2.dispose()
- })
-
- it('append rolls back the whole batch on a mid-batch seq collision (transaction)', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
- const fiber = await ctx.plugin(SqliteSessionPersistence, { path: ':memory:' })
- const m = meta('rollback')
- await ctx.sessionPersistence.create(m)
- await ctx.sessionPersistence.append(m.id, oneTurnLog()) // seqs 0..5
+ const fiber = await ctx.plugin(SessionPersistenceSqlite, { path })
+ const header = meta('packed')
+ const events = chunkLog(100)
+ await ctx.sessionPersistence.create(header)
+ await ctx.sessionPersistence.append(header.id, events.slice(0, 3))
+ await ctx.sessionPersistence.append(header.id, events.slice(3, 4))
+ const before = new DatabaseSync(path, { readOnly: true })
+ const originalRows = before.prepare(testSql('select-event-rowids')).all()
+ before.close()
+ await ctx.sessionPersistence.append(header.id, events.slice(4))
- // A batch that re-states an already-stored seq must be rejected and leave
- // the stored log unchanged (the UNIQUE (session_id, seq) constraint fires
- // inside the transaction → ROLLBACK).
- await expect(ctx.sessionPersistence.append(m.id, oneTurnLog())).rejects.toThrow()
- const loaded = await ctx.sessionPersistence.load(m.id)
- expect(loaded.events).toEqual(oneTurnLog()) // unchanged
+ const inspected = await ctx.sessionPersistence.inspect(header.id)
+ expect(inspected.events).toEqual(events)
+ for (const fromSeq of [0, 2, 25, 101, 104, 105]) {
+ expect((await ctx.sessionPersistence.readFrom(header.id, fromSeq)).events)
+ .toEqual(events.filter(event => event.seq >= fromSeq))
+ }
await fiber.dispose()
+
+ const db = new DatabaseSync(path)
+ expect(db.prepare(testSql('select-user-version')).get()).toEqual({ user_version: SCHEMA_VERSION })
+ expect(db.prepare(testSql('count-events')).get()).toEqual({ count: 7 })
+ expect(db.prepare(testSql('count-packed-events')).get())
+ .toEqual({ count: 1 })
+ expect(db.prepare(testSql('select-event-rowids')).all().slice(0, originalRows.length))
+ .toEqual(originalRows)
+ db.close()
})
- it('persists across separate backend instances over the same file', async () => {
- const path = await freshDbPath()
- const m = meta('persist', '/proj')
- const ctx1 = new Context()
- await ctx1.plugin(SessionStore)
- const fiber1 = await ctx1.plugin(SqliteSessionPersistence, { path })
- await ctx1.sessionPersistence.create(m)
- await ctx1.sessionPersistence.append(m.id, oneTurnLog())
- await fiber1.dispose()
+ it.runIf(process.platform !== 'win32')('bounds paced-stream WAL extent without rewriting committed rows', async () => {
+ const events = chunkLog(1_000)
+ const measured = await measureWriteTraffic(await freshDbPath('dsh-sqlite-traffic-'), events)
- const ctx2 = new Context()
- await ctx2.plugin(SessionStore)
- const fiber2 = await ctx2.plugin(SqliteSessionPersistence, { path })
- expect((await ctx2.sessionPersistence.list()).map(x => x.id)).toContain(m.id)
- const loaded = await ctx2.sessionPersistence.load(m.id)
- expect(loaded.meta).toMatchObject({ id: m.id, cwd: '/proj' })
- expect(loaded.events).toEqual(oneTurnLog())
- await fiber2.dispose()
+ expect(measured).toMatchObject({ rows: 31, inserted: 31, changed: 0, removed: 0 })
+ expect(measured.inserted).toBe(measured.rows)
+ expect(measured.largest).toBeLessThanOrEqual(MAX_PACKED_DATA_BYTES)
+ expect(measured.idleWalBytes).toBe(measured.walBytes)
})
- it('source-qualifies revisions across stores while preserving same-file reopen identity', async () => {
- const pathA = await freshDbPath()
- const pathB = await freshDbPath()
- const m = meta('revision-source')
- const a = await backend(pathA)
- await a.ctx.sessionPersistence.create(m)
- await a.ctx.sessionPersistence.append(m.id, oneTurnLog())
- const revisionA = (await a.ctx.sessionPersistence.listSnapshots())[0]?.revision
- await a.dispose()
+ it('includes a packed predecessor when an overlapping scalar tail hides it', async () => {
+ const path = await freshDbPath('dsh-sqlite-overlap-')
+ const store = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS })
+ const header = meta('overlap')
+ await store.appendBatch(header, [chunk(0), chunk(1), chunk(2)], false)
- const probeA = openDatabase(pathA, 'wal')
- const storeIdA = (probeA.prepare(
- 'SELECT store_id FROM persistence_state WHERE singleton = 1',
- ).get() as { store_id: string }).store_id
- probeA.close()
+ const db = new DatabaseSync(path)
+ db.prepare(testSql('insert-corrupt-event'))
+ .run(header.id, 1, 'assistant/chunk', 2, JSON.stringify(chunk(1).data), null)
+ db.close()
- const aliasA = `${pathA}.alias`
- await symlink(pathA, aliasA)
- const reopenedA = await backend(aliasA)
- expect((await reopenedA.ctx.sessionPersistence.listSnapshots())[0]?.revision).toBe(revisionA)
- await reopenedA.dispose()
+ expect((await store.loadStoredFrom(header.id, 2))?.events).toEqual([chunk(2)])
- const b = await backend(pathB)
- await b.ctx.sessionPersistence.create(m)
- await b.ctx.sessionPersistence.append(m.id, oneTurnLog())
- const revisionB = (await b.ctx.sessionPersistence.listSnapshots())[0]?.revision
- const probeB = openDatabase(pathB, 'wal')
- const storeIdB = (probeB.prepare(
- 'SELECT store_id FROM persistence_state WHERE singleton = 1',
- ).get() as { store_id: string }).store_id
- probeB.close()
- expect(storeIdB).not.toBe(storeIdA)
- expect(revisionB).not.toBe(revisionA)
- expect(String(revisionA)).toMatch(/:revision:1$/)
- expect(String(revisionB)).toMatch(/:revision:1$/)
- await b.dispose()
+ const malformed = new DatabaseSync(path)
+ malformed.prepare(testSql('delete-session-events')).run(header.id)
+ malformed.prepare(testSql('insert-corrupt-event'))
+ .run(header.id, 0, 'text-chunks', 1, '{not json', 0)
+ malformed.close()
+ expect((await store.loadStoredFrom(header.id, 2))?.events).toEqual([])
+ await store.close()
})
- it('binds a full stored prefix to the same revision as a lightweight read', async () => {
- const b = await backend()
- const m = meta('stored-prefix-revision')
- await b.ctx.sessionPersistence.create(m)
- await b.ctx.sessionPersistence.append(m.id, oneTurnLog())
- const persistence = b.ctx.sessionPersistence as SqliteSessionPersistence
+ it('waits for a competing process within the configured busy timeout', async () => {
+ const path = await freshDbPath('dsh-sqlite-busy-')
+ const store = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: 1_000 })
+ const header = meta('busy')
+ await store.appendBatch(header, [chunk(0)], false)
- const stored = await persistence.loadStored(m.id)
- expect(stored?.revision).toBe(await persistence.readStoredRevision(m.id))
- expect(await persistence.readStoredRevision(SessionId('missing-revision'))).toBeUndefined()
- await b.dispose()
- })
-
- it('changes revisions when a deleted session id is materialized again in the same database', async () => {
- const path = await freshDbPath()
- const m = meta('recreated-revision')
- const first = await backend(path)
- await first.ctx.sessionPersistence.create(m)
- await first.ctx.sessionPersistence.append(m.id, oneTurnLog())
- const before = (await first.ctx.sessionPersistence.listSnapshots())[0]?.revision
- await first.dispose()
-
- const cleanup = openDatabase(path, 'wal')
- cleanup.prepare('DELETE FROM sessions WHERE id = ?').run(m.id)
- cleanup.close()
-
- const second = await backend(path)
- await second.ctx.sessionPersistence.create(m)
- await second.ctx.sessionPersistence.append(m.id, oneTurnLog())
- const after = (await second.ctx.sessionPersistence.listSnapshots())[0]?.revision
- expect(after).not.toBe(before)
- expect(String(before)).toMatch(/:revision:1$/)
- expect(String(after)).toMatch(/:revision:1$/)
- await second.dispose()
- })
-
- it('awaits in-flight readiness before surfacing snapshot-list cancellation', async () => {
- const b = await backend()
- const internals = b.ctx.sessionPersistence as unknown as { ready: Promise }
- const originalReady = internals.ready
- const readiness = Promise.withResolvers()
- internals.ready = readiness.promise
- const reason = new Error('SQLite snapshot readiness cancelled')
- const controller = new AbortController()
- const pending = b.ctx.sessionPersistence.listSnapshots(controller.signal)
- let settled = false
- void pending.then(
- () => { settled = true },
- () => { settled = true },
- )
-
- controller.abort(reason)
- await Promise.resolve()
- expect(settled).toBe(false)
-
- readiness.resolve(undefined)
- await expect(pending).rejects.toBe(reason)
- internals.ready = originalReady
- await b.dispose()
- })
-
- it('exposes the schema version constant', () => {
- expect(SCHEMA_VERSION).toBe(15)
- })
-
- it('keeps the revision stable for an empty repair hook', async () => {
- const b = await backend()
- const m = meta('empty-repair')
- await b.ctx.sessionPersistence.create(m)
- await b.ctx.sessionPersistence.append(m.id, oneTurnLog())
- const before = await b.ctx.sessionPersistence.listSnapshots()
- await (b.ctx.sessionPersistence as SqliteSessionPersistence).commitRepair(m, undefined, [])
- expect(await b.ctx.sessionPersistence.listSnapshots()).toEqual(before)
- await b.dispose()
- })
-})
-
-describe('SqliteSessionPersistence: edge cases', () => {
- it('resolves the preparation-cache default without schema normalization', async () => {
- const ctx = new Context()
- await ctx.plugin(SessionStore)
- let persistence!: SqliteSessionPersistence
- await ctx.plugin(Object.assign((inner: Context) => {
- persistence = new SqliteSessionPersistence(inner, {
- path: ':memory:',
- journalMode: 'wal',
- })
- }, { inject: ['sessions'] }))
-
- expect(await persistence.list()).toEqual([])
- await ctx.fiber.dispose()
- })
-
- it('uses the configured preparation cache through the public service', async () => {
- const ctx = new Context()
- await ctx.plugin(SessionStore)
- const fiber = await ctx.plugin(SqliteSessionPersistence, {
- path: ':memory:',
- preparedSessionCacheSize: 1,
- writeBatchMaxDelayMs: 1,
+ const holder = spawn(process.execPath, ['--input-type=module', '-e', String.raw`
+ import { DatabaseSync } from 'node:sqlite';
+ const db = new DatabaseSync(process.argv[1]);
+ db.exec('BEGIN IMMEDIATE');
+ process.stdout.write('locked\n');
+ setTimeout(() => { db.exec('COMMIT'); db.close(); }, 100);
+ `, path], { stdio: ['ignore', 'pipe', 'pipe'] })
+ const exited = new Promise((resolve, reject) => {
+ holder.once('error', reject)
+ holder.once('exit', resolve)
})
- const m = meta('sqlite-preparation-cache')
- await ctx.sessionPersistence.create(m)
- await ctx.sessionPersistence.append(m.id, oneTurnLog())
-
- const preparation = await ctx.sessionPersistence.prepare(m.id)
- expect(preparation.session.header).toEqual(m)
- preparation[Symbol.dispose]()
- await fiber.dispose()
+ try {
+ await once(holder.stdout, 'data')
+ await expect(store.appendBatch(header, [chunk(1)], true)).resolves.toBeUndefined()
+ const code = await exited
+ expect(code).toBe(0)
+ expect((await store.loadStored(header.id))?.events).toEqual([chunk(0), chunk(1)])
+ } finally {
+ if (holder.exitCode === null) holder.kill()
+ await store.close()
+ }
})
- it('rejects and closes a current-schema database with an invalid store identity', async () => {
- const path = await freshDbPath()
- const db = openDatabase(path, 'wal')
- db.exec("UPDATE persistence_state SET store_id = '' WHERE singleton = 1")
+ it('rejects an older SQLite physical schema', async () => {
+ const path = await freshDbPath('dsh-sqlite-old-schema-')
+ const seed = await openDatabase(DatabaseSync, path, 'wal', DEFAULT_BUSY_TIMEOUT_MS)
+ seed.exec(testSql('set-user-version-16'))
+ seed.close()
+ await chmod(path, 0o600)
+ await expect(openDatabase(DatabaseSync, path, 'wal', DEFAULT_BUSY_TIMEOUT_MS))
+ .rejects.toThrow(/schema version 16.*incompatible/)
+ })
+
+ it('rejects a stale physical append without replacing the winning tail', async () => {
+ const path = await freshDbPath('dsh-sqlite-stale-')
+ const first = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS })
+ const second = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS })
+ const header = meta(SessionId('stale'))
+ await first.appendBatch(header, [chunk(0)], false)
+ await second.appendBatch(header, [chunk(1)], true)
+ await expect(first.appendBatch(header, [chunk(1)], true)).rejects.toThrow(/stored next seq is 2/)
+ expect((await first.loadStored(header.id))?.events).toEqual([chunk(0), chunk(1)])
+ await first.close()
+ await second.close()
+ })
+
+ it('rejects a stale repair without deleting a newer winning tail', async () => {
+ const path = await freshDbPath('dsh-sqlite-stale-repair-')
+ const stale = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS })
+ const winner = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS })
+ const header = meta(SessionId('stale-repair'))
+ await stale.appendBatch(header, [chunk(0)], false)
+ const db = new DatabaseSync(path)
+ db.prepare(testSql('insert-corrupt-event')).run(header.id, 1, 'assistant/chunk', 2, '{not json', null)
db.close()
+ expect((await stale.loadStored(header.id))?.tornMarker).toBe(1)
+ await winner.commitRepair(header, 1, [])
+ await winner.appendBatch(header, [chunk(1), chunk(2)], true)
+ await expect(stale.commitRepair(header, 1, [])).rejects.toThrow(/repair is stale/)
+ expect((await stale.loadStored(header.id))?.events).toEqual([chunk(0), chunk(1), chunk(2)])
+ await stale.close()
+ await winner.close()
+ })
+})
- const b = await backend(path)
- await expect(b.ctx.sessionPersistence.listSnapshots()).rejects.toThrow(/no valid store identity/)
- await expect(b.dispose()).resolves.toBeUndefined()
+describe('SessionPersistenceSqlite schema ownership', () => {
+ it('accepts every configured journal mode and SQLite memory mode result', async () => {
+ const resources = {
+ wal: 'journal-mode-wal',
+ delete: 'journal-mode-delete',
+ truncate: 'journal-mode-truncate',
+ persist: 'journal-mode-persist',
+ } as const
+ for (const mode of ['wal', 'delete', 'truncate', 'persist'] as const) {
+ ;(await openDatabase(DatabaseSync, ':memory:', mode, DEFAULT_BUSY_TIMEOUT_MS)).close()
+ const path = await freshDbPath(`dsh-sqlite-journal-${mode}-`)
+ const db = await openDatabase(DatabaseSync, path, mode, DEFAULT_BUSY_TIMEOUT_MS)
+ expect(db.prepare(sql(resources[mode])).get()).toEqual({ journal_mode: mode })
+ expect(db.prepare(sql('select-trusted-schema')).get()).toEqual({ trusted_schema: 0 })
+ expect(db.prepare(sql('select-mmap-size')).get()).toEqual({ mmap_size: 0 })
+ expect(db.prepare(sql('select-synchronous')).get()).toEqual({ synchronous: 2 })
+ db.close()
+ }
})
- it('creates a new database and WAL sidecars with owner-only modes without changing its parent mode', async () => {
- if (process.platform === 'win32') return
- const path = await freshDbPath()
- const dir = dirname(path)
- await chmod(dir, 0o755)
+ it('retries a busy journal-mode transition within its retry budget', async () => {
+ const path = await freshDbPath('dsh-sqlite-journal-busy-')
+ let attempts = 0
+ const BusyOnceDatabase = databaseWithJournalFailure(() => {
+ attempts += 1
+ return attempts === 1
+ ? Object.assign(new Error('database is locked'), {
+ code: 'ERR_SQLITE_ERROR',
+ errcode: 5,
+ errstr: 'database is locked',
+ })
+ : undefined
+ })
- const b = await backend(path)
- await b.ctx.sessionPersistence.list()
-
- expect((await stat(dir)).mode & 0o777).toBe(0o755)
- expect((await stat(path)).mode & 0o777).toBe(0o600)
- expect((await stat(`${path}-wal`)).mode & 0o777).toBe(0o600)
- expect((await stat(`${path}-shm`)).mode & 0o777).toBe(0o600)
- await b.dispose()
- })
-
- it('creates a persistent rollback journal with owner-only mode', async () => {
- if (process.platform === 'win32') return
- const path = await freshDbPath()
- const ctx = new Context()
- await ctx.plugin(SessionStore)
- const fiber = await ctx.plugin(SqliteSessionPersistence, { path, journalMode: 'persist' })
- const m = meta('persist-permissions')
-
- await ctx.sessionPersistence.create(m)
- await ctx.sessionPersistence.append(m.id, oneTurnLog())
-
- expect((await stat(path)).mode & 0o777).toBe(0o600)
- expect((await stat(`${path}-journal`)).mode & 0o777).toBe(0o600)
- await fiber.dispose()
- })
-
- it('preserves the mode of an existing database file', async () => {
- if (process.platform === 'win32') return
- const path = await freshDbPath()
- await writeFile(path, '', { mode: 0o644 })
- await chmod(path, 0o644)
-
- const ctx = new Context()
- await ctx.plugin(SessionStore)
- const fiber = await ctx.plugin(SqliteSessionPersistence, { path, journalMode: 'delete' })
- await ctx.sessionPersistence.list()
-
- expect((await stat(path)).mode & 0o777).toBe(0o644)
- await fiber.dispose()
- })
-
- it('surfaces an invalid database path during pre-creation', async () => {
- const path = await freshDbPath()
- const b = await backend(`${path}\0`)
-
- await expect(b.ctx.sessionPersistence.list()).rejects.toMatchObject({ code: 'ERR_INVALID_ARG_VALUE' })
- await b.dispose()
- })
-
- it('append rolls back and rethrows when an event INSERT fails inside the transaction', async () => {
- const path = await freshDbPath()
- const m = meta('rollback-insert')
- const b1 = await backend(path)
- await b1.ctx.sessionPersistence.create(m)
- await b1.ctx.sessionPersistence.append(m.id, oneTurnLog())
-
- // A SECOND backend over the same file loads the session first, so it adopts
- // cursor 6 (the committed length) into its OWN in-memory state.
- const b2 = await backend(path)
- await b2.ctx.sessionPersistence.load(m.id) // cursor 6 in b2
- const turn2: SessionEvent[] = [
- { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } },
- { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } },
- ]
- // b1 commits seq 6..7 first.
- await b1.ctx.sessionPersistence.append(m.id, turn2)
- // b2 still thinks its cursor is 6, so this batch passes the contiguity check
- // but its INSERT of seq 6 hits the UNIQUE (session_id, seq) constraint
- // mid-transaction → ROLLBACK + rethrow.
- await expect(b2.ctx.sessionPersistence.append(m.id, turn2)).rejects.toThrow(/UNIQUE/)
- // b1's turn is intact; b2's rolled-back attempt left nothing extra.
- const loaded = await b1.ctx.sessionPersistence.load(m.id)
- expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
- await b1.dispose()
- await b2.dispose()
- })
-
- it('journalMode config reaches the database (default wal, rollback modes selectable)', async () => {
- // :memory: databases always report journal_mode=memory, so probe file DBs.
- const walPath = await freshDbPath()
- const bWal = await backend(walPath)
- await bWal.ctx.sessionPersistence.create(meta('jm-wal'))
- const probe = openDatabase(walPath, 'wal')
- expect((probe.prepare('PRAGMA journal_mode').get() as { journal_mode: string }).journal_mode).toBe('wal')
- probe.close()
- await bWal.dispose()
-
- const deletePath = await freshDbPath()
- const ctx = new Context()
- await ctx.plugin(SessionStore)
- const fiber = await ctx.plugin(SqliteSessionPersistence, { path: deletePath, journalMode: 'delete' })
- await ctx.sessionPersistence.create(meta('jm-delete'))
- // Probe through a second connection: journal_mode=delete is a per-database
- // property only insofar as no WAL files exist — assert the world, not the
- // backend's self-report (no -wal sidecar after writes in delete mode).
- const db = openDatabase(deletePath, 'delete')
- expect((db.prepare('PRAGMA journal_mode').get() as { journal_mode: string }).journal_mode).toBe('delete')
+ const db = await openDatabase(BusyOnceDatabase, path, 'wal', 100)
+ expect(attempts).toBe(2)
+ expect(db.prepare(sql('journal-mode-wal')).get()).toEqual({ journal_mode: 'wal' })
+ expect(db.prepare(sql('select-trusted-schema')).get()).toEqual({ trusted_schema: 0 })
+ expect(db.prepare(sql('select-mmap-size')).get()).toEqual({ mmap_size: 0 })
+ expect(db.prepare(sql('select-synchronous')).get()).toEqual({ synchronous: 2 })
db.close()
- expect(existsSync(`${deletePath}-wal`)).toBe(false)
- await fiber.dispose()
})
- it('HMR: a DIFFERENT session colliding with a materialized on-disk id is rejected', async () => {
- const path = await freshDbPath()
- // Instance 1 materializes a session and disposes.
- const b1 = await backend(path)
- const s1 = b1.ctx.sessions.create(SessionId('hmr-collide'))
- appendLog(s1, oneTurnLog())
- await b1.ctx.sessions.flush(s1)
- await b1.dispose()
+ it('does not retry journal failures outside the available busy budget', async () => {
+ for (const { errcode, timeout } of [
+ { errcode: 5, timeout: 0 },
+ { errcode: 6, timeout: 100 },
+ ]) {
+ let attempts = 0
+ const FailingDatabase = databaseWithJournalFailure(() => {
+ attempts += 1
+ return Object.assign(new Error(`SQLite error ${errcode}`), { errcode })
+ })
+ await expect(openDatabase(
+ FailingDatabase,
+ await freshDbPath(`dsh-sqlite-journal-failure-${errcode}-`),
+ 'wal',
+ timeout,
+ )).rejects.toThrow(`SQLite error ${errcode}`)
+ expect(attempts).toBe(1)
+ }
+ })
- // A fresh context with an UNRELATED live session reusing the id meets a
- // materialized row that is NOT a prefix of its events → reject.
+ it('starts no journal retry after its open-relative cutoff', async () => {
+ let attempts = 0
+ const BusyDatabase = databaseWithJournalFailure(() => {
+ attempts += 1
+ return Object.assign(new Error('database is locked'), { errcode: 5 })
+ })
+ const clock = vi.spyOn(performance, 'now')
+ .mockReturnValueOnce(0)
+ .mockReturnValueOnce(50)
+ .mockReturnValueOnce(100)
+ try {
+ await expect(openDatabase(
+ BusyDatabase,
+ await freshDbPath('dsh-sqlite-journal-cutoff-'),
+ 'wal',
+ 100,
+ )).rejects.toThrow('database is locked')
+ } finally {
+ clock.mockRestore()
+ }
+ expect(attempts).toBe(1)
+ })
+
+ it('paces repeated busy journal-mode attempts', async () => {
+ let attempts = 0
+ const BusyDatabase = databaseWithJournalFailure(() => {
+ attempts += 1
+ return Object.assign(new Error('database is locked'), { errcode: 5 })
+ })
+ await expect(openDatabase(
+ BusyDatabase,
+ await freshDbPath('dsh-sqlite-journal-paced-'),
+ 'wal',
+ 50,
+ )).rejects.toThrow('database is locked')
+ expect(attempts).toBeGreaterThan(1)
+ expect(attempts).toBeLessThanOrEqual(6)
+ })
+
+ it('rejects unversioned, incompatible, and foreign-application databases', async () => {
+ const unversionedPath = await freshDbPath('dsh-sqlite-unversioned-')
+ const unversioned = new DatabaseSync(unversionedPath)
+ unversioned.exec(testSql('create-unrelated-table'))
+ unversioned.close()
+ await expect(openDatabase(DatabaseSync, unversionedPath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).rejects.toThrow(/unversioned schema/)
+
+ const incompatiblePath = await freshDbPath('dsh-sqlite-incompatible-')
+ const incompatible = new DatabaseSync(incompatiblePath)
+ incompatible.exec(testSql('set-user-version-16'))
+ incompatible.close()
+ await expect(openDatabase(DatabaseSync, incompatiblePath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).rejects.toThrow(/incompatible with this build/)
+
+ const foreignPath = await freshDbPath('dsh-sqlite-foreign-')
+ const foreign = new DatabaseSync(foreignPath)
+ foreign.exec(testSql('set-user-version-17'))
+ foreign.exec(testSql('set-application-id-12345'))
+ foreign.close()
+ await expect(openDatabase(DatabaseSync, foreignPath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).rejects.toThrow(/has application id 12345/)
+ })
+
+ it('rejects changed columns and non-strict owned tables', async () => {
+ const changedPath = await freshDbPath('dsh-sqlite-columns-')
+ ;(await openDatabase(DatabaseSync, changedPath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).close()
+ const changed = new DatabaseSync(changedPath)
+ changed.exec(testSql('add-unexpected-column'))
+ changed.close()
+ await expect(openDatabase(DatabaseSync, changedPath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).rejects.toThrow(/required schema objects/)
+
+ const nonStrictPath = await freshDbPath('dsh-sqlite-nonstrict-')
+ ;(await openDatabase(DatabaseSync, nonStrictPath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).close()
+ const nonStrict = new DatabaseSync(nonStrictPath)
+ nonStrict.exec(testSql('replace-events-with-nonstrict-table'))
+ nonStrict.close()
+ await expect(openDatabase(DatabaseSync, nonStrictPath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).rejects.toThrow(/required schema objects/)
+
+ const loosePath = await freshDbPath('dsh-sqlite-loose-')
+ const loose = new DatabaseSync(loosePath)
+ loose.exec(testSql('create-loose-schema'))
+ loose.close()
+ await expect(openDatabase(DatabaseSync, loosePath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).rejects.toThrow(/required schema objects/)
+ })
+
+ it('rejects schema ownership changes observed at mutation time', async () => {
+ const changedVersion = await openDatabase(DatabaseSync, ':memory:', 'wal', DEFAULT_BUSY_TIMEOUT_MS)
+ changedVersion.exec(testSql('set-user-version-16'))
+ expect(() => { validateSchemaForMutation(DatabaseSync, changedVersion, ':memory:') })
+ .toThrow(/schema changed before mutation/)
+ changedVersion.close()
+
+ const changedApplication = await openDatabase(DatabaseSync, ':memory:', 'wal', DEFAULT_BUSY_TIMEOUT_MS)
+ changedApplication.exec(testSql('set-application-id-12345'))
+ expect(() => { validateSchemaForMutation(DatabaseSync, changedApplication, ':memory:') })
+ .toThrow(/application id changed before mutation/)
+ changedApplication.close()
+ })
+
+ it('validates creation time and restores every optional header field', () => {
+ const base: SessionRow = {
+ id: 'stored-header',
+ version: 0,
+ created_at: 1,
+ cwd: '/project',
+ parent_session: 'parent',
+ seed_length: 4,
+ origin: 'subagent',
+ incarnation: '00000000-0000-4000-8000-000000000000',
+ revision: 1,
+ delegation_depth: 2,
+ agent_preset: 'minimal',
+ }
+ expect(rowToMeta(decodeSessionRow(base))).toMatchObject({
+ cwd: '/project',
+ parentSession: 'parent',
+ seedLength: 4,
+ origin: 'subagent',
+ delegationDepth: 2,
+ agentPreset: 'minimal',
+ })
+ expect(() => decodeSessionRow({ ...base, created_at: -1 })).toThrow(/created_at/)
+ expect(() => decodeSessionRow({ ...base, origin: 'external' })).toThrow(/origin/)
+ expect(() => decodeSessionRow({ ...base, delegation_depth: -1 })).toThrow(/delegation_depth/)
+ })
+
+ it('rejects malformed SQLite row primitives generically', () => {
+ const base: SessionRow = {
+ id: 'stored-header',
+ version: 0,
+ created_at: 1,
+ cwd: '/project',
+ parent_session: null,
+ seed_length: null,
+ origin: null,
+ incarnation: '00000000-0000-4000-8000-000000000000',
+ revision: 1,
+ delegation_depth: null,
+ agent_preset: null,
+ }
+ for (const [value, message] of [
+ [null, /object/],
+ [{ ...base, id: 1 }, /id.*string/],
+ [{ ...base, id: '' }, /id.*empty/],
+ [{ ...base, version: '0' }, /version.*safe integer/],
+ [{ ...base, cwd: 'relative' }, /cwd.*absolute/],
+ [{ ...base, cwd: 1 }, /cwd.*string or null/],
+ [{ ...base, incarnation: 'invalid' }, /incarnation.*UUID/],
+ [{ ...base, seed_length: '1' }, /seed_length.*safe integer or null/],
+ [{ ...base, agent_preset: 1 }, /agent_preset.*string or null/],
+ ] as const) {
+ expect(() => decodeSessionRow(value)).toThrow(message)
+ }
+
+ const eventRow = {
+ seq: 0, type: 'turn/start', time: 1, data: '{}',
+ source_event_seqs: null, surface_op: null, ignorable: null,
+ }
+ for (const [value, message] of [
+ [null, /object/],
+ [{ ...eventRow, seq: '0' }, /seq.*safe integer/],
+ [{ ...eventRow, type: '' }, /type.*empty/],
+ [{ ...eventRow, time: '1' }, /time.*safe integer/],
+ [{ ...eventRow, data: 1 }, /data.*string or blob/],
+ [{ ...eventRow, source_event_seqs: 1 }, /source_event_seqs.*blob or null/],
+ [{ ...eventRow, ignorable: 2 }, /ignorable.*0, 1, or null/],
+ ] as const) {
+ expect(() => decodeEventRow(value)).toThrow(message)
+ }
+ expect(() => decodeStoreIdentity({ store_id: 'invalid' })).toThrow(/store_id.*UUID/)
+ })
+
+ it('rejects invalid durable metadata before exposing a session header', async () => {
+ const path = await freshDbPath('dsh-sqlite-metadata-')
+ const store = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS })
+ const header = meta('invalid-metadata')
+ await store.appendBatch(header, [chunk(0)], false)
+ const db = new DatabaseSync(path)
+ db.prepare(testSql('update-invalid-session-metadata')).run(header.id)
+ db.close()
+ await expect(store.list()).rejects.toThrow(/seed_length|origin|delegation_depth/)
+ await expect(store.loadStored(header.id)).rejects.toThrow(/seed_length|origin|delegation_depth/)
+ await store.close()
+ })
+
+ it('uses the shared persistence application identity', () => {
+ expect(SESSION_PERSISTENCE_SQLITE_APPLICATION_ID).toBe(0x44534850)
+ })
+})
+
+describe('SessionPersistenceSqlite edge behavior', () => {
+ it('keeps a fresh database unopened until the first persistence operation', async () => {
+ const path = await freshDbPath('dsh-sqlite-lazy-')
const ctx = new Context()
await ctx.plugin(SessionStore)
- let session!: Session
- await ctx.plugin(Object.assign((inner: Context) => {
- session = inner.sessions.create(SessionId('hmr-collide'))
- }, { inject: ['sessions'] }))
- session.append('turn/start', { turn: 1 })
- await ctx.plugin(SqliteSessionPersistence, { path })
- await expectFlushError(ctx.sessions.flush(session), /id collision/)
+ await ctx.plugin(SessionPersistenceSqlite, { path })
+ await expect(stat(path)).rejects.toMatchObject({ code: 'ENOENT' })
+ const emitWarning = Reflect.get(process, 'emitWarning')
+ expect(await ctx.sessionPersistence.list()).toEqual([])
+ expect(Reflect.get(process, 'emitWarning')).toBe(emitWarning)
+ expect(typeof (await stat(path)).size).toBe('number')
await ctx.fiber.dispose()
})
-})
-describe('surface field round-trip', () => {
- it('rowToEvent parses surface fields from EventRow columns', () => {
- const row: EventRow = {
- seq: 0, type: 'assistant/message', time: 1,
- data: JSON.stringify({ turn: 1, step: 1, content: [] }),
- source_event_seqs: JSON.stringify([3, 5]),
- surface_op: JSON.stringify('append'),
- ignorable: null,
- }
- const event = rowToEvent(row)
- expect((event as SurfaceEvent).sourceEventSeqs).toEqual([3, 5])
- expect((event as SurfaceEvent).surfaceOp).toBe('append')
- })
-
- it('rowToEvent handles replace surfaceOp object', () => {
- const row: EventRow = {
- seq: 0, type: 'assistant/message', time: 1,
- data: JSON.stringify({ turn: 1, step: 1, content: [] }),
- source_event_seqs: JSON.stringify([0, 1]),
- surface_op: JSON.stringify({ op: 'replace', start: 0, end: 1 }),
- ignorable: null,
- }
- const event = rowToEvent(row)
- expect((event as SurfaceEvent).sourceEventSeqs).toEqual([0, 1])
- expect((event as SurfaceEvent).surfaceOp).toEqual({ op: 'replace', start: 0, end: 1 })
- })
-
- it('scanRows with surface columns reconstructs events with surface fields', () => {
- const rows: EventRow[] = [
- { seq: 0, type: 'user/message', time: 1,
- data: JSON.stringify({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }),
- source_event_seqs: null, surface_op: '{"op":"replace","start":0,"end":0}', ignorable: null },
- { seq: 1, type: 'turn/end', time: 2,
- data: JSON.stringify({ turn: 1, reason: { kind: 'completed' } }),
- source_event_seqs: null, surface_op: null, ignorable: 1 },
- ]
- const { preserved } = scanRows(rows)
- expect(preserved).toHaveLength(2)
- expect((preserved[0]! as SurfaceEvent).surfaceOp).toEqual({ op: 'replace', start: 0, end: 0 })
- expect((preserved[0]! as SurfaceEvent).sourceEventSeqs).toBeUndefined()
- expect((preserved[1] as SessionEvent).surfaceOp).toBeUndefined()
- })
-
- it('append and load round-trips surface fields through SQLite', async () => {
+ it('disposes after path validation without opening the database', async () => {
+ const path = await freshDbPath('dsh-sqlite-unused-')
const ctx = new Context()
await ctx.plugin(SessionStore)
- const fiber = await ctx.plugin(SqliteSessionPersistence, { path: ':memory:' })
- const session = ctx.sessions.create(SessionId('roundtrip-surface'))
- session.append('turn/start', { turn: 1 })
- session.append('step/start', { turn: 1, step: 1 })
- session.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
- }), { surfaceOp: 'append' })
- session.append('assistant/message', {
- turn: 1, step: 1,
- message: createMessage({
- role: 'assistant',
- content: [],
- source: {
- kind: 'model',
- ...{ provider: 'mock', model: 'mock' },
- },
- }),
- }, { surfaceOp: 'append', sourceEventSeqs: [2] })
- session.append('step/end', { turn: 1, step: 1 })
- session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
- await ctx.sessions.flush(session)
- const loaded = await ctx.sessionPersistence.load(SessionId('roundtrip-surface'))
- expect(loaded.events).toHaveLength(6)
- const um = loaded.events[2]!
- expect((um as SurfaceEvent).surfaceOp).toBe('append')
- expect((um as SurfaceEvent).sourceEventSeqs).toBeUndefined()
- const am = loaded.events[3]!
- expect((am as SurfaceEvent).surfaceOp).toBe('append')
- expect((am as SurfaceEvent).sourceEventSeqs).toEqual([2])
- await fiber.dispose()
+ await ctx.plugin(SessionPersistenceSqlite, { path })
+ await ctx.fiber.dispose()
+ await expect(stat(path)).rejects.toMatchObject({ code: 'ENOENT' })
+
+ const untouchedPath = await freshDbPath('dsh-sqlite-never-validated-')
+ const untouched = new SqliteStore({
+ path: untouchedPath,
+ journalMode: 'wal',
+ busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS,
+ })
+ await untouched.close()
+ await expect(stat(untouchedPath)).rejects.toMatchObject({ code: 'ENOENT' })
})
- it('persists events with surfaceOp but no sourceEventSeqs (covers null branch in surfaceBindings)', async () => {
+ it('uses constructor defaults and exposes locate and prepare directly', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
- const fiber = await ctx.plugin(SqliteSessionPersistence, { path: ':memory:' })
- const session = ctx.sessions.create(SessionId('surface-noseq'))
- session.append('turn/start', { turn: 1 })
- session.append('user/message', createUserMessage({
- content: [],
- source: { kind: 'user' },
- }), { surfaceOp: 'append' })
- session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
- await ctx.sessions.flush(session)
- const loaded = await ctx.sessionPersistence.load(SessionId('surface-noseq'))
- expect((loaded.events[1]! as SurfaceEvent).surfaceOp).toBe('append')
- expect((loaded.events[1]! as SurfaceEvent).sourceEventSeqs).toBeUndefined()
- await fiber.dispose()
+ let persistence!: SessionPersistenceSqlite
+ await ctx.plugin(Object.assign((inner: Context) => {
+ persistence = new SessionPersistenceSqlite(inner, { path: ':memory:' })
+ }, { inject: ['sessions'] }))
+
+ const header = meta('direct-provider')
+ const events = chunkLog(3)
+ expect(persistence.locate(header)).toBeUndefined()
+ await persistence.create(header)
+ await persistence.append(header.id, events)
+ const preparation = await persistence.prepare(header.id)
+ expect(preparation.session.header).toEqual(header)
+ preparation[Symbol.dispose]()
+ await ctx.fiber.dispose()
+ })
+
+ it('keeps empty mutations inert and rolls back a repair without metadata', async () => {
+ const store = new SqliteStore({
+ path: ':memory:',
+ journalMode: 'wal',
+ busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS,
+ })
+ const header = meta('empty-store')
+ await store.appendBatch(header, [], false)
+ await store.commitRepair(header, undefined, [])
+ expect(await store.readStoredRevision(header.id)).toBeUndefined()
+ await expect(store.commitRepair(header, 0, [])).rejects.toThrow(/metadata row is missing/)
+ await store.close()
+ })
+
+ it('rejects omitted torn markers and stale closer positions', async () => {
+ const path = await freshDbPath('dsh-sqlite-repair-validation-')
+ const store = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS })
+ const header = meta('repair-validation')
+ await store.appendBatch(header, [chunk(0)], false)
+ const db = new DatabaseSync(path)
+ db.prepare(testSql('insert-corrupt-event')).run(header.id, 1, 'assistant/chunk', 2, '{not json', null)
+ db.close()
+ await expect(store.commitRepair(header, undefined, [chunk(1)])).rejects.toThrow(/omitted current torn tail/)
+ await store.commitRepair(header, 1, [])
+ await expect(store.commitRepair(header, undefined, [chunk(2)])).rejects.toThrow(/closer starts at seq 2/)
+
+ const cleared = new DatabaseSync(path)
+ cleared.prepare(testSql('delete-session-events')).run(header.id)
+ cleared.close()
+ await store.commitRepair(header, undefined, [chunk(0)])
+ expect((await store.loadStored(header.id))?.events).toEqual([chunk(0)])
+ await store.close()
+ })
+
+ it('rejects malformed physical tail rows before appending', async () => {
+ const path = await freshDbPath('dsh-sqlite-tail-')
+ const store = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS })
+ const header = meta('invalid-tail')
+ await store.appendBatch(header, [chunk(0)], false)
+ const db = new DatabaseSync(path)
+ db.prepare(testSql('insert-corrupt-event'))
+ .run(header.id, 1, 'assistant/chunk', 2, '{not json', null)
+ db.close()
+
+ await expect(store.appendBatch(header, [chunk(2)], true)).rejects.toThrow(/invalid physical tail/)
+ await store.close()
+ })
+
+ it('rejects missing and empty store identities', async () => {
+ for (const mode of ['missing', 'empty'] as const) {
+ const path = await freshDbPath(`dsh-sqlite-identity-${mode}-`)
+ const db = await openDatabase(DatabaseSync, path, 'wal', DEFAULT_BUSY_TIMEOUT_MS)
+ if (mode === 'missing') db.exec(testSql('delete-persistence-state'))
+ else db.exec(testSql('empty-store-id'))
+ db.close()
+ await chmod(path, 0o600)
+
+ expect(errorMessage(await backendFailure(path))).toMatch(/no valid store identity/)
+ }
+ })
+
+ it('rejects invalid paths during service initialization', async () => {
+ const path = await freshDbPath('dsh-sqlite-invalid-path-')
+ const ctx = new Context()
+ await ctx.plugin(SessionStore)
+ await expect(ctx.plugin(SessionPersistenceSqlite, { path: `${path}\0` })).rejects.toMatchObject({
+ code: 'ERR_INVALID_ARG_VALUE',
+ })
+ await ctx.fiber.dispose()
+ })
+
+ it('rejects non-files and symbolic links', async () => {
+ const directoryPath = await freshDbPath('dsh-sqlite-directory-')
+ await mkdir(directoryPath)
+ expect(errorMessage(await backendFailure(directoryPath)))
+ .toMatch(/must be a regular file/)
+
+ const linkPath = await freshDbPath('dsh-sqlite-link-')
+ const target = join(linkPath, '..', 'target.db')
+ await writeFile(target, '')
+ await symlink(target, linkPath)
+ expect(errorMessage(await backendFailure(linkPath)))
+ .toMatch(/not a symbolic link/)
+
+ const parentLinkPath = await freshDbPath('dsh-sqlite-parent-link-')
+ const realParent = join(parentLinkPath, '..', 'real-parent')
+ const linkedParent = join(parentLinkPath, '..', 'linked-parent')
+ await mkdir(realParent, { mode: 0o700 })
+ await symlink(realParent, linkedParent)
+ expect(errorMessage(await backendFailure(join(linkedParent, 'sessions.db'))))
+ .toMatch(/must be a real directory/)
+ })
+
+ it.runIf(
+ process.getuid !== undefined && process.getuid() !== 0,
+ )('rejects permissive files and writable parents', async () => {
+ const permissivePath = await freshDbPath('dsh-sqlite-permissive-')
+ await writeFile(permissivePath, '')
+ await chmod(permissivePath, 0o644)
+ expect(errorMessage(await backendFailure(permissivePath)))
+ .toMatch(/accessible only by that user/)
+
+ const writableParentPath = await freshDbPath('dsh-sqlite-parent-')
+ await chmod(join(writableParentPath, '..'), 0o770)
+ expect(errorMessage(await backendFailure(writableParentPath)))
+ .toMatch(/not group\/world-writable/)
+ })
+
+ it('surfaces database creation failures after path validation', async () => {
+ const path = await freshDbPath('dsh-sqlite-create-failure-')
+ const store = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS })
+ await store.validatePath()
+ const parent = join(path, '..')
+ await rm(parent, { recursive: true })
+ await writeFile(parent, 'not a directory')
+ await expect(store.open()).rejects.toThrow(/ENOENT|ENOTDIR/)
+ await store.close()
})
})
diff --git a/packages/session/session-persistence-sqlite/tests/test-sql.ts b/packages/session/session-persistence-sqlite/tests/test-sql.ts
new file mode 100644
index 0000000000..77b53a404e
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/test-sql.ts
@@ -0,0 +1,32 @@
+/** Test-only loader for fixed SQLite fixtures. */
+
+import { readFileSync } from 'node:fs'
+
+export type TestSqlName =
+ | 'add-unexpected-column'
+ | 'count-events'
+ | 'count-ignorable-events'
+ | 'count-packed-events'
+ | 'count-physical-types'
+ | 'create-loose-schema'
+ | 'create-unrelated-table'
+ | 'delete-persistence-state'
+ | 'delete-session-events'
+ | 'empty-store-id'
+ | 'insert-corrupt-event'
+ | 'measure-write-traffic'
+ | 'replace-events-with-nonstrict-table'
+ | 'select-last-event'
+ | 'select-event-rowids'
+ | 'select-event-rows'
+ | 'select-user-version'
+ | 'set-application-id-12345'
+ | 'set-user-version-15'
+ | 'set-user-version-16'
+ | 'set-user-version-17'
+ | 'update-invalid-session-metadata'
+
+/** Load one fixed test SQL resource. */
+export function testSql(name: TestSqlName): string {
+ return readFileSync(new URL(`./resources/sql/${name}.sql`, import.meta.url), 'utf8')
+}
diff --git a/packages/session/session-persistence-sqlite/tsconfig.json b/packages/session/session-persistence-sqlite/tsconfig.json
index 2bb79c5919..8865e04321 100644
--- a/packages/session/session-persistence-sqlite/tsconfig.json
+++ b/packages/session/session-persistence-sqlite/tsconfig.json
@@ -20,6 +20,9 @@
{
"path": "../../core/session"
},
+ {
+ "path": "../../llm/llm"
+ },
{
"path": "../session-persistence"
},
diff --git a/packages/session/session-persistence/README.i18n.yaml b/packages/session/session-persistence/README.i18n.yaml
index 74b808b05e..d5623dca3f 100644
--- a/packages/session/session-persistence/README.i18n.yaml
+++ b/packages/session/session-persistence/README.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session/session-persistence/README.md
README.md: 335f40e7439fc4a49fa49bb4541f101250468d1b
-README.zh.md: 5a50f051a2c53a5f27b009df11ad46f5952a32ac
+README.zh.md: 7cda3f271398fca8279fcdfa92801aa219e9e8ed
diff --git a/packages/session/session-persistence/README.zh.md b/packages/session/session-persistence/README.zh.md
index 5a50f051a2..7cda3f2713 100644
--- a/packages/session/session-persistence/README.zh.md
+++ b/packages/session/session-persistence/README.zh.md
@@ -2,7 +2,7 @@
[English](README.md) | 中文
-会话持久化是一项能力 seam。抽象的 `SessionPersistence` 服务(`ctx.sessionPersistence`)是其 Service Definition。它要求持久化后端持久存储、重新加载和列出会话,但不规定具体存储实现。该 seam 采用与 `dsh-shell` 相同的角色划分(见[能力 seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):本包负责 Service Definition,同级包负责 Service Provider,Consumer 注入该服务。
+会话持久化是一项能力 seam。抽象的 `SessionPersistence` 服务(`ctx.sessionPersistence`)是其 Service Definition。它要求持久化后端持久存储、重新加载和列出会话,但不规定具体存储实现。该 seam 采用与 `dsh-shell` 相同的角色划分(见[能力 seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md)):本包负责 Service Definition,同级包负责 Service Provider,Consumer 注入该服务。
持久化单元就是现有 `SessionEvent`(事件溯源模型:日志是唯一真源),因此不存在另一套并行的「持久消息」类型。不属于可回放对话状态的元数据(格式版本、cwd、血缘、种子边界、origin、委托深度)作为 `SessionHeader` 单独传输,该类型归 `dsh-session` 所有,并在此重新导出。
@@ -31,13 +31,13 @@
## 写入协调器
-`PersistenceCoordinator` 负责每 id 状态和串行化、每个活动会话各自的有界写入 controller、延迟实体化、崩溃尾部修复、会话接管和完全停稳的 dispose。第一方后端组合一个协调器,实现小型 `PersistenceBackend` 存储钩子接口,并委托其有状态方法。因此 JSONL 和 SQLite 共享生命周期正确性,同时保留不同存储原语;见[协调器 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md)、[flush controller 简化](../../../.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.md)和[有界批处理决策](../../../.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md)。
+`PersistenceCoordinator` 负责每 id 状态和串行化、每个活动会话各自的有界写入 controller、延迟实体化、崩溃尾部修复、会话接管和完全停稳的 dispose。第一方后端组合一个协调器,实现小型 `PersistenceBackend` 存储钩子接口,并委托其有状态方法。因此 JSONL 和 SQLite 共享生命周期正确性,同时保留不同存储原语;见[协调器 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md)、[flush controller 简化](../../../.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.zh.md)和[有界批处理决策](../../../.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.zh.md)。
每个 `session/event` 将事件复制到会话 controller。第一个待处理事件会开启固定批处理窗口;后续事件会加入该批次,但不会重置截止时间。配置的 `writeBatchMaxDelayMs` 只限制这段有意等待,而不限制事件循环、初始化、串行化操作或后端延迟。写入期间接纳的事件会形成一个新的有界批次。`session/flush` 会取消等待,并作为共享的完全停稳屏障,排空屏障运行期间接纳的事件。后台写入失败只记录一次日志,保留顺序不变的批次,并暂停自动重试;新事件会开启新的固定窗口,而显式 flush 或后端拆卸会立即重试,并在失败再次发生时向调用方暴露失败。
崩溃修复只适用于冷状态。对于已有活动会话的 id,`load(id)` 为权威内存日志制作快照,等待该快照持久,并只在平衡时返回;活动会话中开放的轮次会被拒绝,而不会收到合成中断 closer。对于冷 id,检查只读取、验证、冻结并构造一次未发布 Session;只有来源修订值仍然是当前值时,重复检查才会复用该对象图。`prepare(id)` 在修复前执行相同校验,预留该 Session 本身,提交任何待处理的撕裂尾部或中断轮次修复,并将其返回用于发布。HMR(热模块替换)接管通过 `loadStored` 读取,应用协调器 cwd 检查,并绝不关闭活动轮次。
-后端读取会在验证当前记录前,转换同一格式版本中明确受支持的旧记录。消息标识机制引入前的消息会获得确定性的 id `legacy-message::`;工具结果的内容替换会继承其目标导入后的 id。react-loop 引入前的 `turn/start` 会移除过时的 trigger,已移除的 steering(中途引导)事件 `steering/message` 会转换为同一条带标识的 `user/message`;旧版 `turn/end` 会映射终止原因,但不会虚构旧记录中没有记载的调用方。协调器对 `load`、`inspect`、`readFrom`、无所有者状态的认领和 HMR 前缀接管使用同一份转换后视图。存储仍然仅追加:读取不会重写旧记录,此后追加的事件使用当前格式。这些是[消息标识机制引入前的消息](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md)与 [react-loop 引入前会话](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md)决策所规定的范围受限的导入例外,并不构成通用的 v0 迁移承诺。
+后端读取会在验证当前记录前,转换同一格式版本中明确受支持的旧记录。消息标识机制引入前的消息会获得确定性的 id `legacy-message::`;工具结果的内容替换会继承其目标导入后的 id。react-loop 引入前的 `turn/start` 会移除过时的 trigger,已移除的 steering(中途引导)事件 `steering/message` 会转换为同一条带标识的 `user/message`;旧版 `turn/end` 会映射终止原因,但不会虚构旧记录中没有记载的调用方。协调器对 `load`、`inspect`、`readFrom`、无所有者状态的认领和 HMR 前缀接管使用同一份转换后视图。存储仍然仅追加:读取不会重写旧记录,此后追加的事件使用当前格式。这些是[消息标识机制引入前的消息](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.zh.md)与 [react-loop 引入前会话](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.zh.md)决策所规定的范围受限的导入例外,并不构成通用的 v0 迁移承诺。
活动会话发出 `session/disposed` 时,协调器等待其 controller,以串行方式执行最终 drain,然后释放该精确 `Session` 对象拥有的状态。失败退役会将 controller 保留在活动会话 map 中,使后端拆卸可重试。后端拆卸先停止事件接纳,flush 每个剩余 controller,等待每 id 操作,最后才关闭存储句柄。
@@ -56,7 +56,7 @@
| `list(signal?)` | 列出全部已存储元数据,并遵循可选的取消信号。 |
| `close?()` | 可选生命周期拆卸(例如关闭 db 句柄),在 dispose drain 后等待其完成。 |
-协调器断言已存储 id,并在修复或活动会话接管前比较已存储/活动会话 cwd。其 `inspect()` 路径取得新鲜后端值的所有权,只验证和冻结一次,并在不调用 `commitRepair` 的情况下最多保留配置数量的未发布 Session。只有保留源的修订值仍等于 `readStoredRevision` 时,系统才会复用或修复它;否则协调器会重新读取。该新鲜性校验不会增加跨进程写入排他。持久日志在一次读取与复核往返内保持不变时,修订值重试才能收敛;持续的外部写入可能延迟 `load`、`inspect` 或 `prepare`。`tornMarker` 完全不透明:协调器只测试 `!== undefined`,并将其原样往返给 `commitRepair`,绝不检查值(JSONL 后端使用待截断字节偏移,SQLite 后端使用待删除 seq)。第三方后端可以不用协调器直接实现抽象服务,但必须提供相同的非修改式检查和可信轻量快照修订。详见[写入协调器 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md)。
+协调器断言已存储 id,并在修复或活动会话接管前比较已存储/活动会话 cwd。其 `inspect()` 路径取得新鲜后端值的所有权,只验证和冻结一次,并在不调用 `commitRepair` 的情况下最多保留配置数量的未发布 Session。只有保留源的修订值仍等于 `readStoredRevision` 时,系统才会复用或修复它;否则协调器会重新读取。该新鲜性校验不会增加跨进程写入排他。持久日志在一次读取与复核往返内保持不变时,修订值重试才能收敛;持续的外部写入可能延迟 `load`、`inspect` 或 `prepare`。`tornMarker` 完全不透明:协调器只测试 `!== undefined`,并将其原样往返给 `commitRepair`,绝不检查值(JSONL 后端使用待截断字节偏移,SQLite 后端使用待删除 seq)。第三方后端可以不用协调器直接实现抽象服务,但必须提供相同的非修改式检查和可信轻量快照修订。详见[写入协调器 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md)。
## 元数据与位置类型
diff --git a/packages/session/session-persistence/package.json b/packages/session/session-persistence/package.json
index 73bba56675..43b0108dc9 100644
--- a/packages/session/session-persistence/package.json
+++ b/packages/session/session-persistence/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-session-persistence",
"description": "Abstract durable session persistence seam (ctx.sessionPersistence) for the DeepSeek Harness",
- "version": "0.1.0-rc.7",
+ "version": "0.1.0-rc.8",
"publishConfig": {
"access": "public"
},
diff --git a/packages/session/session-persistence/src/coordinator.ts b/packages/session/session-persistence/src/coordinator.ts
index eb5f9714c4..63def62528 100644
--- a/packages/session/session-persistence/src/coordinator.ts
+++ b/packages/session/session-persistence/src/coordinator.ts
@@ -1170,7 +1170,8 @@ export class PersistenceCoordinator {
this.live.set(session, restored)
return restored
}
- const seed = session.events.map(e => structuredClone(e))
+ // Session owns this stable deep-frozen snapshot; backends only serialize it.
+ const seed = session.events
const live: LiveSessionState = {
init: Promise.resolve(),
writes: this.createWriteBehind(session, () => live.init),
diff --git a/packages/session/session-persistence/tests/persistence.spec.ts b/packages/session/session-persistence/tests/persistence.spec.ts
index 50ad798e04..6c63d72b9f 100644
--- a/packages/session/session-persistence/tests/persistence.spec.ts
+++ b/packages/session/session-persistence/tests/persistence.spec.ts
@@ -180,6 +180,7 @@ class ControlledBackend implements PersistenceBackend {
readonly name = 'session-persistence-controlled'
readonly store: MemoryStore = new Map()
readonly lifecycle: string[] = []
+ lastAppendedBatch: readonly SessionEvent[] | undefined
appendAttempts = 0
loadAttempts = 0
repairAttempts = 0
@@ -212,6 +213,7 @@ class ControlledBackend implements PersistenceBackend {
}
async appendBatch(m: SessionHeader, events: readonly SessionEvent[], _isMaterialized: boolean): Promise {
+ this.lastAppendedBatch = events
const attempt = ++this.appendAttempts
await this.beforeAppend?.(attempt)
const entry = this.store.get(m.id)
@@ -279,6 +281,28 @@ runCoordinatorContract('memory', async (): Promise => {
}
})
+describe('PersistenceCoordinator seed ownership', () => {
+ it('retains the immutable session seed without cloning it', async () => {
+ const ctx = new Context()
+ await ctx.plugin(SessionStore)
+ const backend = new ControlledBackend()
+ const fiber = await ctx.plugin(Object.assign((inner: Context) => {
+ new PersistenceCoordinator(inner, backend)
+ }, { inject: ['sessions'] }))
+
+ try {
+ const session = ctx.sessions.create(SessionId('shared-seed'), { seed: oneTurnLog() })
+ const seed = session.events
+ await ctx.sessions.flush(session)
+
+ expect(backend.lastAppendedBatch).toBe(seed)
+ } finally {
+ await fiber.dispose()
+ await ctx.fiber.dispose()
+ }
+ })
+})
+
describe('PersistenceCoordinator bounded writes', () => {
it('cancels the batching deadline when live initialization rejects', async () => {
vi.useFakeTimers()
diff --git a/packages/session/session-projection-cache/README.i18n.yaml b/packages/session/session-projection-cache/README.i18n.yaml
index 618533d282..609f146f24 100644
--- a/packages/session/session-projection-cache/README.i18n.yaml
+++ b/packages/session/session-projection-cache/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/session/session-projection-cache/README.md
-README.md: 5d4ad07fab6648acdb40c6aa86d32cc78b4c016e
-README.zh.md: 5e38ee98b04bc8f856538112d2922b64cadce4d5
+README.md: 33908578a5127f2b6bb78ed7467833aaaa2cf085
+README.zh.md: 0ca410f91562360d85faadf4cf64cb61ac467482
diff --git a/packages/session/session-projection-cache/README.md b/packages/session/session-projection-cache/README.md
index 5d4ad07fab..33908578a5 100644
--- a/packages/session/session-projection-cache/README.md
+++ b/packages/session/session-projection-cache/README.md
@@ -2,12 +2,13 @@
English | [中文](README.zh.md)
-The persisted projection cache (`ctx.sessionProjectionCache`): durable checkpoints of every registered projection unit's state, one record per session on the domain data form (`session_projcache` domain — the shipped json backend lands it beside `workspace.json` under the configured storage root). Design authority: the [session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md) (persisted projection cache section).
+The persisted projection cache (`ctx.sessionProjectionCache`): durable checkpoints of every projection unit's state, one record per session on the domain data form (`session_projcache` domain — the shipped json backend lands it beside `workspace.json` under the configured storage root). Design authority: the [session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md) (persisted projection cache section).
A stored row `(key → {ver, seq, val})` is a fold shortcut, never an authority: possibly stale (`seq` says exactly how stale) but never wrong. Consequences the implementation commits to:
- **Every background write is fail-soft.** A failed durable write logs a warning and keeps the cache stale; the next write or cold read self-heals. A crash between writes costs a longer tail replay, never a wrong value.
- **A `ver` mismatch against the live unit's `stateVersion` discards, never migrates.** A unit bump invalidates its rows at read time; the key refolds from the log.
+- **A row must pass the live unit's `stateSchema`.** A malformed row is omitted from the zero-I/O view and rejected by restore so the cold-read ladder refolds it from the log.
- **Whole-record writes.** Each write replaces the session's full checkpoint (the registry cut is always complete), snapshotted through the lossless-JSON boundary — a unit state violating the plain-JSON contract fails loud.
- **Records are bound to a log lifecycle, not just an id.** Each record stores the header identity (`createdAt`, `cwd`) it was folded from; every read validates it (the live or stored header is the witness) before accepting a row, so a deleted-then-recreated id or a persistence store swapped under a surviving cache discards the unrelated record instead of seeding phantom values.
- **The log leads, the cache follows.** A live checkpoint flushes the session's buffered events durably BEFORE the cache row lands, so a crash can leave the cache behind the log (a longer tail replay) but never ahead of it.
@@ -27,7 +28,7 @@ Both `Config` fields are required (no defaults): flush cadence is a deployment c
## Listing read (`cachedSnapshot(meta)`)
-The zero-I/O rung: whole values viewed straight from the identity-matching stored record (version-matching keys only), returned as a `{asOfSeq, values}` cut — `asOfSeq` is the lowest served-row watermark, so a client seeding its per-session value store under higher-seq-wins can never let a stale list block overwrite a newer push frame. `undefined` when no usable record exists (unknown id, unrelated lifecycle, or no version-matching rows); the api-proxy list carrier turns that into an absent column.
+The zero-I/O rung: client values viewed straight from the identity-matching stored record (version- and state-schema-matching keys only), returned as a `{asOfSeq, values}` cut — `asOfSeq` is the lowest served-row watermark, so a client seeding its per-session value store under higher-seq-wins can never let a stale list block overwrite a newer push frame. Host-only rows are never returned. `undefined` when no usable client row exists (unknown id, unrelated lifecycle, or no usable rows); the api-proxy list carrier turns that into an absent column.
## Cold read (`coldSnapshot(id, signal?)`)
diff --git a/packages/session/session-projection-cache/README.zh.md b/packages/session/session-projection-cache/README.zh.md
index 5e38ee98b0..0ca410f915 100644
--- a/packages/session/session-projection-cache/README.zh.md
+++ b/packages/session/session-projection-cache/README.zh.md
@@ -2,12 +2,13 @@
[English](README.md) | 中文
-持久投影缓存(`ctx.sessionProjectionCache`):把每个已注册投影单元的状态持久化为检查点,基于域数据形态(domain data form)每会话一条记录(`session_projcache` 域——出厂 JSON 后端将其落在配置的存储根目录下、`workspace.json` 旁边)。设计权威:[session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md)(persisted projection cache 一节)。
+持久投影缓存(`ctx.sessionProjectionCache`):把每个投影单元的状态保存为检查点,基于域数据形态(domain data form)每会话一条记录(`session_projcache` 域——出厂 JSON 后端将其落在配置的存储根目录下、`workspace.json` 旁边)。设计权威:[session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md)(persisted projection cache 一节)。
一条存储行 `(key → {ver, seq, val})` 是折叠捷径,绝不是权威:可能陈旧(`seq` 精确说明陈旧到哪),但绝不会错。实现据此承诺:
- **每次后台写入都 fail-soft。** 持久写失败只记一条警告并保持缓存陈旧;下一次写入或冷读自愈。两次写之间崩溃的代价是更长的尾部回放,绝不是错误的值。
- **`ver` 与当前运行单元的 `stateVersion` 不匹配即丢弃,绝不迁移。** 单元递增版本会在读取时使其行失效;该 key 从日志重新折叠。
+- **存储行必须通过当前单元的 `stateSchema`。** 畸形行从零 I/O view 中省略,并被 restore 拒绝,使冷读阶梯从日志重新折叠。
- **整记录写入。** 每次写入替换该会话的完整检查点(注册表切面始终是完整的),并经无损 JSON 边界快照——违反纯 JSON 约定的单元状态会显式失败并报错。
- **记录绑定到日志生命周期,而不只是 id。** 每条记录存储其折叠来源的 header 身份(`createdAt`、`cwd`);每次读取先以活 header 或存储 header 为证验证它,再接受任何行——被删后重建的 id、或缓存幸存而持久化存储被换掉时,无关记录被整体丢弃,绝不播种幻影值。
- **日志领先,缓存跟随。** 活会话检查点先把缓冲事件持久 flush,缓存行才落地,因此崩溃只会让缓存落后于日志(更长的尾部回放),绝不领先于它。
@@ -27,7 +28,7 @@
## 列表读(`cachedSnapshot(meta)`)
-零 I/O 一档:从身份匹配的存储记录直接 view 全量值(仅版本匹配的 key),以 `{asOfSeq, values}` 切面返回——`asOfSeq` 取所服务行的最低水位,客户端在 higher-seq-wins 规则下播种值存储时,陈旧列表块永远压不过更新的推送帧。无可用记录(未知 id、无关生命周期、无版本匹配行)时返回 `undefined`;api-proxy 列表载体将其转为列缺席。
+零 I/O 一档:从身份匹配的存储记录直接 view 客户端值(仅版本与 state schema 均匹配的 key),以 `{asOfSeq, values}` 切面返回——`asOfSeq` 取所服务行的最低水位,客户端在 higher-seq-wins 规则下播种值存储时,陈旧列表块永远压不过更新的推送帧。host-only 行永不返回。无可用客户端行(未知 id、无关生命周期、无可用行)时返回 `undefined`;api-proxy 列表载体将其转为列缺席。
## 冷读(`coldSnapshot(id, signal?)`)
diff --git a/packages/session/session-projection-cache/package.json b/packages/session/session-projection-cache/package.json
index 61694c5fb9..867717ba28 100644
--- a/packages/session/session-projection-cache/package.json
+++ b/packages/session/session-projection-cache/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-session-projection-cache",
"description": "Persisted projection cache (ctx.sessionProjectionCache): durable per-session projection checkpoints over the domain data form, throttled write-behind, and the cold-read ladder (cache row + persistence tail replay)",
- "version": "0.1.0-rc.7",
+ "version": "0.1.0-rc.8",
"publishConfig": {
"access": "public"
},
diff --git a/packages/session/session-projection-cache/src/index.ts b/packages/session/session-projection-cache/src/index.ts
index 66ad637402..f7bdb62a3a 100644
--- a/packages/session/session-projection-cache/src/index.ts
+++ b/packages/session/session-projection-cache/src/index.ts
@@ -1,6 +1,6 @@
/**
* Persisted projection cache (`ctx.sessionProjectionCache`): durable
- * checkpoints of every registered projection unit's state, one record per
+ * checkpoints of every client-visible or explicitly persisted projection unit's state, one record per
* session on the domain data form (`session_projcache` domain — the shipped
* json backend lands it beside `workspace.json`). The cache is a fold
* shortcut, never an authority: a row is possibly stale (its `seq`
@@ -185,10 +185,9 @@ export class SessionProjectionCache extends Service {
if (!related) throw new Error('unrelated log identity')
restored = this.ctx.sessionProjections.restore(cached, tail.events, floor)
} catch {
- // The recoverable restore failures: an unrelated record, or a row
- // overreaching the stored log end (or predating the floor). Both imply
- // floor > 0 (baseSeq-0 restores never throw and an unrelated record
- // still carried a usable watermark), so the full log is a fresh read.
+ // Recoverable failures are an unrelated record, a row outside the
+ // supplied suffix or log end, and stateSchema rejection. The full read
+ // removes every checkpoint seed and lets each unit refold from init.
const whole = await persistence.readFrom(id, 0, signal)
restored = this.ctx.sessionProjections.restore({}, whole.events, 0)
}
diff --git a/packages/session/session-projection-cache/tests/cache.spec.ts b/packages/session/session-projection-cache/tests/cache.spec.ts
index 6395ed7d0f..89154ec108 100644
--- a/packages/session/session-projection-cache/tests/cache.spec.ts
+++ b/packages/session/session-projection-cache/tests/cache.spec.ts
@@ -19,6 +19,10 @@ import { MemoryMediaPool, MemoryStorageBackend } from '../../../storage/storage-
import SessionProjectionCache from '../src/index.ts'
declare module '@deepseek-ai/dsh-session-projection/types' {
+ interface SessionProjectionStateMap {
+ 'cache-test/marks': MarksState
+ 'cache-test/marks2': Map
+ }
interface SessionProjectionMap {
'cache-test/marks': { marks: string[] }
}
@@ -35,14 +39,17 @@ declare module '@deepseek-ai/dsh-session/types' {
}
type MarksState = { marks: string[] } | null
-const marksUnit = (stateVersion = 1): ProjectionDefinition<'cache-test/marks', MarksState> => ({
+const marksUnit = (stateVersion = 1) => ({
key: 'cache-test/marks',
- schema: z.object({ marks: z.array(z.string()) }),
+ stateSchema: z.object({ marks: z.array(z.string()) }).nullable(),
init: () => null,
apply: (state, event) => (event.type === 'cache-test/mark' ? (event).data : state),
- view: state => state ?? { marks: [] },
+ wire: {
+ viewSchema: z.object({ marks: z.array(z.string()) }),
+ view: state => state ?? { marks: [] },
+ },
stateVersion,
-})
+}) satisfies ProjectionDefinition<'cache-test/marks', MarksState>
/** A persistence double serving readFrom over a fixed per-id stored log (headers stamp createdAt 0). */
function fakePersistence(logs: Map) {
@@ -175,11 +182,10 @@ describe('SessionProjectionCache write policy', () => {
expect(storedRows(pool, clean.id)?.['cache-test/marks']).toEqual({ ver: 1, seq: -1, val: null })
// A unit whose state violates the plain-JSON contract fails the write loud.
ctx.sessionProjections.register({
- key: 'cache-test/marks2' as never,
- schema: { parse: (value: unknown) => value } as never,
+ key: 'cache-test/marks2',
+ stateSchema: z.custom