better-dsh 0.2.3-g → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -52,3 +52,12 @@
52
52
  - 官方 harness 文档语料(241 个 md,源自 deepseek-ai/deepseek-harness docs/,经 dsh-dev-skill 抓取)vendor 进包内 `dsh-docs/`,随 `files` 发布。
53
53
  - 解析顺序:显式 docsDir → `pkgRoot/dsh-docs` → `pkgRoot/docs` → `pkgRoot/../docs`;docs-dir 逐级上溯在每层先探 `dsh-docs`。
54
54
  - 4999 部署后实测:`dsh://docs` 索引返回 agent-lifecycle/api-gateway/architecture/… 官方语料清单。
55
+
56
+ ## 追加(2026-09-15)— 外部调查四洞修复(commit 待填:本条 commit)
57
+ 外部 owner 级调查确认四个洞,全部修复并在 4999 部署后实测:
58
+ 1. **fan-out 死代码**:`obtainClient` 被提到 `all:true` 分支之前,primary 缺失时 fan-out 永不可达。修复:fan-out 提前分发(`diagnosticsFanOut` 独立函数),不再要求 primary client。
59
+ 2. **fan-out 失败与干净不可分**:每 server 结果现带 `status: ok|missing|error`(`perServer` 数组),summary 明示 "missing (NOT clean): …" / "errors: …"。4999 实测:`perServer:[{typescript-language-server,ok},{biome,missing},{eslint,missing},{denols,ok},{tailwindcss,missing}]`。
60
+ 3. **安装结果被丢弃**:`ensureMustHave` 的 `installServer` 结果现落入 `installFailed`(per-session),在 `status` 读面以 `installFailures` 呈现;成功安装后调 `clearCommandProbeCache` 清负探针缓存(修复负缓存永久误报)。
61
+ 4. **must-have 只在 write 后触发**:新增 `lspGateEnsure`,read/query-first 路径同样触发后台安装。附加:`resolveCommandPath` 显式探测 `~/.local/bin`、`~/.cargo/bin`、`~/go/bin`(daemon PATH 被 scrub 后 npm -g 安装位置不可见——本次 user 手工 symlink 到 /usr/local/bin 的根因)。
62
+ 5. **device summary 过期**("advertises 4, answers 11"):summary 改为完整动作枚举(read 面 + write 面)。
63
+ 6. **status 读面扩充**:`availability`(每语言 command+available)、`liveServers`、(会话内)`gate`+`installFailures`。4999 实测:python/typescript/rust available=true、go=false,liveServers 两条。
@@ -0,0 +1,109 @@
1
+ # Cookbook: adding a Session log format version
2
+
3
+ English | [中文](adding-a-session-format-version.zh.md)
4
+
5
+ ## Summary
6
+
7
+ Use this tutorial to introduce the next structural Session log version without rewriting released data. Read the [version and release-status authority](../session-format-status.md) to identify the checkout writer and the latest released format. Let N denote that verified released format and N+1 the target; substitute numeric values for these placeholders in names and metadata. Start with a working contributor checkout and read the [package checklist](adding-a-package.md), [format library](../../packages/session/session-format/README.md), and [released-format decision](../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md).
8
+
9
+ ## Table of Contents
10
+
11
+ - [1. Choose the version and release base](#choose-the-version)
12
+ - [2. Add an identity edge](#add-an-identity-edge)
13
+ - [3. Implement per-artifact stages and validation](#stages-and-validation)
14
+ - [4. Update current-version consumers](#current-version-consumers)
15
+ - [5. Create snapshot successors](#snapshot-successors)
16
+ - [6. Validate the integrated result](#validate)
17
+ - [Dev Note](#dev-note)
18
+
19
+ <a id="choose-the-version"></a>
20
+ ## 1. Choose the version and release base
21
+
22
+ Bump the format for a structural change to headers, event envelopes, core event semantics, or surface reconstruction. Ordinary event additions do not require a bump; follow the [versioning rule](../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md). Distinguish the Session format integer from package release versions, SQLite schema versions, projection-unit versions, and protocol-wrapper versions.
23
+
24
+ Use a shared `release/*` integration base for N+1. The base change adds the writer, codec, catalog wiring, identity migration, and verification. Create each independent child branch from that base and target its PR at the release branch, not another independent child’s branch. Each child adds its structural transformation, validators, consumers, and tests to the same adjacent migration package. Do not allocate extra versions just to represent review order. Merge reviewed children into the release branch through PRs, then validate the combined result before release. Honor release-branch force-push and deletion protections; do not force-sync it.
25
+
26
+ Released codecs and migration semantics remain frozen. Do not amend a released edge to implement a new structural feature. Only the N→N+1 edge may incorporate coordinated changes before N+1 ships; after release, further structural changes need the next adjacent edge.
27
+
28
+ Use disposable, isolated Harness homes for unreleased N+1 integration testing. An interim N+1 file already has the target writer version, so a later edit to N→N+1 will not migrate that file again. Re-run from unchanged historical input in a fresh test home; never repair this by rewriting a committed generation or reusing a real user's home.
29
+
30
+ <a id="add-an-identity-edge"></a>
31
+ ## 2. Add an identity edge
32
+
33
+ Follow the package checklist to create a library for N→N+1, not a mounted plugin. An identity body conversion is only an initial wiring scaffold. The [V2-to-V3 specification](../../packages/session/session-format-v2-to-v3/README.md#v2-to-v3-specification) is a fixed example of explicit transformations and preservation rules, not an edge to extend or treat as an identity conversion.
34
+
35
+ Declare `dsh.sessionFormatMigration` with numeric `from: N` and `to: N+1`, an export path, and the exported migration, source codec, target codec, target-header validator, and target restorer. Reuse the source codec exported by the preceding edge package and depend on that package; do not copy or redefine a released codec. Export the target codec and validators from the new package. Add the edge as a direct dependency of the catalog and add the workspace’s TypeScript paths and project references.
36
+
37
+ Set `SESSION_FORMAT_VERSION` in [core Session types](../../packages/core/session/src/types.ts) to N+1 alongside the new edge declarations, then generate the catalog. The command below generates only the declared chain; it does not implement a new version:
38
+
39
+ ```sh
40
+ pnpm run gen-session-format-catalog
41
+ ```
42
+
43
+ The [generator](../../scripts/gen-session-format-catalog.ts) requires exactly one adjacent package for every step from zero to the writer version, matching directory/package names, matching adjacent codec exports, and declared dependencies. It rejects gaps, duplicate or extra edges, unknown metadata members, and a catalog that does not share Session through peer plus development dependencies. Fix the declarations rather than hand-editing `generated.ts`. The catalog is build-static; plugin mounting must not determine historical readability.
44
+
45
+ <a id="stages-and-validation"></a>
46
+ ## 3. Implement per-artifact stages and validation
47
+
48
+ Use the [Stage interfaces](../../packages/session/session-format/src/types.ts), not a whole-artifact array-to-array migrator. An immutable `SessionFormatMigration` declaration supplies `migrateHeader`, `validateTargetHeader`, and `createStage`. Every call to `createStage` creates independent state for one source artifact. Keep counters, pending events, and reference maps there; never share a mutable stage across Sessions.
49
+
50
+ Implement `transformEvent(event, context)`, `transformRun(run, context)`, and `finish(context)`. Emit synchronously through `context.emitEvent` or `context.emitRun`; a call can produce zero, one, or many outputs. Let a stage consume codec-owned compact runs directly, or iterate `run.expand()` without materializing an intermediate array. The caller owns scheduling, and the chain finishes upstream stages before downstream stages.
51
+
52
+ Treat the inherited cut as a logical event count, not a physical row count. Expose `headerInheritedEventCount` only when it is known before EOF; `finish` returns the exact target cut. A preceding cardinality-changing edge can make that count unavailable at construction. Derive it from validated seed markers when required, and test seeded multi-hop restoration from each supported historical generation through N+1, not just direct N input. Never substitute zero for an unknown cut.
53
+
54
+ Define the new edge's event admission and transformation rules explicitly. The [V2-to-V3 source audit](../../packages/session/session-format-v2-to-v3/README.md#source-audit) and [alpha V0→V1 rule](../../.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.md) own the policies of those released edges, not the new edge. Do not generalize either to every edge. A change to structure or event positions requires classifying source events, payload members, and references, and explicitly deciding whether opaque data can remain valid. [Equal-version retention](../../.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.md) alone does not prove a structural transformation safe. Validate target semantics and give each newly accepted case a rejecting counterexample; never widen older edges to hide an unsupported transformation.
55
+
56
+ Prove strict restoration through `sessionFormatCatalog.createRestore(header, { recovery: 'strict', validation: 'current' })`, feeding rows in order and calling `finish()`. This exercises physical decoding, the complete chain, and installed current Session validation. Production's recoverable/transformed policy is not a replacement for strict fixture and publication verification. Preserve documented historical validation exceptions rather than claiming stricter source validation than the edge actually performs.
57
+
58
+ <a id="current-version-consumers"></a>
59
+ ## 4. Update current-version consumers
60
+
61
+ Trace each current-version consumer, including Session creation/restoration, JSONL filename selection and publication, the catalog's current encoder/restorer, projection-cache generation identity, replay and snapshot normalization, and TypeScript/Python SDK recordings. Use the writer constant where a value means current; keep literal historical versions in released codecs and historical fixtures. Update current documentation and generated references through their owners.
62
+
63
+ Do not bump unrelated versions automatically. A request wrapper's `sessionFormatVersion` identifies its embedded Session generation; its outer schema version has its own meaning. Projection-unit state versions likewise do not replace the cache's Session-generation identity.
64
+
65
+ Verify both read and write paths. Header-only listing must not read bodies or publish. Historical read open may return the migrated in-memory artifact without writing; write open must verify and publish only the final current successor before append. The source path, bytes, and inode stay unchanged. A newer or invalid selected generation must not cause fallback to a predecessor. The [preparation decision](../../.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.md) owns publication timing.
66
+
67
+ <a id="snapshot-successors"></a>
68
+ ## 5. Create snapshot successors
69
+
70
+ Read [snapshot ownership](../../snapshots/AGENTS.md) and the [snapshot library](../../packages/test-support/session-snapshot/README.md). Select the owning scenario, not an adapter that only references it. After implementing N+1, keep each historical file and generate its successor using the target version’s canonical parent and child filenames. Never rename a predecessor to the target filename or change only its header.
71
+
72
+ For unchanged replay input, use keyless refresh on the owner, then replay without write-back. These SDK commands use `text-turn` and the checkout's writer version. Implement and wire N+1 before using them to generate that version, and select the actual affected owner for a feature:
73
+
74
+ ```sh
75
+ pnpm run test:snapshot:refresh snapshots/sdk/sdk.snapshot.ts -t text-turn
76
+ pnpm run test:snapshot snapshots/sdk/sdk.snapshot.ts -t text-turn
77
+ ```
78
+
79
+ Review the new generation, request sidecars, and protocol output together. Verify every predecessor remains byte-identical and that parent/child roles remain contiguous. Selection uses the numerically highest generation, so update shared references to the owner's selected parent. Do not use the packed-layout migrator as a version upgrader. If the model transcript must change, the scenario owner uses live recording under the [testing policy](../testing.md), with its required provider key.
80
+
81
+ Keep deliberate historical cases explicit through `snapshot.yml`'s `sessionFormat.version` and supported `coverage` names; record and refresh leave their Session fixtures untouched. Update the [corpus policy](../../scripts/session-snapshot-corpus-policy.ts) for the current generation while retaining focused direct-edge, multi-hop, packed-row, retry/failure, and shipped-profile coverage. Check the corpus and both SDK projections; do not mass-refresh unrelated scenarios merely to silence a validation failure.
82
+
83
+ <a id="validate"></a>
84
+ ## 6. Validate the integrated result
85
+
86
+ Run from the repository root. These commands check catalog declarations, Stage composition, the released V2→V3 edge, and generation selection. They are a baseline; add focused coverage for the new edge:
87
+
88
+ ```sh
89
+ pnpm run verify-session-format-catalog
90
+ pnpm exec vitest run scripts/gen-session-format-catalog.spec.ts packages/session/session-format/tests packages/session/session-format-v2-to-v3/tests packages/session/session-format-catalog/tests
91
+ pnpm run test:snapshot scripts/session-snapshot-corpus.corpus.ts
92
+ ```
93
+
94
+ After implementing the new edge, add its actual test path to the focused Vitest run. Add the changed JSONL, replay, projection, and SDK tests selected by the actual diff, plus the built publication-Worker smoke when that path changes. Require successful strict migration, identity preservation for the skeleton, malformed and unknown-required-event refusal, deterministic repeated restores, independent concurrent stage state, seeded multi-hop cuts, unchanged predecessors, and no fallback. Report exact commands and failures, not an inferred full-suite result.
95
+
96
+ Update the [owning Agent Note](../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md) rather than adding a redundant decision record. Keep the [release record](../session-format-status.md#updating-the-record) unchanged until publication; after publication, update it with verified release evidence. Audit related active notes for supersession; retain independent rationale and leave archived notes frozen. Update bilingual prose together, re-record each changed pair with the repository tool, then run documentation checks:
97
+
98
+ ```sh
99
+ pnpm run verify-translation-pairing --write docs/cookbook/adding-a-session-format-version.md
100
+ pnpm run test:docs
101
+ pnpm run doc-sync
102
+ pnpm run lint
103
+ git diff --check
104
+ ```
105
+
106
+ <a id="dev-note"></a>
107
+ ## Dev Note
108
+
109
+ None.
@@ -0,0 +1,109 @@
1
+ # 实操手册:添加 Session 日志格式版本
2
+
3
+ [English](adding-a-session-format-version.md) | 中文
4
+
5
+ ## 概述
6
+
7
+ 本教程介绍如何添加下一个结构性 Session 日志版本,同时不改写已发布数据。阅读[版本与发布状态真源](../session-format-status.zh.md),确定工作区写入器与最新已发布格式。令 N 表示经核实的已发布格式,N+1 表示目标版本;名称与元数据中的这些占位符须替换为数字。开始前,请准备可用的贡献者工作区,并阅读[包检查清单](adding-a-package.zh.md)、[格式库](../../packages/session/session-format/README.zh.md)和[已发布格式决策](../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md)。
8
+
9
+ ## 目录
10
+
11
+ - [1. 选择版本与发布基线](#choose-the-version)
12
+ - [2. 添加恒等迁移边](#add-an-identity-edge)
13
+ - [3. 实现每份产物独占的 Stage 与校验](#stages-and-validation)
14
+ - [4. 更新当前版本消费方](#current-version-consumers)
15
+ - [5. 创建快照后继代际](#snapshot-successors)
16
+ - [6. 验证集成结果](#validate)
17
+ - [开发备注](#dev-note)
18
+
19
+ <a id="choose-the-version"></a>
20
+ ## 1. 选择版本与发布基线
21
+
22
+ 当 header、事件信封、核心事件语义或表面重建发生结构性变更时,提升格式版本。普通事件新增不需要提升版本;遵循[版本规则](../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md)。区分 Session 格式整数与包发布版本、SQLite schema 版本、投影单元版本及协议包装层版本。
23
+
24
+ 为 N+1 使用共享的 `release/*` 集成基线。基线变更添加写入器、codec、catalog 接线、恒等迁移与验证。从该基线创建各个独立子分支,并将其 PR(Pull Request)的目标设为发布分支,而非另一个独立子分支。每个子分支在同一个相邻迁移包内添加自身的结构变换、校验器、消费方和测试。不要只为表示评审顺序而分配额外版本。通过 PR 将评审后的子分支合入发布分支,并在发布前验证组合结果。遵守发布分支的强制推送与删除保护;不要强制同步该分支。
25
+
26
+ 已发布 codec 和迁移语义保持冻结。不要通过修改已发布迁移边来实现新的结构性功能。只有 N→N+1 迁移边可在 N+1 发布前纳入协同变更;发布后,进一步的结构性变更需要下一条相邻迁移边。
27
+
28
+ 未发布 N+1 的集成测试应使用可丢弃、相互隔离的 Harness home。中间版本产生的 N+1 文件已标为目标写入器版本,因此后续对 N→N+1 的修改不会再次迁移该文件。请在全新测试 home 中从未变更的历史输入重新运行;绝不通过改写已提交代际或复用真实用户 home 来修复这个问题。
29
+
30
+ <a id="add-an-identity-edge"></a>
31
+ ## 2. 添加恒等迁移边
32
+
33
+ 按照包检查清单为 N→N+1 创建库,而非挂载插件。恒等正文转换仅是最初的接线骨架。[V2 到 V3 规范](../../packages/session/session-format-v2-to-v3/README.zh.md#v2-to-v3-specification)是明确转换与保留规则的固定示例,而不是可继续扩展或视为恒等转换的迁移边。
34
+
35
+ 在 manifest(元数据清单)中声明 `dsh.sessionFormatMigration`,包含数值 `from: N` 和 `to: N+1`、导出路径,以及导出的迁移、源 codec、目标 codec、目标 header 校验器和目标恢复器。复用前一条迁移边所属包导出的源 codec,并依赖该包;不要复制或重新定义已发布 codec。从新包导出目标 codec 和校验器。将迁移边加入 catalog 的直接依赖,并添加工作区的 TypeScript 路径与项目引用。
36
+
37
+ 在添加新迁移边声明的同时,将[核心 Session 类型](../../packages/core/session/src/types.ts)中的 `SESSION_FORMAT_VERSION` 设为 N+1,然后生成 catalog。下面的命令只生成已声明的迁移链;它不会实现新版本:
38
+
39
+ ```sh
40
+ pnpm run gen-session-format-catalog
41
+ ```
42
+
43
+ [生成器](../../scripts/gen-session-format-catalog.ts)要求从零到写入器版本的每一步恰好有一个相邻迁移包,目录与包名匹配、相邻 codec 导出匹配,并声明所需依赖。它拒绝缺口、重复或多余的迁移边、未知元数据成员,以及未通过对等依赖(peer dependency)加开发依赖共享 Session 的 catalog。请修复声明,而非手改 `generated.ts`。Catalog 在构建时静态确定;插件挂载不得决定历史数据是否可读。
44
+
45
+ <a id="stages-and-validation"></a>
46
+ ## 3. 实现每份产物独占的 Stage 与校验
47
+
48
+ 使用 [Stage 接口](../../packages/session/session-format/src/types.ts),不要使用整份产物的数组到数组迁移器。不可变的 `SessionFormatMigration` 声明提供 `migrateHeader`、`validateTargetHeader` 和 `createStage`。每次调用 `createStage` 都为一份源产物创建独立状态。计数器、待处理事件和引用映射归该状态所有;不同 Session 之间绝不共享可变 Stage。
49
+
50
+ 实现 `transformEvent(event, context)`、`transformRun(run, context)` 和 `finish(context)`。通过 `context.emitEvent` 或 `context.emitRun` 同步输出;一次调用可以产生零个、一个或多个输出。让 Stage 直接消费 codec 所有的紧凑 run,或者迭代 `run.expand()`,而不物化中间数组。调用方负责调度,迁移链先结束上游 Stage,再结束下游 Stage。
51
+
52
+ 继承截点是逻辑事件数量,不是物理行数。只有在 EOF 前已知时才公开 `headerInheritedEventCount`;`finish` 返回精确的目标截点。前一条改变事件数量的迁移边可能使该数量在构造时不可知。必要时从已校验的种子标记推导它,并测试从每个受支持历史代际到 N+1 的有种子多跳恢复,而非仅测试直接 N 输入。绝不以零替代未知截点。
53
+
54
+ 显式定义新迁移边的事件准入与变换规则。[V2 到 V3 源审计](../../packages/session/session-format-v2-to-v3/README.zh.md#source-audit)和 [Alpha V0→V1 规则](../../.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.zh.md)分别负责对应已发布迁移边的策略,而非新迁移边的策略。不要将任一策略推广到所有迁移边。结构或事件位置变化时,必须分类源事件、载荷成员与引用,并显式判断不透明数据能否保持有效。[同版本保留](../../.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.zh.md)本身不能证明结构变换安全。校验目标语义,并为每个新增可接受案例提供一个被拒绝的反例;绝不放宽旧迁移边来掩盖不受支持的转换。
55
+
56
+ 通过 `sessionFormatCatalog.createRestore(header, { recovery: 'strict', validation: 'current' })` 验证严格恢复,按顺序传入各行并调用 `finish()`。这会执行物理解码、完整迁移链与已安装当前 Session 校验。生产环境的 recoverable/transformed 策略不能替代 fixture(测试前置数据)和发布验证所需的严格校验。保留已记录的历史校验例外,不要宣称源校验比迁移边实际执行的更严格。
57
+
58
+ <a id="current-version-consumers"></a>
59
+ ## 4. 更新当前版本消费方
60
+
61
+ 追踪每个当前版本消费方,包括 Session 创建与恢复、JSONL 文件名选择与发布、catalog 的当前编码器与恢复器、投影缓存的代际身份、回放与快照归一化,以及 TypeScript/Python SDK 录制。当值表示当前版本时使用写入器常量;在已发布 codec 和历史 fixture 中保留字面历史版本。通过各自所有者更新当前文档与生成参考。
62
+
63
+ 不要自动提升无关版本。请求包装层的 `sessionFormatVersion` 标识嵌入的 Session 代际;外层 schema 版本有自己的含义。投影单元状态版本同样不能替代缓存的 Session 代际身份。
64
+
65
+ 验证读取与写入两条路径。仅 header 的列表操作不得读取正文或发布。历史读取打开可以直接返回迁移后的内存产物而不写入;写入打开必须先校验并发布唯一的最终当前后继代际,再允许追加。源路径、字节与 inode 保持不变。所选代际高于当前版本或无效时,不得回退到前代。[准备阶段决策](../../.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.zh.md)负责发布时序。
66
+
67
+ <a id="snapshot-successors"></a>
68
+ ## 5. 创建快照后继代际
69
+
70
+ 阅读[快照所有权](../../snapshots/AGENTS.md)和[快照库](../../packages/test-support/session-snapshot/README.zh.md)。选择拥有数据的场景,而非仅引用它的适配器。实现 N+1 后,保留每份历史文件,并按目标版本的规范父子文件名生成后继文件。绝不将前代重命名为目标文件名,或仅修改其 header。
71
+
72
+ 如果回放输入不变,在所有者上执行无密钥 refresh,再执行不写回的 replay。以下 SDK 命令使用 `text-turn` 和工作区的写入器版本。先实现并接入 N+1,才能用它们生成该版本;功能变更应选择实际受影响的所有者:
73
+
74
+ ```sh
75
+ pnpm run test:snapshot:refresh snapshots/sdk/sdk.snapshot.ts -t text-turn
76
+ pnpm run test:snapshot snapshots/sdk/sdk.snapshot.ts -t text-turn
77
+ ```
78
+
79
+ 一起审查新代际、请求伴随文件与协议输出。验证每个前代的字节保持相同,且父子角色连续。选择规则采用数值最高的代际,因此应将共享引用更新为所有者选中的父代际。不要把 packed 布局迁移器当作版本升级器。如果模型 transcript(文本记录)必须变化,由场景所有者按照[测试策略](../testing.zh.md)使用所需提供方密钥进行实时录制。
80
+
81
+ 通过 `snapshot.yml` 的 `sessionFormat.version` 与受支持的 `coverage` 名称显式保留历史案例;record 和 refresh 不改动这些 Session fixture。更新[语料策略](../../scripts/session-snapshot-corpus-policy.ts)以采用当前代际,同时保留聚焦的直接迁移边、多跳、packed row、重试/失败及交付 profile 覆盖。检查语料和两个 SDK 投影;不要仅为消除校验失败而批量 refresh 无关场景。
82
+
83
+ <a id="validate"></a>
84
+ ## 6. 验证集成结果
85
+
86
+ 从仓库根目录运行。以下命令检查 catalog 声明、Stage 组合、已发布的 V2→V3 迁移边与代际选择。它们是基线检查;需为新迁移边添加聚焦覆盖:
87
+
88
+ ```sh
89
+ pnpm run verify-session-format-catalog
90
+ pnpm exec vitest run scripts/gen-session-format-catalog.spec.ts packages/session/session-format/tests packages/session/session-format-v2-to-v3/tests packages/session/session-format-catalog/tests
91
+ pnpm run test:snapshot scripts/session-snapshot-corpus.corpus.ts
92
+ ```
93
+
94
+ 实现新迁移边后,将其实际测试路径加入聚焦的 Vitest 命令。根据实际 diff 添加受影响的 JSONL、回放、投影与 SDK 测试;发布 Worker 路径变化时还需构建产物冒烟测试。要求严格迁移成功、骨架保持恒等、拒绝格式错误与未知必需事件、重复恢复确定、并发 Stage 状态独立、有种子的多跳截点正确、前代不变且无回退。报告确切命令与失败,不要推断整个测试套件的结果。
95
+
96
+ 更新[所属 Agent Note](../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md),而非添加重复决策记录。发布前保持[发布记录](../session-format-status.zh.md#updating-the-record)不变;发布后,使用已核实的发布证据更新它。审计相关活跃记录的取代关系;保留独立理由,并保持归档记录冻结。一起更新双语正文,通过仓库工具重新记录每个变更的配对,然后运行文档检查:
97
+
98
+ ```sh
99
+ pnpm run verify-translation-pairing --write docs/cookbook/adding-a-session-format-version.md
100
+ pnpm run test:docs
101
+ pnpm run doc-sync
102
+ pnpm run lint
103
+ git diff --check
104
+ ```
105
+
106
+ <a id="dev-note"></a>
107
+ ## 开发备注
108
+
109
+ 无。
@@ -0,0 +1,47 @@
1
+ # Session format version and release status
2
+
3
+ English | [中文](session-format-status.zh.md)
4
+
5
+ ## Summary
6
+
7
+ Use this reference to distinguish the checkout’s Session writer version from the latest published Session format. The code constant owns the writer version; the release record below owns the latest released format and its publication evidence. Other documentation links here instead of restating which version is current, next, or unreleased.
8
+
9
+ ## Table of Contents
10
+
11
+ - [Sources of truth](#sources-of-truth)
12
+ - [Release record](#release-record)
13
+ - [Updating the record](#updating-the-record)
14
+ - [Dev Note](#dev-note)
15
+
16
+ <a id="sources-of-truth"></a>
17
+ ## Sources of truth
18
+
19
+ - **Checkout writer:** `SESSION_FORMAT_VERSION` in [core Session types](../packages/core/session/src/types.ts) is the only hand-maintained current-writer number in code. The [catalog generator](../scripts/gen-session-format-catalog.ts) derives codec ordering and checks that adjacent migrations reach it. A package version, codec export name, fixture filename, or projection-cache version is not the writer authority.
20
+ - **Latest released format:** `latestReleasedVersion` in the following record identifies the published Session format. `evidenceTag` names a published product release whose tagged writer has that value; it need not be the first release carrying the format. The bilingual copy is checked against the same record, not maintained as a separate decision.
21
+ - **Release status:** compare the writer constant with the verified release record. Equality means the writer format has shipped. A greater writer version is a development target beyond the recorded release. When comparing an older checkout against a newer branch’s verified record, a lower writer version identifies an older writer format; the local consistency gate rejects that ordering within one checkout. No separate released boolean is maintained. Before declaring a greater version unreleased, verify that no published release has advanced the record.
22
+
23
+ An alpha, beta, or release-candidate product publication establishes released Session-format obligations. GitHub’s prerelease flag does not make persisted user data disposable. A missing release record is not evidence of non-publication. The [versioning and authority decision](../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md) owns compatibility decisions; [released-format migration](../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md) owns immutable generations and adjacent conversion.
24
+
25
+ <a id="release-record"></a>
26
+ ## Release record
27
+
28
+ ```yaml session-format-release
29
+ latestReleasedVersion: 3
30
+ evidenceTag: dsh-v0.1.5-alpha.1
31
+ ```
32
+
33
+ Evidence: [published release](https://github.com/deepseek-harness/deepseek-harness/releases/tag/dsh-v0.1.5-alpha.1) and [its tagged writer source](https://github.com/deepseek-harness/deepseek-harness/blob/dsh-v0.1.5-alpha.1/packages/core/session/src/types.ts).
34
+
35
+ <a id="updating-the-record"></a>
36
+ ## Updating the record
37
+
38
+ When a structural writer change is implemented, update the code constant and adjacent catalog together; do not advance this release record before publication. When a product release first publishes a higher Session format, confirm publication and its tagged writer, then advance this record and both evidence links in the same bilingual update. Later product releases carrying the same format do not require changing the record. Never lower it on the development trunk.
39
+
40
+ The [documentation-standard test](../scripts/doc-standard.spec.ts) checks record structure, bilingual equality, evidence-link consistency, and that the documented release does not exceed the checkout writer. This keyless check does not query GitHub or prove that the record is up to date; publication verification remains part of the release update.
41
+
42
+ Use “current format” and “next adjacent version” for general behavior. Keep explicit numbers for fixed migration inputs and outputs, wire schemas, historical evidence, and tests of those particular versions. The [format-version cookbook](cookbook/adding-a-session-format-version.md) uses N for the verified latest released format and N+1 for its successor.
43
+
44
+ <a id="dev-note"></a>
45
+ ## Dev Note
46
+
47
+ None.
@@ -0,0 +1,47 @@
1
+ # Session 格式版本与发布状态
2
+
3
+ [English](session-format-status.md) | 中文
4
+
5
+ ## 概述
6
+
7
+ 本参考区分工作区的 Session 写入器版本与最新已发布的 Session 格式。代码常量拥有写入器版本;下方发布记录拥有最新已发布格式及其发布证据。其他文档链接到这里,而不重复声明哪个版本是当前、下一个或尚未发布的版本。
8
+
9
+ ## 目录
10
+
11
+ - [单一真源](#sources-of-truth)
12
+ - [发布记录](#release-record)
13
+ - [更新记录](#updating-the-record)
14
+ - [开发备注](#dev-note)
15
+
16
+ <a id="sources-of-truth"></a>
17
+ ## 单一真源
18
+
19
+ - **工作区写入器:**[核心 Session 类型](../packages/core/session/src/types.ts)中的 `SESSION_FORMAT_VERSION` 是代码中唯一手工维护的当前写入器版本号。[目录生成器](../scripts/gen-session-format-catalog.ts)推导 codec 顺序,并检查相邻迁移是否到达该版本。包版本、codec 导出名称、fixture(测试前置数据)文件名或投影缓存版本都不是写入器版本的权威来源。
20
+ - **最新已发布格式:**下方记录中的 `latestReleasedVersion` 标识已发布的 Session 格式。`evidenceTag` 指定一个已发布的产品版本,其标签对应的写入器具有该值;它不必是首次携带该格式的发布。双语副本按同一记录校验,不作为独立决策维护。
21
+ - **发布状态:**比较写入器常量与已核实的发布记录。相等表示写入器格式已经发布。写入器版本更高表示它是超出记录中发布版本的开发目标。用较新分支中已核实的记录对比旧工作区时,较低的写入器版本表示较旧的写入器格式;本地一致性门禁会拒绝同一工作区内的这种大小关系。不另行维护 released 布尔值。在声明更高版本尚未发布前,必须核实是否已有产品发布推进了记录。
22
+
23
+ 产品的 alpha、beta 或 release-candidate 发布都会确立已发布 Session 格式的义务。GitHub 的 prerelease 标记不会让持久化用户数据成为可丢弃数据。缺少发布记录不代表尚未发布。[版本与真源决策](../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md)拥有兼容性决策;[已发布格式迁移](../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md)拥有不可变代际与相邻转换规则。
24
+
25
+ <a id="release-record"></a>
26
+ ## 发布记录
27
+
28
+ ```yaml session-format-release
29
+ latestReleasedVersion: 3
30
+ evidenceTag: dsh-v0.1.5-alpha.1
31
+ ```
32
+
33
+ 证据:[已发布产品版本](https://github.com/deepseek-harness/deepseek-harness/releases/tag/dsh-v0.1.5-alpha.1)及[对应标签的写入器源码](https://github.com/deepseek-harness/deepseek-harness/blob/dsh-v0.1.5-alpha.1/packages/core/session/src/types.ts)。
34
+
35
+ <a id="updating-the-record"></a>
36
+ ## 更新记录
37
+
38
+ 实现结构性写入器变更时,一起更新代码常量与相邻迁移目录;不要在产品发布前推进此发布记录。当产品首次发布更高的 Session 格式时,确认发布事实及对应标签的写入器,然后在同一次双语更新中推进本记录与两个证据链接。后续携带相同格式的产品发布无需改变此记录。开发主干上的记录绝不降低。
39
+
40
+ [文档标准测试](../scripts/doc-standard.spec.ts)检查记录结构、双语一致性、证据链接一致性,以及文档中的已发布版本不高于工作区写入器。这个无密钥检查不会查询 GitHub,也不能证明记录是最新的;核实发布事实仍属于发布更新的一部分。
41
+
42
+ 一般行为使用“当前格式”和“下一条相邻版本”等表述。固定迁移的输入与输出、协议 schema、历史证据及针对特定版本的测试保留明确版本号。[格式版本实操手册](cookbook/adding-a-session-format-version.zh.md)用 N 表示已核实的最新发布格式,用 N+1 表示其后继版本。
43
+
44
+ <a id="dev-note"></a>
45
+ ## 开发备注
46
+
47
+ 无。
@@ -0,0 +1,91 @@
1
+ # Client Resources
2
+
3
+ English | [中文](client-resources.zh.md)
4
+
5
+ The client resource model turns an address into live data for any Web Client component. [`dsh-client-resources`](../../packages/client/resources/README.md) provides the `ctx.resources` service and the `useResource` global standard hook; a package that owns a kind of content registers one **provider** for its **protocol**, and a component reads the content's current state by **address** without importing the owner's runtime. The right Sidebar's tabs are the model's first consumer ([Right Sidebar](sidebar-right.md)); the decision record is the [client resource model Agent Note](../../.agents/notes/implemented/architecture/2026-09-05-client-resource-model.md).
6
+
7
+ This page is the developer reference: how to write an address, how to register a provider, how to read a resource, what the states and failures mean, and how the model holds and releases a resource.
8
+
9
+ ## Addresses
10
+
11
+ A resource address is a `dsh-resource://<type>/…` URL. The host names the protocol and must be a key of `ResourceProtocolMap`; the path is the protocol's own, and its owner percent-encodes each segment. A protocol that needs a scope puts it in the path: the `file` protocol's addresses read `dsh-resource://file/session/<sessionId>/<path>`, where path is workspace-relative or absolute with its leading slashes preserved, built with `fileAddressFor(sessionId, cwd, path)` and read back with `parseFileAddress(address)` from [`dsh-util-workspace-path`](../../packages/util/workspace-path/README.md). The model itself reads only the scheme and the host: `protocolOf(address)` returns the lower-cased host of a `dsh-resource://` URL and `undefined` for anything else. Addresses under any other scheme — the Sidebar's `sidebar://guide` — name no resource and read as `none`.
12
+
13
+ | Address | Protocol key | Reads as |
14
+ |---|---|---|
15
+ | `dsh-resource://file/session/s1/notes/a.md` | `file` | the metadata of `notes/a.md` under session `s1`'s workspace root, when the `file` provider is registered |
16
+ | `dsh-resource://file/absolute/home/me/notes.md` | `file` | parseable but fails with `workspace-file/unknown-workspace`: no authorizing Session, and neither current nor Tab Session is borrowed |
17
+ | `DSH-RESOURCE://File/session/s1/a` | `file` | a distinct record: addresses compare as strings, and `openResource` accepts only the canonical lower-case spelling that `fileAddressFor` emits |
18
+ | `sidebar://guide` | — | `none`: a navigation address |
19
+ | `/home/me/notes.md` | — | `none`: not a URL |
20
+
21
+ ## Registering a provider
22
+
23
+ The owner of a protocol declares its value type on `ResourceProtocolMap` and registers one provider inside its own `ctx.effect`, so the protocol lives exactly as long as the plugin ([provide a protocol](../../packages/client/resources/README.md#provide-a-protocol)). `open(address, { signal })` returns a stream of `RemoteResult` frames — the current state first, then one frame per change — and must stop when `signal` aborts. A failure is an `ok: false` frame carrying a `RemoteFailure`; a throw inside the stream is a programming error and is not caught.
24
+
25
+ ```ts ignore-check
26
+ import type { Context } from '@deepseek-ai/cordis'
27
+ import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
28
+ import type {} from '@deepseek-ai/dsh-client-resources/client'
29
+
30
+ interface NoteView { readonly title: string; readonly updatedAt: string }
31
+
32
+ declare module '@deepseek-ai/dsh-client-ui-slots' {
33
+ interface ResourceProtocolMap { note: NoteView }
34
+ }
35
+
36
+ export const inject = ['resources', 'remote']
37
+
38
+ export function apply(ctx: Context): void {
39
+ ctx.effect(() => ctx.resources.register<'note'>({
40
+ protocol: 'note',
41
+ async *open(address, { signal }): AsyncIterable<RemoteResult<NoteView>> {
42
+ const id = new URL(address).pathname.slice(1)
43
+ yield await ctx.remote.notes.read(id, signal)
44
+ for await (const change of ctx.remote.notes.follow(id, signal)) yield change
45
+ },
46
+ }), 'my-notes: note resource provider')
47
+ }
48
+ ```
49
+
50
+ A protocol has exactly one provider; a second registration throws. Registering while addresses of the protocol are already held opens their streams at once; disposing the provider ends those streams and the addresses read `none` until a provider returns.
51
+
52
+ ## Reading a resource
53
+
54
+ Every slot component receives `useResource` in its props, whatever its scope ([Slots](slots.md)). `useResource<P>(address)` names the protocol as the type argument and returns the address's current snapshot; subscribing is what holds the resource open, and a component that mounts while another holder keeps the resource alive reads the latest value at once without reopening the stream ([read a resource](../../packages/client/resources/README.md#read-a-resource)).
55
+
56
+ | `status` | Meaning | `value` | `failure` |
57
+ |---|---|---|---|
58
+ | `none` | No provider is registered for the address's protocol, or the address is not a resource address | `undefined` | `undefined` |
59
+ | `loading` | The provider's stream is open and has not yielded yet | `undefined` | `undefined` |
60
+ | `live` | The latest frame succeeded | the latest `ok` value | `undefined` |
61
+ | `failed` | The latest frame reported a failure | the last `ok` value, kept | the frame's `RemoteFailure` |
62
+
63
+ ```tsx ignore-check
64
+ import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
65
+ import type {} from '@deepseek-ai/dsh-api-workspace-files/client'
66
+
67
+ type Props = PropsRuntime<'sidebar.right.pane.tab'>
68
+
69
+ export function FileHeader({ useTabInfo, useResource, t }: Props) {
70
+ const { tab } = useTabInfo()
71
+ const meta = useResource<'file'>(tab.contentId)
72
+ if (meta.status === 'failed') return <p role="alert">{t('failed', { code: meta.failure.code })}</p>
73
+ return (
74
+ <header>
75
+ {tab.title}
76
+ </header>
77
+ )
78
+ }
79
+ ```
80
+
81
+ A consumer presents `failed` itself: the model keeps the last value beside the failure so a body can show stale content with a notice rather than a blank, and the next `ok` frame clears the failure. Nothing in the model produces user-visible text.
82
+
83
+ ## Holding and releasing
84
+
85
+ A resource is alive while it has a holder: a subscribed `useResource`, or a pin. `ctx.resources.pin(address, signal)` keeps a resource open without subscribing until `signal` aborts, and an already-aborted signal pins nothing; the right Sidebar pins every open tab record's address for the record's life, so switching tabs unmounts a body without closing its stream. The first holder opens the provider's stream; the last release aborts it, discards the value, and returns the snapshot to `loading` (provider present) or `none` (absent). A frame the provider yields after that release is dropped, and the iterator is returned. `ctx.resources.source(address)` is the bare observable behind the hook, reference-stable per address, for callers outside React; reading its snapshot does not hold the resource ([lifecycle](../../packages/client/resources/README.md#lifecycle)).
86
+
87
+ Streams carry metadata, not content. The `file` provider's value is `WorkspaceFileStat { absolutePath, version, bytes? }`: the first frame comes from Host `stat`, and later observations update the version. A consumer reads content through the Workspace Files Remote namespace; Preview owns refresh independently per tab ([`dsh-api-workspace-files`](../../packages/api/workspace-files/README.md)).
88
+
89
+ ## Limits
90
+
91
+ Records live for the page lifetime: an address's record stays after its last holder leaves, holding no stream and no value, so memory grows with the number of distinct addresses ever read. A provider that ignores `signal` keeps running until its next frame. The failure type is the Remote face's `RemoteFailure`, so a provider whose source is not a Remote call mints one. A misspelled protocol or a malformed address reads as `none` with no other diagnostic.
@@ -0,0 +1,91 @@
1
+ # 客户端资源
2
+
3
+ [English](client-resources.md) | 中文
4
+
5
+ 客户端资源模型把一个地址变成任何 Web Client 组件都能读的活数据。[`dsh-client-resources`](../../packages/client/resources/README.zh.md) 提供 `ctx.resources` 服务与 `useResource` 全局标准 hook;拥有某类内容的包为它的**协议**注册一个**提供方**,组件按**地址**读取该内容的当前状态,而无需引用拥有者的运行时。右侧 Sidebar 的 tab 是这个模型的第一个消费方([右侧 Sidebar](sidebar-right.zh.md));决策记录见 [客户端资源模型 Agent Note](../../.agents/notes/implemented/architecture/2026-09-05-client-resource-model.zh.md)。
6
+
7
+ 本页是面向开发者的参考:地址怎么写、提供方怎么注册、资源怎么读、状态与失败各是什么意思、模型怎样持有与释放一份资源。
8
+
9
+ ## 地址
10
+
11
+ 资源地址是 `dsh-resource://<type>/…` 形式的 URL。host 命名协议,必须是 `ResourceProtocolMap` 的键;路径归协议自己,由其拥有者逐段做百分号编码。需要作用域的协议把作用域放进路径:`file` 协议的地址形如 `dsh-resource://file/session/<sessionId>/<path>`,其中 path 可以相对工作区根,也可以是保留前导斜杠的绝对路径,用 [`dsh-util-workspace-path`](../../packages/util/workspace-path/README.zh.md) 的 `fileAddressFor(sessionId, cwd, path)` 构造、`parseFileAddress(address)` 读回。模型本身只读 scheme 与 host:`protocolOf(address)` 对 `dsh-resource://` URL 返回小写 host,对其它任何字串返回 `undefined`。其它 scheme 下的地址——Sidebar 的 `sidebar://guide`——不指向资源,读作 `none`。
12
+
13
+ | 地址 | 协议键 | 读作 |
14
+ |---|---|---|
15
+ | `dsh-resource://file/session/s1/notes/a.md` | `file` | 会话 `s1` 工作区根下 `notes/a.md` 的元数据(`file` 提供方已注册时) |
16
+ | `dsh-resource://file/absolute/home/me/notes.md` | `file` | 可解析,但没有授权 Session,以 `workspace-file/unknown-workspace` 失败;不借用当前或 Tab Session |
17
+ | `DSH-RESOURCE://File/session/s1/a` | `file` | 另一份记录:地址按字符串比较,`openResource` 只接受 `fileAddressFor` 生成的规范小写拼写 |
18
+ | `sidebar://guide` | — | `none`:导航地址 |
19
+ | `/home/me/notes.md` | — | `none`:不是 URL |
20
+
21
+ ## 注册提供方
22
+
23
+ 协议拥有者在 `ResourceProtocolMap` 上声明其值类型,并在自己的 `ctx.effect` 里注册一个提供方,使协议与插件同寿([提供协议](../../packages/client/resources/README.zh.md#provide-a-protocol))。`open(address, { signal })` 返回一条 `RemoteResult` 帧流——首帧是当前状态,之后每次变化一帧——并且必须在 `signal` 中止时停下。失败是携带 `RemoteFailure` 的 `ok: false` 帧;流里抛出是编程错误,不会被捕获。
24
+
25
+ ```ts ignore-check
26
+ import type { Context } from '@deepseek-ai/cordis'
27
+ import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
28
+ import type {} from '@deepseek-ai/dsh-client-resources/client'
29
+
30
+ interface NoteView { readonly title: string; readonly updatedAt: string }
31
+
32
+ declare module '@deepseek-ai/dsh-client-ui-slots' {
33
+ interface ResourceProtocolMap { note: NoteView }
34
+ }
35
+
36
+ export const inject = ['resources', 'remote']
37
+
38
+ export function apply(ctx: Context): void {
39
+ ctx.effect(() => ctx.resources.register<'note'>({
40
+ protocol: 'note',
41
+ async *open(address, { signal }): AsyncIterable<RemoteResult<NoteView>> {
42
+ const id = new URL(address).pathname.slice(1)
43
+ yield await ctx.remote.notes.read(id, signal)
44
+ for await (const change of ctx.remote.notes.follow(id, signal)) yield change
45
+ },
46
+ }), 'my-notes: note resource provider')
47
+ }
48
+ ```
49
+
50
+ 一个协议恰有一个提供方;第二次注册抛错。注册时若该协议的地址已被持有,则立刻打开它们的流;提供方 dispose 时结束这些流,地址读作 `none` 直到提供方回来。
51
+
52
+ ## 读取资源
53
+
54
+ 每个 slot 组件不论作用域都在 props 上收到 `useResource`([Slots](slots.zh.md))。`useResource<P>(address)` 以类型参数命名协议,返回该地址的当前快照;订阅就是持有资源的方式,另一个持有者让资源存活时,新挂载的组件立刻读到最新值而不重开流([读取资源](../../packages/client/resources/README.zh.md#read-a-resource))。
55
+
56
+ | `status` | 含义 | `value` | `failure` |
57
+ |---|---|---|---|
58
+ | `none` | 地址的协议没有注册提供方,或地址不是资源地址 | `undefined` | `undefined` |
59
+ | `loading` | 提供方的流已打开、尚未产出 | `undefined` | `undefined` |
60
+ | `live` | 最新一帧成功 | 最新的 `ok` 值 | `undefined` |
61
+ | `failed` | 最新一帧报告了失败 | 保留的上一个 `ok` 值 | 该帧的 `RemoteFailure` |
62
+
63
+ ```tsx ignore-check
64
+ import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
65
+ import type {} from '@deepseek-ai/dsh-api-workspace-files/client'
66
+
67
+ type Props = PropsRuntime<'sidebar.right.pane.tab'>
68
+
69
+ export function FileHeader({ useTabInfo, useResource, t }: Props) {
70
+ const { tab } = useTabInfo()
71
+ const meta = useResource<'file'>(tab.contentId)
72
+ if (meta.status === 'failed') return <p role="alert">{t('failed', { code: meta.failure.code })}</p>
73
+ return (
74
+ <header>
75
+ {tab.title}
76
+ </header>
77
+ )
78
+ }
79
+ ```
80
+
81
+ `failed` 由消费方自己呈现:模型把最后一个值留在失败旁,正文可以带提示显示旧内容而不是一片空白,下一个 `ok` 帧会清除失败。模型本身不产生任何用户可见文案。
82
+
83
+ ## 持有与释放
84
+
85
+ 资源有持有者就存活:一个订阅中的 `useResource`,或一次钉住。`ctx.resources.pin(address, signal)` 在不订阅的情况下让资源保持打开直到 `signal` 中止,已中止的信号什么也不钉;右侧 Sidebar 在每条打开的 tab 记录存续期内钉住其地址,因此切 tab 卸载正文不关流。第一个持有者打开提供方的流;最后一个释放时中止它、丢弃值,并把快照回到 `loading`(有提供方)或 `none`(没有)。提供方在这次释放之后产出的帧被丢弃,迭代器被归还。`ctx.resources.source(address)` 是 hook 背后的裸 observable,按地址引用稳定,供 React 之外的调用方使用;只读它的快照不算持有([生命周期](../../packages/client/resources/README.zh.md#lifecycle))。
86
+
87
+ 流只推元数据不推内容。`file` 提供方的值是 `WorkspaceFileStat { absolutePath, version, bytes? }`:首帧来自 Host 的 `stat`,后续观察更新版本。消费方自己经 Workspace Files Remote 命名空间读取内容;Preview 按 tab 独立刷新([`dsh-api-workspace-files`](../../packages/api/workspace-files/README.zh.md))。
88
+
89
+ ## 限制
90
+
91
+ 记录在页面存续期内保留:地址的记录在最后一个持有者离开后仍留着,不持有流也不持有值,因此内存随读过的不同地址数增长。忽略 `signal` 的提供方会一直跑到它的下一帧。失败类型是 Remote 面的 `RemoteFailure`,来源不是 Remote 调用的提供方得自己铸一个。拼错的协议或畸形的地址读作 `none`,没有别的诊断。
@@ -0,0 +1,148 @@
1
+ # Right Sidebar
2
+
3
+ English | [中文](sidebar-right.zh.md)
4
+
5
+ The right Sidebar is the Web Client's per-Session docking surface: a column of panes and tabs beside the conversation in which addressed content — a workspace file, a directory tree, the product's own pages — opens, splits, floats, and closes. [`dsh-client-ui-sidebar-right`](../../packages/client/ui-sidebar-right/README.md) owns the surface, the tab-type registry, and the navigation service; [`dsh-client-ui-dockkit`](../../packages/client/ui-dockkit/README.md) is its internal layout engine; [`dsh-client-resources`](../../packages/client/resources/README.md) turns addresses into live values for any component; [`dsh-api-workspace-files`](../../packages/api/workspace-files/README.md) provides both the Host workspace service and the Client `file` resource provider.
6
+
7
+ This page is the reference for the subsystem's contracts: addresses, tab-type registration, the navigation service, the extension slots and their owner props, the resource model, the Workspace Files service, the shipped types, and what is deliberately not built. How the layout engine, the frame, and the surface fit together is in the [Agent Note](../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md); slot mechanics are in the [Slots reference](slots.md).
8
+
9
+ ## Position and ownership
10
+
11
+ One docking surface exists per Session, held in a session-scoped slot store and drawn by `rightbar.session`. The root-scoped `rightbar` controller mounts that seat only while Conversation is selected; a reload returns every session to the collapsed default, and switching sessions keeps each surface where it was ([state](../../packages/client/ui-sidebar-right/README.md#state)). The surface's every change is one recorded history entry computed by the kit's pure planners; a docked pane never stays empty, and an empty root pane receives the default page selected from registered guide entries.
12
+
13
+ A tab type is two registrations that share the definition's `id`: a static definition in `ctx.sidebarRightTabs` saying which addresses its `kind` opens, and a keyed slot registration supplying its body. The framework injects `useTabInfo()` for live Sidebar, pane and tab information; each type keeps its own state in its slot store. Packages import each other's declarations only as types.
14
+
15
+ | Package | Role |
16
+ |---|---|
17
+ | [`client/ui-sidebar-right`](../../packages/client/ui-sidebar-right/README.md) | The panel and rail seats, the layout store, `ctx.sidebarRightTabs`, `ctx.sidebarRight`, the Tab domain, the guide type |
18
+ | [`client/ui-dockkit`](../../packages/client/ui-dockkit/README.md) | Pure layout engine and React surface; an internal dependency of `ui-sidebar-right`, not a stable interface |
19
+ | [`client/resources`](../../packages/client/resources/README.md) | `ctx.resources`, `useResource`, the protocol → value roster `ResourceProtocolMap` |
20
+ | [`api/workspace-files`](../../packages/api/workspace-files/README.md) | Host `ctx.workspaceFiles`, the `workspaceFiles` Remote namespace, and the Client `file` resource provider |
21
+ | [`util/workspace-path`](../../packages/util/workspace-path/README.md) | The file address grammar: `fileAddressFor`, `parseFileAddress` |
22
+ | [`client/ui-sidebar-documentpreview`](../../packages/client/ui-sidebar-documentpreview/README.md), [`client/ui-sidebar-files`](../../packages/client/ui-sidebar-files/README.md) | The shipped `text` and `files` types |
23
+
24
+ ## Addresses
25
+
26
+ Every tab is opened by an address string, and the address is the tab's content identity. Two families exist.
27
+
28
+ A **resource address** is a `dsh-resource://<type>/…` URL. The host names the resource protocol — the key of `ResourceProtocolMap` — and everything after it is the protocol's own path; one scheme serves every protocol, so adding a protocol adds a host, never a scheme. The `file` protocol's path opens with its scope: `session/<sessionId>` followed by the path relative to that session's workspace root (`dsh-resource://file/session/abc/src/notes.txt`), or `absolute` followed by the absolute path with its leading `/` dropped (`dsh-resource://file/absolute/home/ys/notes.txt`, `dsh-resource://file/absolute/C:/x/y.txt` on Windows). Every id and path segment is component-encoded, with `:` kept literal for drive letters. `fileAddressFor(sessionId, cwd, path)` builds one — a relative path or an absolute path inside the workspace becomes `session`-relative, any other absolute path becomes `absolute` — and `parseFileAddress(address)` reads it back or returns `undefined` ([grammar](../../packages/util/workspace-path/README.md)).
29
+
30
+ A **page address** is what the Sidebar records for a tab opened by kind rather than by resource: `sidebar://<kind>`, written by the Sidebar itself when `openTab(kind)` runs. Callers never build one — the guide and the file tree are opened as `openTab('guide')` and `openTab('files')` — and no other navigation address exists ([not built](#not-built)).
31
+
32
+ Tab identity is the pair `(kind, address)`: the registry's claim uses the address verbatim as the record's `contentId`, so opening the same address through the same type finds the existing tab, and the same address through two types is two tabs.
33
+
34
+ ## Tab-type registration
35
+
36
+ `ctx.sidebarRightTabs.register(definition)` registers one implementation of a type for the caller's lifetime and returns the disposer; the caller holds it inside its own `ctx.effect`, so an implementation lives exactly as long as the plugin that contributed it, and a second registration of the same `id` throws ([extension seats](../../packages/client/ui-sidebar-right/README.md#extension-seats)). The definition is static: no runtime hook, nothing per tab or per session.
37
+
38
+ | Field | Meaning |
39
+ |---|---|
40
+ | `id` | The implementation's identity, unique across every registration; a package name is the natural value (`@deepseek-ai/dsh-client-ui-sidebar-files`). It is the key the body and title register under. |
41
+ | `kind` | The type's discriminator: what its tabs are, and what `openTab` names. Not unique — an extension may take over a builtin's kind. The shipped kinds are `guide`, `text`, `files`. |
42
+ | `patterns` | Optional resource-address globs the type recognizes; a page type opened by kind omits them. A pattern containing `:` matches the whole address (`dsh-resource://file/**`); one without matches the URL's path at any depth (`*.md`), and an address that is not a URL matches no such pattern. Matching is case-insensitive and does not hide dotfiles; the syntax is picomatch's POSIX dialect. |
43
+ | `priority` | One of three literal bands: `extension` (the default and the highest: a type from outside the product outranks every shipped viewer), `builtin` (types shipped with the product), `fallback` (plain-content viewers anything more specific should beat). |
44
+ | `canOpen(address)` | Optional synchronous veto of a glob match; it runs on every routing decision. |
45
+ | `title(address)` | The chip's text, captured into the layout record when the tab opens and never rewritten. |
46
+ | `guide` | Optional entry boxes for the guide page: `{ order, title(), description?(), icon? }`. Picking a box opens the contributing type as a page; omit to stay off the page. |
47
+
48
+ Routing is a ranked claim. `candidates(address)` ranks the types whose patterns match and whose `canOpen` does not veto: by band, then by the length of the longest matched pattern, then by registration order. `claim(address, kind?)` picks the first candidate, or the named `kind` outright — its globs are skipped, its `canOpen` still applies — and returns `{ kind, contentId: address, title }`. An address no type claims throws: it is a wiring mistake, not a user error.
49
+
50
+ One `kind` may carry one `builtin` and one `extension` registration at the same time. The extension is the one in force for claims, `get(kind)`, `openTab(kind)`, and the guide page, and the seat finds a tab's body and title under the definition in force's `id`, so no slot priority is involved; when the extension unregisters, the builtin resumes. Every other collision on a kind, and every duplicate `id`, throws.
51
+
52
+ ```ts ignore-check
53
+ import type { Context } from '@deepseek-ai/cordis'
54
+ import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
55
+
56
+ export const inject = ['sidebarRightTabs', 'slots']
57
+
58
+ export function apply(ctx: Context): void {
59
+ ctx.effect(() => ctx.sidebarRightTabs.register({
60
+ id: '@acme/dsh-client-ui-image',
61
+ kind: 'image',
62
+ patterns: ['*.png', '*.jpg', '*.gif', '*.svg'],
63
+ canOpen: address => address.startsWith('dsh-resource://file/'),
64
+ title: address => address.slice(address.lastIndexOf('/') + 1),
65
+ }), 'image type')
66
+ ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register(
67
+ { name: 'sidebar.right.pane.tab', key: '@acme/dsh-client-ui-image' },
68
+ ImageBody,
69
+ )), 'image body')
70
+ }
71
+ ```
72
+
73
+ ## Navigation: `ctx.sidebarRight`
74
+
75
+ Two opens are the navigation controller, and every way into the column calls one of them: `openResource(address, options?)` for a `dsh-resource://` address — the conversation's file links, a tool row's line reference, a file tree's rows — and `openTab(kind, options?)` for a page — the strip's add control, a guide entry box. Both run four steps as one history entry — claim (the registry ranks the resource's types, or the named `kind`'s implementation in force answers); focus a tab already showing the same `(kind, address)`; otherwise seat a new tab; expand the column — and then record the navigation in the Tab domain ([service](../../packages/client/ui-sidebar-right/README.md#ctxsidebarright)). Content the user cannot see is not opened, so a collapsed column expands in the same step. `openResource` throws for an address outside `dsh-resource://` or one no type claims; `openTab` throws for a kind nothing registered: both are wiring mistakes, not user errors.
76
+
77
+ | Option | Meaning |
78
+ |---|---|
79
+ | `paneId` | Land a new tab in this pane; default is the active docked pane (the first docked pane while a floating pane is active). |
80
+ | `replaceTab` | Take this tab's pane and strip slot, closing it in the same step; a floating tab lends no place, so the new tab lands as if unplaced. |
81
+ | `revealIfOpened` | Default `true`: a tab already showing the same `(kind, address)` is focused and handed `params`. `false` opens another tab regardless. |
82
+ | `kind` (`openResource` only) | Name the opening type instead of ranking claims; its implementation in force opens the address, and its `canOpen` still applies. |
83
+ | `params` | Navigation parameters for the body, delivered as `navigation.params`. `openResource` types them by resource type through the merge-extensible `SidebarRightResourceParamsMap` (the text preview declares `{ line?: number }`); `openTab<K>` types them by kind through `SidebarRightTabParamsMap`, `undefined` for a kind that declares none; a body reads `SidebarRightNavigationParams`, the union of both. Values are JSON-shaped by convention and not validated at run time. |
84
+
85
+ Placement is the caller's option, never a type's property. The conversation calls `openResource(fileAddressFor(sessionId, cwd, path))` and, from a `read` tool row, adds `{ params: { line } }` from the call's 1-based `offset`; a guide entry box calls `tab.actions.openTab(entry.kind, { replaceTab: true })`; a file-tree row calls `tab.actions.openResource(address)`; the strip's add control calls `openTab('guide', { paneId, revealIfOpened: false })`.
86
+
87
+ `close(tabId)` closes a tab; `active()` returns the active pane's active tab; `isExpanded()` and `toggleExpanded()` read and flip the column, the flip recorded in the sequence. Reads answer for the no-Session case with `undefined` or `false`; writes need a mounted Session surface and throw without one rather than write into a surface nobody draws.
88
+
89
+ `focus(tabId)` makes a tab its pane's active tab; `split(paneId?)` splits the active docked pane, or the named one, and returns the new pane's id — or `undefined`, recording nothing, when the pane budget or the column's width forbids a split; `float(tabId, rect?)` lifts a tab into a floating pane; `dock(paneId)` returns a floating pane to the docked area. All four run the store's existing actions and record one history entry each; a target that does not exist or is already in the requested state is a no-op, and like `open` they throw without a mounted Session surface. `TabId`, `PaneId`, `TabRecord`, and `FloatRect` are re-exported from the package's `/client` entry so a caller needs no dockkit import.
90
+
91
+ ## Slots and owner props
92
+
93
+ The Sidebar declares four extension slots; its document tab declares the additional keyed document body below ([hierarchy](slots.md)).
94
+
95
+ | Slot | Cardinality | Purpose |
96
+ |---|---|---|
97
+ | `sidebar.right.pane.tab` | keyed by the definition's `id`, Session scope | One tab's body. The seat dispatches a tab to the `id` of its kind's implementation in force, so the registrant receives every tab of its kind, docked or floating. A kind whose implementation registered no body renders the owner's "nothing can view this" notice. |
98
+ | `sidebar.right.pane.tab.title` | keyed by the definition's `id`, Session scope | The chip's title, with the same owner share as the body. Optional: without an entry the chip shows the `title(address)` text captured at open time; a type with a live title reads its own store here. |
99
+ | `sidebar.right.tab.guide` | chain, Session scope | Replaces the guide tab's contents without replacing the tab; the first non-declining entry takes the body, otherwise the shipped guide renders. |
100
+ | `sidebar.right.tab.menu.item` | list, Session scope | Content-level actions appended after the kit's own layout actions. An item that acts must call the owner's `dismiss()`. |
101
+ | `sidebar.right.tab.document` | keyed by the document implementation's `id`, Session scope | The selected file renderer inside the document tab; the parent owns shared loading and toolbar controls. |
102
+
103
+ A body, title and guide replacement receive the framework-injected `useTabInfo()`. It returns `{ sidebar, panel, tab }`: `sidebar` holds `expanded` and `fullscreen`, `panel.id` names the containing pane, and `tab` contains its record fields plus `visible`, `navigation`, `signal`, and `actions`. Docked bodies are visible only while expanded and active; docked titles need only expansion; floats stay visible. `signal` aborts when the record disappears or the plugin unloads, not on hiding or Session switching. `tab.actions` provides `openResource`, `openTab`, and `close`, bound to the tab's own Session. Open placement defaults to its current pane; `revealIfOpened` defaults to `true`, and `replaceTab: true` replaces this record in the same history entry. Menu entries retain plain `tab` and `dismiss` owner parameters.
104
+
105
+ `navigation.revision` increments on every navigation to the tab whether or not `params` changed, so a body can act on "navigated again" alone; it is `1` for a tab opened by address and `0` for a record nobody opened by address — a seeded guide, or a tab restored by undo. The Tab domain holds one occurrence per open record: a record that appears is pinned in the resource model, so switching tabs unmounts a body without dropping its content; a record that vanishes is aborted and dropped; a record restored by undo is a new occurrence ([Tab domain](../../packages/client/ui-sidebar-right/README.md#the-tab-domain)).
106
+
107
+ ## Document renderers
108
+
109
+ The `text` tab is the shared Document Preview owner. Its [root registration](../../packages/client/ui-sidebar-documentpreview/src/client/index.ts) declares `sidebar.right.tab.document` and provides `ctx.documentPreviews`. A renderer registers `DocumentPreviewDefinition` metadata in its own effect, then waits through `ctx.slots.inject('sidebar.right.tab.document', ...)` and registers its component with `key: definition.id` and its locale namespace. Changing the renderer does not change the tab or resource address; the [extension decision](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md) separates preview policy from resource ownership.
110
+
111
+ The [registry](../../packages/client/ui-sidebar-documentpreview/src/client/document/registry.ts) records unique `id`, `extensions`, localized `title()`, `loading`, optional `priority`, and optional `wrap`. Case-insensitive suffix matching ranks `extension` (the default) before `builtin`, then longer suffixes before shorter ones, then registration order. Unlike tab-kind replacement, the registry keeps all implementations available; the toolbar lists matching alternatives and remembers the selection per tab. Unknown extensions use plain text. `loading` is `text-pages` or `bytes-complete`; `wrap` advertises support for the shared source-wrap control.
112
+
113
+ [`DocumentPreviewProps`](../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts) derives from `PropsRuntime<'sidebar.right.tab.document'>`. The owner supplies the original `resourceAddress`, `content`, and current `wrap`: text content is `{ kind: 'text', text, pages: [{ offset, text, lines }], eof }`, with cumulative `text`; complete bytes are `{ kind: 'bytes', data }`, with `Uint8Array<ArrayBuffer>` data. These transient buffers are borrowed read-only and must not enter durable layout or Session JSON. PDF copies the bytes before Worker transfer, preserving the owner's buffer. The child receives the same framework-bound `useTabInfo` and the global metadata-only `useResource`. The parent reads through ordinary inject callbacks to `remote.workspaceFiles.read`/`readAll` and owns page appends, per-tab refresh, and loading status. HTML's own inject callback uses `readRelated`; Host code resolves paths. Markdown and code retain one incremental renderer across appends and settle at EOF; HTML and PDF receive complete bytes.
114
+
115
+ Preview records its loaded version and the version observed when a read starts. Refresh rereads only that tab, without changing shared metadata or another tab's content. Reads are non-transactional; versions are opaque equality tokens, not ordered timestamps ([resource observation and Preview RPC](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md)).
116
+
117
+ ## Resource model
118
+
119
+ The model is documented in [Client Resources](client-resources.md); this section states what the Sidebar relies on. A resource is one address, and a resource address is a `dsh-resource://<type>/…` URL whose lower-cased host is the protocol key. The protocol's owning client package registers one provider with `ctx.resources.register(provider)` for its own lifetime; a second provider for the same protocol throws ([provide a protocol](../../packages/client/resources/README.md#provide-a-protocol)). A provider is `{ protocol, open(address, { signal }) }`: `open` yields `RemoteResult` frames — the current state first, one frame per later change — and stops when `signal` aborts; a failure is an `{ ok: false, error }` frame, never a throw, and a throw inside the stream is a programming error the model does not catch.
120
+
121
+ `useResource<P>(address)` is a global standard prop on every slot component, whatever its scope. It returns `{ status, value, failure }`: `none` when the address's protocol has no provider or the address is not a resource address (`sidebar://guide` names no resource), `loading` until the first frame, `live` with the latest `ok` value, `failed` with the latest frame's failure beside the last value. ([read a resource](../../packages/client/resources/README.md#read-a-resource)).
122
+
123
+ A resource stays open while it has a holder — a subscribed `useResource` or a `ctx.resources.pin(address, signal)`; the first holder opens the provider's stream, later holders share it and read the latest value at once, and the last release aborts the stream and discards the value. Streams carry metadata, not content: the `file` value is `{ absolutePath, version, bytes? }`, and a consumer reads file text itself, by page, through the Workspace Files service ([lifecycle](../../packages/client/resources/README.md#lifecycle)).
124
+
125
+ ## Workspace Files
126
+
127
+ The Host `ctx.workspaceFiles` service and generated `workspaceFiles` Remote namespace read files allowed by the Session filesystem backend: `stat(path)` returns `{ absolutePath, version, bytes? }`; `read(path, { offset?, limit? })` returns one page of lines (`offset` 1-based, `limit` capped by the configured page size) as `{ …stat, offset, text, eof }`; `readBytes(path, { offset?, length? })` returns one raw byte window (`offset` 0-based, `length` capped by the configured byte limit) as base64 `{ …stat, offset, data, eof }` with no text decoding. `list(path)` remains inside the workspace root and returns a directory's direct children (`name`, `type: 'file' | 'directory' | 'other'`, `size?`) cut to the configured cap with `truncated` set. `changes()` likewise remains workspace-scoped and yields `{ kind: 'ready' }` once subscribed, then `{ kind: 'change', change }` frames whose payload is `{ absolutePath, version }` or `{ absolutePath, absent: true }` ([README](../../packages/api/workspace-files/README.md#use-this-package)). File operations reject final symlinks and enforce transfer caps; `read` additionally requires UTF-8 text. Failures use `workspace-file/*` codes ([failures](../../packages/api/workspace-files/README.md)).
128
+
129
+ [`dsh-api-workspace-files`](../../packages/api/workspace-files/README.md) registers the `file` provider, with `ResourceProtocolMap.file` directly naming `WorkspaceFileStat`. A Session address carries the authorizing Session and a relative or absolute path, passed unchanged to the Host for resolution. The provider waits for Host `ready` before stat and filters changes by `stat.absolutePath`. Bare `absolute` addresses have no authorizing Session and fail with `workspace-file/unknown-workspace`, without borrowing current or Tab Session. Any UI, including Global components, shares the observation for the same complete address. Preview's ordinary Remote callbacks use the Session in that address; Host `readAll` and `readRelated` remain, and Preview's `rpc.ts` decodes byte results.
130
+
131
+ ## Shipped types
132
+
133
+ - **`guide`** — `builtin`, opened as `openTab('guide')`. A muted compass sits above one capsule per contributed `guide` entry, in `order`; short lists show registered descriptions, and every missing icon uses the shipped placeholder. Picking a capsule opens the contributing type as a page in the guide tab's place. A pane holds at most one guide tab, and the strip's add control appears only while its pane has none. A new pane receives the registered default page: the sole guide entry directly, or the guide when the entry count is not one ([guide](../../packages/client/ui-sidebar-right/README.md#the-guide)).
134
+ - **`text`** — `fallback`, `dsh-resource://file/**`, claiming Session addresses only. Document Preview observes metadata through `useResource<'file'>`, loads content through Remote callbacks, and owns renderer selection, the toolbar, per-tab refresh, scroll, and source navigation; unknown extensions render as plain text ([README](../../packages/client/ui-sidebar-documentpreview/README.md)).
135
+ - **`files`** — `builtin`, opened as `openTab('files')`. The workspace directory tree, listed lazily through `list`, opening a file with `tab.actions.openResource(fileAddressFor(sessionId, root, path))` into its own pane ([README](../../packages/client/ui-sidebar-files/README.md)).
136
+
137
+ <a id="not-built"></a>
138
+ ## Not built
139
+
140
+ - Persistence: layout state is memory-only; a reload starts every session collapsed, and no session's tabs are visible from another.
141
+ - A read-only layout snapshot or subscription on `ctx.sidebarRight`: the service exposes operations only, and dockkit's `LayoutState`/`LayoutOp` are internal.
142
+ - A capability-discovery array (`features`) on the service.
143
+ - An `option` priority band for tab types: nothing lists a tab type without letting it claim.
144
+ - Retitling a record: `title(address)` is captured once; a live chip comes from the title slot, not from the record.
145
+ - Naming a tab implementation when opening: `openResource` names a kind at most; document-renderer selection belongs to the file tab's toolbar.
146
+ - An address lookup on the service (`find`): a caller opens with `revealIfOpened` and lets the surface de-duplicate.
147
+ - Navigation addresses beyond the Sidebar's own `sidebar://<kind>` bookkeeping; their grammar waits for the navigation controller as a whole.
148
+ - A user-facing undo, a content navigation stack, and tab icons ([deferred](../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md#deferred)).
@@ -0,0 +1,148 @@
1
+ # 右侧 Sidebar
2
+
3
+ [English](sidebar-right.md) | 中文
4
+
5
+ 右侧 Sidebar 是 Web Client 里每个会话一份的停靠面:会话区旁的一列 pane 与 tab,按地址寻址的内容——工作区文件、目录树、产品自带页面——在这里打开、分栏、浮出、关闭。[`dsh-client-ui-sidebar-right`](../../packages/client/ui-sidebar-right/README.zh.md) 拥有这个面、tab 类型注册表与导航服务;[`dsh-client-ui-dockkit`](../../packages/client/ui-dockkit/README.zh.md) 是它内部的布局引擎;[`dsh-client-resources`](../../packages/client/resources/README.zh.md) 把地址变成任何组件都能读的活数据;[`dsh-api-workspace-files`](../../packages/api/workspace-files/README.zh.md) 同时提供 Host 工作区文件服务与 Client `file` 资源提供者。
6
+
7
+ 本页是该子系统契约的参考:地址、tab 类型注册、导航服务、扩展 slot 与其 owner props、资源模型、Workspace Files 服务、内置类型,以及明确不做的事。布局引擎、frame 与停靠面如何拼在一起见 [Agent Note](../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md);slot 机制见 [Slots 参考](slots.zh.md)。
8
+
9
+ ## 定位与归属
10
+
11
+ 每个会话恰有一个停靠面,保存在会话作用域的 slot store 里、由 `rightbar.session` 绘制。root 作用域的 `rightbar` 控制器仅在选中 Conversation 时挂载该席位;刷新页面后每个会话回到折叠的默认态,切换会话时各自的面保持原状([状态](../../packages/client/ui-sidebar-right/README.zh.md#state))。面的每一次变化都是 kit 纯规划器算出的一条历史记录;停靠的 pane 从不空着,根 pane 为空时会加入根据已注册引导入口选出的默认页。
12
+
13
+ 一个 tab 类型是共用定义 `id` 的两次注册:在 `ctx.sidebarRightTabs` 里的静态定义说明其 `kind` 打开哪些地址,一次 keyed slot 注册提供它的正文。框架注入 `useTabInfo()` 以读取 Sidebar、窗格和标签的实时信息;各类型把自身状态放在 slot store 里。各包之间只以类型形式引用彼此的声明。
14
+
15
+ | 包 | 职责 |
16
+ |---|---|
17
+ | [`client/ui-sidebar-right`](../../packages/client/ui-sidebar-right/README.zh.md) | 面板与栏席位、布局 store、`ctx.sidebarRightTabs`、`ctx.sidebarRight`、Tab 域、引导类型 |
18
+ | [`client/ui-dockkit`](../../packages/client/ui-dockkit/README.zh.md) | 纯布局引擎与 React 面;`ui-sidebar-right` 的内部依赖,不是稳定接口 |
19
+ | [`client/resources`](../../packages/client/resources/README.zh.md) | `ctx.resources`、`useResource`、协议 → 值类型的花名册 `ResourceProtocolMap` |
20
+ | [`api/workspace-files`](../../packages/api/workspace-files/README.zh.md) | Host `ctx.workspaceFiles`、`workspaceFiles` Remote 命名空间与 Client `file` 资源提供者 |
21
+ | [`util/workspace-path`](../../packages/util/workspace-path/README.zh.md) | 文件地址语法:`fileAddressFor`、`parseFileAddress` |
22
+ | [`client/ui-sidebar-documentpreview`](../../packages/client/ui-sidebar-documentpreview/README.zh.md)、[`client/ui-sidebar-files`](../../packages/client/ui-sidebar-files/README.zh.md) | 内置的 `text` 与 `files` 类型 |
23
+
24
+ ## 地址
25
+
26
+ 每个 tab 都由一个地址字串打开,地址就是 tab 的内容身份。地址分两族。
27
+
28
+ **资源地址**是 `dsh-resource://<type>/…` 形式的 URL。host 命名资源协议——即 `ResourceProtocolMap` 的键——其后是该协议自己的路径;所有协议共用一个 scheme,新增协议只新增 host、不新增 scheme。`file` 协议的路径以其作用域开头:`session/<sessionId>` 后接相对该会话工作区根的路径(`dsh-resource://file/session/abc/src/notes.txt`),或 `absolute` 后接去掉前导 `/` 的绝对路径(`dsh-resource://file/absolute/home/ys/notes.txt`,Windows 上为 `dsh-resource://file/absolute/C:/x/y.txt`)。id 与每一段路径都做组件编码,盘符的 `:` 保留原样。`fileAddressFor(sessionId, cwd, path)` 构造地址——相对路径或工作区内的绝对路径成为 `session` 相对地址,其他绝对路径成为 `absolute` 地址——`parseFileAddress(address)` 读回各部分或返回 `undefined`([语法](../../packages/util/workspace-path/README.zh.md))。
29
+
30
+ **页面地址**是 Sidebar 为按 kind(而非按资源)打开的 tab 记下的地址:`sidebar://<kind>`,由 Sidebar 自己在 `openTab(kind)` 运行时写入。调用方从不拼它——引导页与文件树以 `openTab('guide')`、`openTab('files')` 打开——此外不存在任何导航地址([不做](#not-built))。
31
+
32
+ tab 身份是 `(kind, address)` 二元组:注册表的认领把地址原文用作记录的 `contentId`,因此同一地址经同一类型再次打开会找到已有 tab,同一地址经两个类型打开则是两个 tab。
33
+
34
+ ## Tab 类型注册
35
+
36
+ `ctx.sidebarRightTabs.register(definition)` 在调用方的生命周期内注册一个类型的一份实现并返回注销器;调用方把它放在自己的 `ctx.effect` 里,因此实现与贡献它的插件同寿,同一 `id` 的第二次注册抛错([扩展席位](../../packages/client/ui-sidebar-right/README.zh.md#extension-seats))。定义是静态的:没有运行时 hook,没有按 tab 或按会话的东西。
37
+
38
+ | 字段 | 含义 |
39
+ |---|---|
40
+ | `id` | 该实现的身份,在所有注册中唯一;包名是自然取值(`@deepseek-ai/dsh-client-ui-sidebar-files`)。正文与标题坑位按它注册。 |
41
+ | `kind` | 类型的判别名:它的 tab 是什么,也是 `openTab` 点名的对象。不唯一——extension 可以接管 builtin 的 kind。内置 kind 为 `guide`、`text`、`files`。 |
42
+ | `patterns` | 可选的资源地址 glob;按 kind 打开的页面类型省略。含 `:` 的模式匹配整个地址(`dsh-resource://file/**`);不含的匹配 URL 的路径部分且任意深度都中(`*.md`),不是 URL 的地址不会命中此类模式。匹配不分大小写、不隐藏 dotfile;语法为 picomatch 的 POSIX 方言。 |
43
+ | `priority` | 三档字面量之一:`extension`(缺省且最高:产品之外的类型压过所有内置查看器)、`builtin`(随产品发布的类型)、`fallback`(任何更具体的类型都应压过的纯内容查看器)。 |
44
+ | `canOpen(address)` | 可选的同步否决,对 glob 命中生效;每次路由决策都会调用。 |
45
+ | `title(address)` | chip 文本,在 tab 打开时捕获进布局记录,之后不再改写。 |
46
+ | `guide` | 可选的引导页入口框:`{ order, title(), description?(), icon? }`。点一框即把贡献它的类型作为页面打开;省略即不上引导页。 |
47
+
48
+ 路由是一次排序认领。`candidates(address)` 对模式命中且未被 `canOpen` 否决的类型排序:先按档,再按最长命中模式的长度,最后按注册顺序。`claim(address, kind?)` 取第一个候选,或直接用点名的 `kind`——跳过它的 glob,但 `canOpen` 仍生效——返回 `{ kind, contentId: address, title }`。没有任何类型认领的地址会抛错:这是接线错误,不是用户错误。
49
+
50
+ 同一个 `kind` 可同时携带一个 `builtin` 与一个 `extension` 注册。extension 在认领、`get(kind)`、`openTab(kind)` 与引导页上生效,席位按生效定义的 `id` 找 tab 的正文与标题,不涉及任何 slot 优先级;extension 注销后 builtin 恢复。kind 上的其它任何撞名以及任何重复的 `id` 都抛错。
51
+
52
+ ```ts ignore-check
53
+ import type { Context } from '@deepseek-ai/cordis'
54
+ import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
55
+
56
+ export const inject = ['sidebarRightTabs', 'slots']
57
+
58
+ export function apply(ctx: Context): void {
59
+ ctx.effect(() => ctx.sidebarRightTabs.register({
60
+ id: '@acme/dsh-client-ui-image',
61
+ kind: 'image',
62
+ patterns: ['*.png', '*.jpg', '*.gif', '*.svg'],
63
+ canOpen: address => address.startsWith('dsh-resource://file/'),
64
+ title: address => address.slice(address.lastIndexOf('/') + 1),
65
+ }), 'image type')
66
+ ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register(
67
+ { name: 'sidebar.right.pane.tab', key: '@acme/dsh-client-ui-image' },
68
+ ImageBody,
69
+ )), 'image body')
70
+ }
71
+ ```
72
+
73
+ ## 导航:`ctx.sidebarRight`
74
+
75
+ 两种打开构成导航控制器,进入这一列的每条路都调用其一:`openResource(address, options?)` 打开 `dsh-resource://` 地址——会话区的文件链接、工具行的行号引用、文件树的行;`openTab(kind, options?)` 打开页面——tab 条的新增控件、引导页入口框。两者都以一条历史记录走完四步——认领(注册表为资源排候选,或点名 `kind` 的生效实现应答);聚焦已显示同一 `(kind, address)` 的 tab;否则落一个新 tab;展开这一列——然后把导航记入 Tab 域([服务](../../packages/client/ui-sidebar-right/README.zh.md#ctxsidebarright))。用户看不见的内容不算打开,所以折叠的列会在同一步展开。`openResource` 对 `dsh-resource://` 之外的地址或无人认领的地址抛错;`openTab` 对无人注册的 kind 抛错:二者都是接线错误,不是用户错误。
76
+
77
+ | 选项 | 含义 |
78
+ |---|---|
79
+ | `paneId` | 新 tab 落到这个 pane;缺省为活动的停靠 pane(活动的是浮窗时取第一个停靠 pane)。 |
80
+ | `replaceTab` | 占用这个 tab 的 pane 与条上位置,并在同一步关闭它;浮窗里的 tab 让不出位置,新 tab 按未指定位置落位。 |
81
+ | `revealIfOpened` | 缺省 `true`:已显示同一 `(kind, address)` 的 tab 被聚焦并收到 `params`。`false` 则无论如何再开一个。 |
82
+ | `kind`(仅 `openResource`) | 点名打开类型而不排候选;该 kind 的生效实现打开地址,它的 `canOpen` 仍生效。 |
83
+ | `params` | 给正文的导航参数,作为 `navigation.params` 送达。`openResource` 按资源类型经声明合并表 `SidebarRightResourceParamsMap` 定型(文本预览声明 `{ line?: number }`);`openTab<K>` 按 kind 经 `SidebarRightTabParamsMap` 定型,未声明的 kind 为 `undefined`;正文读到的是二者联合 `SidebarRightNavigationParams`。值按约定为 JSON 形状,运行时不校验。 |
84
+
85
+ 落位是调用方的选项,从不是类型的属性。会话区调 `openResource(fileAddressFor(sessionId, cwd, path))`,`read` 工具行另加 `{ params: { line } }`(来自调用的 1 起 `offset`);引导页入口框调 `tab.actions.openTab(entry.kind, { replaceTab: true })`;文件树的行调 `tab.actions.openResource(address)`;tab 条的新增控件调 `openTab('guide', { paneId, revealIfOpened: false })`。
86
+
87
+ `close(tabId)` 关闭一个 tab;`active()` 返回活动 pane 的活动 tab;`isExpanded()` 与 `toggleExpanded()` 读取与翻转这一列,翻转记入序列。无会话时读操作返回 `undefined` 或 `false`;写操作需要已挂载的会话面,没有时抛错而不是写进没人绘制的面。
88
+
89
+ `focus(tabId)` 让一个 tab 成为其 pane 的活动 tab;`split(paneId?)` 分割活动的停靠 pane 或点名的 pane,返回新 pane 的 id——pane 数预算或列宽不允许时返回 `undefined` 且不记账;`float(tabId, rect?)` 把一个 tab 浮出为浮窗 pane;`dock(paneId)` 把浮窗 pane 收回停靠区。四者都走 store 既有动作、各记一条历史;目标不存在或已处于目标状态时是空操作,与 `open` 一样在没有已挂载会话面时抛错。`TabId`、`PaneId`、`TabRecord`、`FloatRect` 自本包 `/client` 入口再导出,调用方无需引 dockkit。
90
+
91
+ ## Slot 与 owner props
92
+
93
+ Sidebar 声明四个扩展 slot;其文档 tab 另行声明下表中的 keyed 文档正文 slot([层级](slots.zh.md))。
94
+
95
+ | Slot | Cardinality | 用途 |
96
+ |---|---|---|
97
+ | `sidebar.right.pane.tab` | 按定义的 `id` keyed,会话作用域 | 一个 tab 的正文。席位把 tab 分发到其 kind 生效实现的 `id`,因此注册者收到该 kind 的每个 tab,停靠或浮窗。实现没有注册正文的 kind 渲染 owner 的「无法查看此内容」提示。 |
98
+ | `sidebar.right.pane.tab.title` | 按定义的 `id` keyed,会话作用域 | chip 的标题,owner share 与正文相同。可选:没有条目时 chip 显示打开时捕获的 `title(address)` 文本;有活标题的类型在此读自己的 store。 |
99
+ | `sidebar.right.tab.guide` | chain,会话作用域 | 替换引导 tab 的内容而不替换 tab;第一个不拒绝的条目接管正文,否则渲染自带引导。 |
100
+ | `sidebar.right.tab.menu.item` | list,会话作用域 | 追加在 kit 自身布局动作之后的内容级动作。执行了动作的条目必须调用 owner 的 `dismiss()`。 |
101
+ | `sidebar.right.tab.document` | 按文档实现的 `id` keyed,会话作用域 | 文档 tab 内选中的文件渲染器;父组件拥有共享加载与工具栏控件。 |
102
+
103
+ 正文、标题与引导页替换项接收框架注入的 `useTabInfo()`。它返回 `{ sidebar, panel, tab }`:`sidebar` 包含 `expanded` 与 `fullscreen`,`panel.id` 标识所属窗格,`tab` 包含记录字段以及 `visible`、`navigation`、`signal` 和 `actions`。停靠正文仅在展开且活跃时可见;停靠标题只要求展开;浮窗保持可见。`signal` 在记录消失或插件卸载时中止,不因隐藏或切换 Session 而中止。`tab.actions` 提供绑定到标签所属 Session 的 `openResource`、`openTab` 与 `close`。打开位置缺省为当前所属窗格;`revealIfOpened` 缺省为 `true`,`replaceTab: true` 在同一历史项中替换本记录。菜单项保留普通的 `tab` 与 `dismiss` owner 参数。
104
+
105
+ `navigation.revision` 在每次导航到该 tab 时递增,`params` 不变也递增,正文可仅凭「又被导航了」行动;按地址打开的 tab 为 `1`,没有人按地址打开的记录——种入的引导、撤销恢复的 tab——为 `0`。Tab 域为每条打开的记录保有一个 occurrence:记录出现即在资源模型里钉住,因此切换 tab 卸载正文也不丢内容;记录消失即中止并丢弃;撤销恢复的记录是新的 occurrence([Tab 域](../../packages/client/ui-sidebar-right/README.zh.md#the-tab-domain))。
106
+
107
+ ## 文档渲染器
108
+
109
+ `text` tab 是共享的 Document Preview 所有者。其[根注册](../../packages/client/ui-sidebar-documentpreview/src/client/index.ts)声明 `sidebar.right.tab.document` 并提供 `ctx.documentPreviews`。渲染器在自己的 effect 中注册 `DocumentPreviewDefinition` 元数据,再通过 `ctx.slots.inject('sidebar.right.tab.document', ...)` 等待 slot,以 `key: definition.id` 和自己的 locale 命名空间注册组件。切换渲染器不改变 tab 或资源地址;[扩展决议](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md)将预览策略与资源归属分开。
110
+
111
+ [注册表](../../packages/client/ui-sidebar-documentpreview/src/client/document/registry.ts)记录唯一的 `id`、`extensions`、本地化 `title()`、`loading`,以及可选的 `priority` 和 `wrap`。后缀匹配不区分大小写,先排 `extension`(缺省值)、再排 `builtin`,随后比较后缀长度(长者优先)与注册顺序。与 tab kind 替换不同,注册表保留所有实现;工具栏列出匹配的候选,按 tab 记住选择。未知扩展名使用纯文本。`loading` 为 `text-pages` 或 `bytes-complete`;`wrap` 声明是否支持共享的源码换行控件。
112
+
113
+ [`DocumentPreviewProps`](../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts) 派生自 `PropsRuntime<'sidebar.right.tab.document'>`。owner 提供原始 `resourceAddress`、`content` 与当前 `wrap`:文本内容为 `{ kind: 'text', text, pages: [{ offset, text, lines }], eof }`,其中 `text` 为累积文本;完整字节为 `{ kind: 'bytes', data }`,其中 `data` 为 `Uint8Array<ArrayBuffer>`。这些瞬时缓冲区按只读方式借用,不得进入持久布局或 Session JSON。PDF 在转移到 Worker 前复制字节,以保留 owner 的缓冲区。子组件收到同一个框架绑定的 `useTabInfo`,以及全局共享、仅提供元数据的 `useResource`。父组件通过普通 inject 回调调用 `remote.workspaceFiles.read`/`readAll`,拥有追加分页、逐 tab 刷新与加载状态。HTML 自己的 inject 回调使用 `readRelated`;路径由 Host 代码解析。Markdown 和代码在追加期间保留同一个增量渲染器,到 EOF 完成最终解析;HTML 和 PDF 接收完整字节。
114
+
115
+ Preview 记录已载入版本和读取开始时的观察版本。刷新只重读当前 tab,不改变共享元数据或其他 tab 的内容。读取不具备事务性;版本是不透明的相等性令牌,不是可排序的时间戳([资源观察与 Preview RPC](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md))。
116
+
117
+ ## 资源模型
118
+
119
+ 模型本身见[客户端资源](client-resources.zh.md);本节只写 Sidebar 依赖的部分。一份资源是一个地址,资源地址是 `dsh-resource://<type>/…` 形式的 URL,小写 host 即协议键。协议所属的客户端包用 `ctx.resources.register(provider)` 在自身生命周期内注册唯一的提供方;同一协议的第二个提供方抛错([提供协议](../../packages/client/resources/README.zh.md#provide-a-protocol))。提供方是 `{ protocol, open(address, { signal }) }`:`open` 产出 `RemoteResult` 帧——首帧是当前状态,之后每次变化一帧——并在 `signal` 中止时停下;失败是 `{ ok: false, error }` 帧而不是抛错,流里抛出的东西是编程错误,模型不捕获。
120
+
121
+ `useResource<P>(address)` 是每个 slot 组件都有的全局标准 prop,不论作用域。它返回 `{ status, value, failure }`:地址协议没有提供方或地址不是资源地址(`sidebar://guide` 不指向资源)时为 `none`,首帧之前为 `loading`,`live` 携带最新 `ok` 值,`failed` 在最后一个值旁携带最新帧的失败。([读取资源](../../packages/client/resources/README.zh.md#read-a-resource))。
122
+
123
+ 资源有持有者就保持打开——订阅中的 `useResource` 或一次 `ctx.resources.pin(address, signal)`;第一个持有者打开提供方的流,之后的持有者共享它并立刻读到最新值,最后一个释放时中止流并丢弃值。流只推元数据不推内容:`file` 的值是 `{ absolutePath, version, bytes? }`,消费方自己经 Workspace Files 服务按页读文件文本([生命周期](../../packages/client/resources/README.zh.md#lifecycle))。
124
+
125
+ ## Workspace Files
126
+
127
+ Host 的 `ctx.workspaceFiles` 服务与生成的 `workspaceFiles` Remote 命名空间读取 Session 文件系统后端允许的文件:`stat(path)` 返回 `{ absolutePath, version, bytes? }`;`read(path, { offset?, limit? })` 返回一页行(`offset` 1 起,`limit` 受配置页长限制),形如 `{ …stat, offset, text, eof }`;`readBytes(path, { offset?, length? })` 返回一个原始字节窗口(`offset` 0 起,`length` 受配置字节上限限制),形如 base64 的 `{ …stat, offset, data, eof }`、不做文本解码。`list(path)` 仍限定在工作区根内,返回目录的直接子项(`name`、`type: 'file' | 'directory' | 'other'`、`size?`),按配置上限截断并置 `truncated`。`changes()` 同样限定于工作区,订阅就绪后产出 `{ kind: 'ready' }`,随后产出 `{ kind: 'change', change }` 帧,其载荷为 `{ absolutePath, version }` 或 `{ absolutePath, absent: true }`([README](../../packages/api/workspace-files/README.zh.md#use-this-package))。文件操作拒绝末端符号链接并执行传输上限;`read` 还要求 UTF-8 文本。失败使用 `workspace-file/*` 错误码([失败](../../packages/api/workspace-files/README.zh.md))。
128
+
129
+ [`dsh-api-workspace-files`](../../packages/api/workspace-files/README.zh.md) 注册 `file` 提供方,`ResourceProtocolMap.file` 直接是 `WorkspaceFileStat`。Session 地址携带授权 Session 与相对或绝对路径,Host 原样接收并解析。提供方在 stat 前等待 Host 的 `ready` 帧,并按 `stat.absolutePath` 过滤变更。裸 `absolute` 地址没有授权 Session,以 `workspace-file/unknown-workspace` 失败,不借用当前或 Tab Session。任何 UI(包括 Global)访问同一完整地址都共享观察。Preview 的普通 Remote 回调使用地址中的 Session;Host `readAll` 和 `readRelated` 保留,字节结果由 Preview 的 `rpc.ts` 解码。
130
+
131
+ ## 内置类型
132
+
133
+ - **`guide`**——`builtin`,以 `openTab('guide')` 打开。一枚弱化的罗盘位于各类型按 `order` 贡献的入口胶囊上方;入口较少时显示已注册的描述,未提供图标的入口统一使用内置占位符。点选胶囊即在引导 tab 的位置把贡献它的类型作为页面打开。每个 pane 最多一个引导 tab,tab 条的新增控件只在本 pane 没有引导时出现。新 pane 使用已注册的默认页:只有一个引导入口时直接使用该入口,否则使用引导页([引导](../../packages/client/ui-sidebar-right/README.zh.md#the-guide))。
134
+ - **`text`**——`fallback`,`dsh-resource://file/**`,只认领 Session 地址。Document Preview 通过 `useResource<'file'>` 观察元数据,经 Remote 回调加载内容,并拥有渲染器选择、工具栏、逐 tab 刷新、滚动与源码定位;未知扩展名按纯文本渲染([README](../../packages/client/ui-sidebar-documentpreview/README.zh.md))。
135
+ - **`files`**——`builtin`,以 `openTab('files')` 打开。工作区目录树,经 `list` 懒加载,用 `tab.actions.openResource(fileAddressFor(sessionId, root, path))` 在自己所在 pane 打开文件([README](../../packages/client/ui-sidebar-files/README.zh.md))。
136
+
137
+ <a id="not-built"></a>
138
+ ## 不做
139
+
140
+ - 持久化:布局状态只在内存里;刷新后每个会话从折叠开始,任何会话的 tab 都不会出现在另一个会话里。
141
+ - `ctx.sidebarRight` 上的只读布局快照或订阅:服务只暴露操作,dockkit 的 `LayoutState`/`LayoutOp` 是内部的。
142
+ - 服务上的能力探测数组(`features`)。
143
+ - tab 类型的 `option` 优先级档:没有「只列出、不许认领」的 tab 类型。
144
+ - 改写记录的标题:`title(address)` 只捕获一次;活的 chip 来自标题 slot,而不是记录。
145
+ - 打开时点名某个 tab 实现:`openResource` 最多点名一个 kind;文档渲染器由文件 tab 的工具栏选择。
146
+ - 服务上的地址查找(`find`):调用方用 `revealIfOpened` 打开,由停靠面去重。
147
+ - Sidebar 自身 `sidebar://<kind>` 记账之外的导航地址;其语法等导航控制器整体做时再定。
148
+ - 面向用户的撤销、内容导航栈与 tab 图标([暂缓](../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md#deferred))。
package/lib/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { a as RESERVED_ERROR_MEMBERS, i as RESERVED_BINDING_GLOBALS, n as DUNDER_MEMBER, o as ReplRuntime, r as PORTABLE_RESERVED_WORDS, t as isFlatBindableName } from "./py-sdk-Chvy92MB.js";
2
2
  import { s as resolveKernelEnv } from "./kernel-env-hxaihi9C.js";
3
3
  import { a as createDshHandler, c as createDvcHandler, d as registerDvcDevice, f as UrlSchemesError, i as createHttpHandler, l as dispatchDvcWrite, m as SCHEME_NAMES, n as wrapFsWithSchemes, o as UrlResolver, p as parseUrl, r as HTTP_SCHEMES, s as resolveDocsDir, u as listDvcDevices } from "./wrap-JFjcWwZf.js";
4
- import { a as resolveCommandPath, i as primaryServerForFile, n as installHintFor, o as serverByName, s as serversForFile, t as findWorkspaceRoot } from "./lsp-server-registry-BexQagaK.js";
4
+ import { a as primaryServerForFile, c as serversForFile, n as findWorkspaceRoot, o as resolveCommandPath, r as installHintFor, s as serverByName } from "./lsp-server-registry-DkaYmTwt.js";
5
5
  import { createRequire } from "node:module";
6
6
  import { Context } from "@deepseek-ai/cordis";
7
7
  import { createHmac, randomBytes, randomUUID } from "node:crypto";
@@ -3603,12 +3603,19 @@ function gateOf(sessionId) {
3603
3603
  state: "unasked",
3604
3604
  nags: 0,
3605
3605
  installTried: /* @__PURE__ */ new Set(),
3606
+ installFailed: /* @__PURE__ */ new Map(),
3606
3607
  warmed: /* @__PURE__ */ new Set()
3607
3608
  };
3608
3609
  gates.set(sessionId, gate);
3609
3610
  }
3610
3611
  return gate;
3611
3612
  }
3613
+ /** Install failures recorded for a session (status surface). */
3614
+ function lspInstallFailures(sessionId) {
3615
+ const out = {};
3616
+ for (const [language, error] of gateOf(sessionId).installFailed) out[language] = error;
3617
+ return out;
3618
+ }
3612
3619
  /** Drop a session's gate state (agent dispose). */
3613
3620
  function disposeLspGate(sessionId) {
3614
3621
  gates.delete(sessionId);
@@ -3629,14 +3636,19 @@ function ensureMustHave(gate, language) {
3629
3636
  if (!MUST_HAVE.has(language) || gate.installTried.has(language)) return;
3630
3637
  gate.installTried.add(language);
3631
3638
  (async () => {
3632
- const registry = await import("./lsp-server-registry-B8DNonhS.js");
3639
+ const registry = await import("./lsp-server-registry-_hk-Wcia.js");
3633
3640
  const probePath = `/tmp/x.${language === "python" ? "py" : "ts"}`;
3634
3641
  const primary = registry.primaryServerForFile(probePath);
3635
3642
  if (primary === null) return;
3636
- const [name$2, config] = primary;
3643
+ const [, config] = primary;
3637
3644
  if (registry.resolveCommandPath(config.command, "/tmp") !== null) return;
3638
- if (!await registry.installServer(config.command)) {}
3639
- })().catch(() => {});
3645
+ if (await registry.installServer(config.command)) {
3646
+ registry.clearCommandProbeCache(config.command);
3647
+ gate.installFailed.delete(language);
3648
+ } else gate.installFailed.set(language, `auto-install of "${config.command}" failed — install manually (${registry.installHintFor(config.command)})`);
3649
+ })().catch((error) => {
3650
+ gate.installFailed.set(language, error instanceof Error ? error.message : String(error));
3651
+ });
3640
3652
  }
3641
3653
  /**
3642
3654
  * Maybe produce the availability notice for a just-landed mutation.
@@ -3665,6 +3677,13 @@ let transport;
3665
3677
  function registerLspGateTransport(fn) {
3666
3678
  transport = fn;
3667
3679
  }
3680
+ /** Availability ensure entry for non-write surfaces (read/status/fan-out). */
3681
+ function lspGateEnsure(sessionId, filePath) {
3682
+ const gate = gateOf(sessionId);
3683
+ const language = languageOf(filePath);
3684
+ if (language === void 0 || !MUST_HAVE.has(language)) return;
3685
+ ensureMustHave(gate, language);
3686
+ }
3668
3687
  function lspGateSyncOnLand(sessionId, kind, filePath, content) {
3669
3688
  if (gateOf(sessionId).state !== "on" || transport === void 0) return;
3670
3689
  if (languageOf(filePath) === void 0) return;
@@ -3983,6 +4002,54 @@ function resolveSymbolColumn(lines, line, symbolSpec) {
3983
4002
  if (occurrence > fallback.length) throw new UrlSchemesError("LSP_SYMBOL_NOT_FOUND", `lsp device: symbol "${symbol}" occurrence ${occurrence} out of bounds on line ${line} (found ${fallback.length})`);
3984
4003
  return fallback[occurrence - 1] ?? 0;
3985
4004
  }
4005
+ /**
4006
+ * Per-file diagnostics fan-out (OMP parity stage 3, made REACHABLE and
4007
+ * honest — 2026-09-15): `{"all":true}` queries every registered server
4008
+ * covering the file. Runs before ANY primary-client requirement; each
4009
+ * server's failure is reported distinctly (`missing`/`error`), never folded
4010
+ * into a silent zero.
4011
+ */
4012
+ async function diagnosticsFanOut(record, file) {
4013
+ const candidates = serversForFile(file);
4014
+ if (candidates.length === 0) throw new UrlSchemesError("LSP_NO_SERVER", `lsp device: no defaults.json language server covers "${path.basename(file)}"`);
4015
+ const results = [];
4016
+ for (const [srvName] of candidates) try {
4017
+ const outcome = await thisDispatch({
4018
+ ...record,
4019
+ all: void 0,
4020
+ server: srvName
4021
+ });
4022
+ results.push({
4023
+ server: srvName,
4024
+ status: "ok",
4025
+ diagnostics: outcome.diagnostics ?? [],
4026
+ summary: outcome.summary ?? ""
4027
+ });
4028
+ } catch (error) {
4029
+ const message = error instanceof Error ? error.message : String(error);
4030
+ const missing$1 = /LSP_SERVER_MISSING|not found/.test(message);
4031
+ results.push({
4032
+ server: srvName,
4033
+ status: missing$1 ? "missing" : "error",
4034
+ diagnostics: [],
4035
+ summary: message
4036
+ });
4037
+ }
4038
+ const total = results.reduce((n, r) => n + r.diagnostics.length, 0);
4039
+ const missing = results.filter((r) => r.status === "missing").map((r) => r.server);
4040
+ const failed = results.filter((r) => r.status === "error").map((r) => r.server);
4041
+ const parts = [`${total} diagnostic(s)`, ...results.map((r) => `${r.server}: ${r.status === "ok" ? String(r.diagnostics.length) : r.status}`)];
4042
+ if (missing.length > 0) parts.push(`missing (NOT clean): ${missing.join(", ")}`);
4043
+ if (failed.length > 0) parts.push(`errors: ${failed.join(", ")}`);
4044
+ return {
4045
+ ok: true,
4046
+ fanout: true,
4047
+ servers: results.map((r) => r.server),
4048
+ perServer: results,
4049
+ diagnostics: results.flatMap((r) => r.diagnostics),
4050
+ summary: `fan-out ${file} — ${parts.join(" — ")}`
4051
+ };
4052
+ }
3986
4053
  /** The `dvc://lsp` device: dispatch on `action`, structured errors on every bad path. */
3987
4054
  const lspDevice = {
3988
4055
  async execute(args, ctx) {
@@ -4014,6 +4081,7 @@ const lspDevice = {
4014
4081
  const position = requirePosition(record);
4015
4082
  let symbolCharacter;
4016
4083
  if (typeof record.symbol === "string" && record.symbol !== "" && position.character === 1 && existsSync(filePath)) symbolCharacter = resolveSymbolColumn((await fsPromises.readFile(filePath, "utf-8")).split("\n"), position.line, record.symbol);
4084
+ if (action === "diagnostics" && record.all === true) return await diagnosticsFanOut(record, filePath);
4017
4085
  const { name: name$2, client } = await obtainClient(record, filePath);
4018
4086
  const base = {
4019
4087
  ok: true,
@@ -4036,38 +4104,6 @@ const lspDevice = {
4036
4104
  await shutdownClientInstance(client$1);
4037
4105
  return `${name$3}: reloaded — the next lsp call respawns the server fresh (config re-read from disk).`;
4038
4106
  }
4039
- if (action === "diagnostics" && record.all === true) {
4040
- const candidates = serversForFile(file);
4041
- if (candidates.length <= 1) {} else {
4042
- const results = [];
4043
- for (const [srvName] of candidates) try {
4044
- const outcome = await thisDispatch({
4045
- ...record,
4046
- all: void 0,
4047
- server: srvName
4048
- });
4049
- results.push({
4050
- server: srvName,
4051
- diagnostics: outcome.diagnostics ?? [],
4052
- summary: outcome.summary ?? ""
4053
- });
4054
- } catch (error) {
4055
- results.push({
4056
- server: srvName,
4057
- diagnostics: [],
4058
- summary: error instanceof Error ? error.message : String(error)
4059
- });
4060
- }
4061
- const total = results.reduce((n, r) => n + r.diagnostics.length, 0);
4062
- return {
4063
- ok: true,
4064
- fanout: true,
4065
- servers: results.map((r) => r.server),
4066
- diagnostics: results.flatMap((r) => r.diagnostics),
4067
- summary: `fan-out ${file}: ${total} diagnostic(s) across ${results.length} server(s) — ${results.map((r) => `${r.server}: ${r.diagnostics.length}`).join(", ")}`
4068
- };
4069
- }
4070
- }
4071
4107
  if (action === "diagnostics" && file === "*") {
4072
4108
  const servers = listLiveClients().filter((c) => c.status === "ready");
4073
4109
  const files = [];
@@ -4257,7 +4293,7 @@ const lspDevice = {
4257
4293
  default: throw new UrlSchemesError("LSP_BAD_ARGS", `lsp device: unhandled action "${String(action)}"`);
4258
4294
  }
4259
4295
  },
4260
- summary: "LSP queries over stdio language servers (defaults.json registry) — diagnostics / definition / references / hover on {file,line,character}",
4296
+ summary: "LSP over stdio language servers (defaults.json registry). read: status | diagnostics?file=[&all=1] | definition|references|hover?file=&line=&character=|symbol=. write: on/off/status | diagnostics(+all) | definition/references/hover | format | rename | code_actions | reload",
4261
4297
  read: lspDeviceRead
4262
4298
  };
4263
4299
  /** One-level recursion for fan-out: re-dispatch with a specific `server`. */
@@ -4282,15 +4318,44 @@ function parseReadQuery(subpath) {
4282
4318
  }
4283
4319
  async function lspDeviceRead(subpath, session) {
4284
4320
  const parsed = parseReadQuery(subpath);
4321
+ if (session !== void 0 && parsed !== void 0) {
4322
+ const f = parsed.args["file"];
4323
+ if (typeof f === "string" && f !== "") lspGateEnsure(session, f);
4324
+ }
4285
4325
  if (parsed === void 0) return `unknown read path dvc://lsp/${subpath} — reads: status | diagnostics?file=…[&all=1] | definition?file=&line=[&character=|&symbol=] | references?… | hover?…`;
4286
4326
  if (parsed.action === "status") {
4287
- if (session !== void 0) return JSON.stringify({
4327
+ const availability = {};
4328
+ const registry = await import("./lsp-server-registry-_hk-Wcia.js");
4329
+ for (const language of [
4330
+ "python",
4331
+ "typescript",
4332
+ "javascript",
4333
+ "rust",
4334
+ "go"
4335
+ ]) {
4336
+ const probe = `/tmp/probe${language === "python" ? ".py" : language === "go" ? ".go" : language === "rust" ? ".rs" : ".ts"}`;
4337
+ const primary = registry.primaryServerForFile(probe);
4338
+ availability[language] = {
4339
+ command: primary !== null ? primary[1].command : language,
4340
+ available: primary !== null && registry.resolveCommandPath(primary[1].command, "/tmp") !== null
4341
+ };
4342
+ }
4343
+ const base = {
4288
4344
  ok: true,
4289
- gate: lspGateState(session)
4345
+ availability,
4346
+ liveServers: listLiveClients().filter((c) => c.status === "ready").map((c) => `${c.config.command}@${c.root}`)
4347
+ };
4348
+ if (session === void 0) return JSON.stringify({
4349
+ ...base,
4350
+ gate: null,
4351
+ note: "gate is per-session — read from a session for its gate"
4290
4352
  });
4353
+ const gate = lspGateState(session);
4354
+ const failures = lspInstallFailures(session);
4355
+ if (Object.keys(failures).length > 0) base.installFailures = failures;
4291
4356
  return JSON.stringify({
4292
- ok: true,
4293
- note: "per-session gate — read from a session for its gate (session missing in read env)"
4357
+ ...base,
4358
+ gate
4294
4359
  });
4295
4360
  }
4296
4361
  const result = await lspDevice.execute(parsed.args);
@@ -776,13 +776,34 @@ function serversForFile(filePath) {
776
776
  return matches;
777
777
  }
778
778
  /**
779
- * The primary server for a file: first non-linter match in registry order,
780
- * falling back to the first linter (upstream getServerForFile preference —
781
- * type intelligence over linting).
779
+ * Clear cached negative probes for a command (after a successful install) so
780
+ * availability is re-probed instead of failing forever until restart.
781
+ */
782
+ function clearCommandProbeCache(command) {
783
+ if (command === void 0) {
784
+ commandProbeCache.clear();
785
+ return;
786
+ }
787
+ for (const key of [...commandProbeCache.keys()]) if (key.startsWith(`${command}\0`) && commandProbeCache.get(key) === null) commandProbeCache.delete(key);
788
+ }
789
+ /**
790
+ * The primary server for a file: first AVAILABLE non-linter match in
791
+ * registry order (availability = its binary resolves), falling back to the
792
+ * first available linter, and only then to the preference-order pick
793
+ * (upstream filters by availability before routing — the vendored port was
794
+ * availability-blind and bound .ts to a missing typescript-language-server
795
+ * even when other covering servers were installed).
782
796
  */
783
797
  function primaryServerForFile(filePath) {
784
798
  const matches = serversForFile(filePath);
785
- return matches.find(([, config]) => config.isLinter !== true) ?? matches[0] ?? null;
799
+ if (matches.length === 0) return null;
800
+ const cwd = path.dirname(filePath);
801
+ const preferred = matches.find(([, config]) => config.isLinter !== true) ?? matches[0];
802
+ for (const match of matches) {
803
+ if (match[1].isLinter === true && match[0] !== preferred[0]) continue;
804
+ if (resolveCommandPath(match[1].command, cwd) !== null) return match;
805
+ }
806
+ return preferred;
786
807
  }
787
808
  /** Look up a named registry entry (device `server` arg). */
788
809
  function serverByName(name) {
@@ -847,7 +868,15 @@ function resolveCommandPath(command, cwd) {
847
868
  const cacheKey = `${command}\0${cwd}`;
848
869
  const cached = commandProbeCache.get(cacheKey);
849
870
  if (cached !== void 0) return cached;
850
- const resolved = (path.isAbsolute(command) ? [command] : [path.join(cwd, "node_modules", ".bin", command), ...(process.env.PATH ?? "").split(path.delimiter).filter((dir) => dir !== "").map((dir) => path.join(dir, command))]).find((candidate) => isExecutableFile(candidate)) ?? null;
871
+ const resolved = (path.isAbsolute(command) ? [command] : [
872
+ path.join(cwd, "node_modules", ".bin", command),
873
+ ...process.env.HOME !== void 0 ? [
874
+ path.join(process.env.HOME, ".local", "bin", command),
875
+ path.join(process.env.HOME, ".cargo", "bin", command),
876
+ path.join(process.env.HOME, "go", "bin", command)
877
+ ] : [],
878
+ ...(process.env.PATH ?? "").split(path.delimiter).filter((dir) => dir !== "").map((dir) => path.join(dir, command))
879
+ ]).find((candidate) => isExecutableFile(candidate)) ?? null;
851
880
  commandProbeCache.set(cacheKey, resolved);
852
881
  return resolved;
853
882
  }
@@ -940,4 +969,4 @@ function installHintFor(command) {
940
969
  }
941
970
 
942
971
  //#endregion
943
- export { resolveCommandPath as a, primaryServerForFile as i, installHintFor as n, serverByName as o, installServer as r, serversForFile as s, findWorkspaceRoot as t };
972
+ export { primaryServerForFile as a, serversForFile as c, installServer as i, findWorkspaceRoot as n, resolveCommandPath as o, installHintFor as r, serverByName as s, clearCommandProbeCache as t };
@@ -0,0 +1,3 @@
1
+ import { a as primaryServerForFile, c as serversForFile, i as installServer, n as findWorkspaceRoot, o as resolveCommandPath, r as installHintFor, s as serverByName, t as clearCommandProbeCache } from "./lsp-server-registry-DkaYmTwt.js";
2
+
3
+ export { clearCommandProbeCache, findWorkspaceRoot, installHintFor, installServer, primaryServerForFile, resolveCommandPath, serverByName, serversForFile };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "better-dsh",
3
- "version": "0.2.3-g",
3
+ "version": "0.2.4",
4
4
  "type": "module",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
@@ -1,3 +0,0 @@
1
- import { a as resolveCommandPath, i as primaryServerForFile, n as installHintFor, o as serverByName, r as installServer, s as serversForFile, t as findWorkspaceRoot } from "./lsp-server-registry-BexQagaK.js";
2
-
3
- export { findWorkspaceRoot, installHintFor, installServer, primaryServerForFile, resolveCommandPath, serverByName, serversForFile };