# dsh-worktree-sidebar 架构与运行机制(TOGAF 4A 四视图)
> 包:`@wingsky-1/dsh-worktree-sidebar` · 源码:`packages/dsh-worktree-sidebar/` · 版本:0.1.0(未发布)
> 功能一句话:**把某个 git worktree 登记给当前会话,让该会话官方右侧栏的文件树根指向它,而会话 cwd 不变**。
>
> 快速上手(安装 / 验证 / 兼容性 / 安全模型 / 已知限制)见 [包 README](../../packages/dsh-worktree-sidebar/README.md);
> 本文按 TOGAF 四视图(业务 BA / 应用 AA / 数据 DA / 技术 TA)讲**原理与运行机制**。
>
> 证据口径:路径省略前缀 `packages/dsh-worktree-sidebar/`,行号取自本分支当前树。
> 凡本文写「未验证」的,都是无法从本仓代码或实跑命令确认的事实;官方包内部的断言以仓库内可读副本为证据。
## 四视图导航
| 视图 | 回答的问题 | 章节 | 本章图(独立 SVG) | 主要证据面 |
|---|---|---|---|---|
| BA 业务架构 | 用户 / agent 能感知到什么能力,刻意不做什么 | [§1](#user-content-1-业务架构ba) | [BA 业务架构图](diagrams/worktree-sidebar-ba.svg) | 三个工具定义、包 README 的显式非目标 |
| AA 应用架构 | 宿主五域与浏览器端怎么分工、依赖谁、在哪接管 | [§2](#user-content-2-应用架构aa) | [AA 应用架构图](diagrams/worktree-sidebar-aa.svg) | 五域 `interface.ts` / `deps.ts`、客户端接管块 |
| DA 数据架构 | 绑定存在哪、长什么样、怎么失效与自愈 | [§3](#user-content-3-数据架构da) | [DA 数据架构图](diagrams/worktree-sidebar-da.svg) | `binding/impl/model`、`store`、自愈判据 |
| TA 技术架构 | 怎么挂载、依赖什么、怎么构建、测试与命门 | [§4](#user-content-4-技术架构ta) | [TA 技术架构图](diagrams/worktree-sidebar-ta.svg) | `cordis.patch.yml`、`package.json`、测试与门禁面 |
> 每节两张图分工固定:**SVG 讲完整载体与证据标注**(源文件 `diagrams/worktree-sidebar-<视图>.html`,
> 自包含内联 SVG,可打开调整后重新导出),**节末 mermaid 讲关系逻辑**(链路、依赖方向、判定分支)。
> 两者不是同一张图的两种画法,改一处不必同步另一处。
---
## 1. 业务架构(BA)

> 图源 `diagrams/worktree-sidebar-ba.html`。上图给完整能力面(含非目标分区与每条论断的证据标注);
> 本节末的 mermaid 只给「能力 → 入口 → 产物」的关系逻辑。
### 1.1 能力面(两条)
**能力 A:agent 建 / 登记 worktree 时会话绑定。**
- 触发者是 agent(模型)在会话内发起工具调用;工具**只在「该会话 cwd 位于 git 仓库内」时注册**,
否则一个工具都不给(`src/server/tools/impl/service/index.ts:81` 经 `repoOf`;
`src/server/tools/impl/bind/index.ts:156-160` 判定)。
- 三个工具的同一次装配:`src/server/tools/impl/service/index.ts:84-88`;名称分别见
`register/index.ts:19`、`create/index.ts:11`、`remove/index.ts:40`。
- 写入路径的校验顺序是刻意的(先本地事实、后要起 git 的归属判定):
路径存在且是目录 → 属于同一仓库 → 落盘(`bind/index.ts:1-9`、`:96-153`;
路径判定在 `register/index.ts:73-76`、`create/index.ts:76-83`)。
- 失败一律 fail-closed:归属不是 `same` 就拒绝写(`bind/index.ts:114-131`)。
**能力 B:右侧栏展示绑定 worktree 下的文件。**
- 载体是官方 `sidebar.right.pane.tab` 座位上的 `files` 正文,被本插件以**同 key、更低 priority 遮蔽**
(`src/client/takeover.ts:29-32`、`:41-43`)。
- 重读只发生在三个用户可感知的时机:打开页签 / 点官方刷新 / 窗口重新可见或获得焦点;
**没有定时轮询**(`src/client/inject.ts:133-156`、`:163-173`、`src/client/index.ts:6-8`)。
- 子 agent 会话**与用户 fork 的会话**都继承父会话的登记:本会话没有登记时沿父链向上找,
命中的第一条登记连同**持有它的那个会话 id** 一起返回(fork 与子 agent 的判据同为 header
`parentSession`,本域刻意不区分;`src/server/scope/impl/inherit/index.ts:24-38`)。
### 1.2 能力 → 入口 → 可见产物
| 能力 | 入口(工具) | 宿主侧产物 | 用户 / 模型可见产物 |
|---|---|---|---|
| 建 worktree 并绑定 | `ws_worktree_create`(`create/index.ts:11`) | `git worktree add` + `bindings.json` 一条登记 | Files 页签根 = 新 worktree;返回文本带状态行 |
| 登记已有 worktree | `ws_worktree_register`(`register/index.ts:19`) | 只写 `bindings.json` 一条登记 | 同上 |
| 解除绑定 | `ws_worktree_remove`(`remove/index.ts:40`) | 摘登记;仅 `removeDirectory: true` 才 `git worktree remove` | Files 页签回到会话 cwd(`remove/index.ts:99-117`) |
状态行由统一信封渲染,**永远出现在返回文本首行**,失败路径也说清当前指向哪里
(`src/server/tools/impl/protocol/index.ts:54-62`、`RESULT_SCHEMA` 在 `:27-52`)。
`ws_worktree_create` 的「目录建出来了但绑定失败」是**部分成功**,不会被报成失败
(`create/index.ts:119-131`)。
### 1.3 显式非目标
| 非目标 | 代码 / 文档依据 |
|---|---|
| 不改执行 cwd | 插件从不写 `session.header.cwd`;工具描述明写 cwd 不变(`register/index.ts:21-29`) |
| `@` 引用、`present` 落点、skill 目录仍锚 cwd | 同上;改写只覆盖单个 entry 的 inject 面(`src/client/source.ts:62-75`) |
| `workspace-write` 策略下写不进 worktree | 工具描述 `register/index.ts:27` |
| 不接管其它 `useSessions` 消费方 | 只改这一条 entry 的注入面(`source.ts:62-75`;[包 README](../../packages/dsh-worktree-sidebar/README.md) 的「显式非目标」) |
| 页签不自动跟随 | 无轮询(`src/client/index.ts:6-8`) |
| 不注入系统提示词 | 包 README「已知限制」(文档事实,代码无对应实现) |
```mermaid
flowchart LR
AGENT(["agent(模型)"]) -->|"工具调用"| T1["ws_worktree_create
git worktree add + 登记"]
AGENT -->|"工具调用"| T2["ws_worktree_register
登记已存在的 worktree"]
AGENT -->|"工具调用"| T3["ws_worktree_remove
摘登记;可选删目录"]
T1 --> GATE{"会话 cwd 在 git 仓库里?"}
T2 --> GATE
GATE -->|"否:不注册工具"| NONE["无工具可用"]
GATE -->|"是"| BIND["bindings.json
会话 id → worktree 登记"]
BIND --> VIEW["右侧栏 Files 页签
文件树根 = worktree"]
BIND --> TEXT["工具返回文本首行
bound worktree + branch"]
VIEW --> PREVIEW["文件预览读 worktree 里那一份"]
T3 --> UNBIND["Files 页签回到会话 cwd"]
CWD["session.header.cwd(全程不改)"] -.->|"@ 引用 / present 落点 / 执行 cwd"| AGENT
```
---
## 2. 应用架构(AA)

> 图源 `diagrams/worktree-sidebar-aa.html`。上图给完整载体:组合根装配序、五域依赖窄面、
> 三个宿主适配层、浏览器端影子接管与两条路由;本节末的 mermaid 只给依赖方向与接管关系。
### 2.1 宿主端:组合根 + 五域 + 三个适配层
`src/index.ts` 是全包**唯一认识 `ctx` 的地方**(`src/index.ts:1-7`),它把 `ctx` 收窄成 `HostPort` 后
按依赖顺序装配五域(`src/index.ts:58-72`、`:93-148`),并声明宿主服务依赖
`inject = ["webServer", "agents", "typert", "sessions"]`(`src/index.ts:39`)。
| 序 | 域 | 装配调用 | 依赖谁的能力(经 `deps.ts` 声明) | 证据 |
|---|---|---|---|---|
| 1 | binding | `installBinding({ logger, file })` | 无域依赖(只有落盘路径与日志) | `src/index.ts:102`;`src/server/binding/deps.ts:5-9` |
| 2 | git | `installGit({ exec })` | 无域依赖(exec 面由组合根注入) | `src/index.ts:107`;`src/server/git/deps.ts:23-25` |
| 3 | scope | `installScope({ logger, binding, git, typert, sessions })` | `binding: get / drop`、`git: belongsTo` | `src/index.ts:112-118`;`src/server/scope/deps.ts:19`、`:22` |
| 4 | tools | `installTools({ logger, binding, git, scope, agents, now })` | `binding: get / put / drop`、`git: commonDir / addWorktree / resolveCommit 等 8 方法` | `src/index.ts:123-130`;`src/server/tools/deps.ts:12`、`:15-25` |
| 5 | api | `installApi({ register, logger, binding, scope })` | `binding: revision`、`scope: effectiveWorktree + 状态读数` | `src/index.ts:135-140`;`src/server/api/deps.ts:11`、`:20`、`:28` |
三条硬纪律(都由代码与单测守):
1. **域拿不到 `ctx`**:官方**宿主上下文 / 服务**类型只在组合根与 `src/server/host/` 三个适配层出现
(`src/index.ts:1-7`;`src/server/host/typert.ts:1-10`);各域只经自己的 `deps.ts` 引官方**类型**
(全 type-only,例如 `src/server/tools/deps.ts:2` 引 `@deepseek-ai/dsh-tools`、
`src/server/api/deps.ts:2` 引 `@deepseek-ai/dsh-host-webserver`),这正是 `verify-dir-imports` 认可的依赖面。
2. **单例 + `install` / `release` 成对,二次装配显式抛错**:五域的守卫分别在
`binding/impl/service/index.ts:52`、`git/impl/service/index.ts:105`、`scope/impl/service/index.ts:102-103`、
`tools/impl/service/index.ts:37`、`api/impl/service/index.ts:31`。
3. **装配中途失败要回滚**:已装域按逆序释放后原样抛出(`src/index.ts:142-145`、`:151-160`);
api 域的端点注册是事务性的(`src/server/api/impl/route/index.ts:28-68`,`api/impl/service/index.ts:35-42`)。
依赖方向的口径是「谁 `Pick` 了谁的 `interface.ts`」:`scope` 只拿 binding 的 `get/drop`
(`scope/deps.ts:19`)、tools 只拿 `commonDir` 等 8 个 git 方法(`tools/deps.ts:15-25`)、
api **刻意不读绑定表原文**而读 scope 算出的生效值(`api/deps.ts:13-20`),
且 api 的能力面里没有 `put/drop`,因此**浏览器侧不存在写绑定的授权路径**(`tools/deps.ts:12`)。
### 2.2 工具面与写路径不变量
- 三个工具由 tools 域装进**每个 agent 自己的作用域**,并随该 agent 的 effect 一起释放
(`src/server/host/agents.ts:50-82`);两条入口缺一不可:`agent/created` 覆盖之后发布的 agent,
`list()` 覆盖装配前已在跑的 agent(`tools/impl/service/index.ts:1-9`、`:42-43`)。
- 写绑定只有一条路:`bindWorktree`(`tools/impl/bind/index.ts:96-153`),归属校验在其中做**唯一一次**,
所以「只允许绑定同一仓库的 worktree」不会因某条路径漏写而破。
- 删除是显式的:默认只摘登记,`force` 必须与 `removeDirectory: true` 同时给出
(`remove/index.ts:91-97`)。
### 2.3 浏览器端:干净模块与官方座位的影子接管
客户端入口只 `export function apply(ctx)` + `export const inject`(`src/client/index.ts:77`、`:133`),
`inject` 面是 `["slots", "sidebarRightTabs", "sessions", "locale"]`(`src/client/index.ts:127-133`);
块内分工见本页 §2 的图,跨块形状只由纯类型面承载(`src/client/shared/ports.ts:121-128`),
`takeover.ts` 与 `inject.ts` **零互引**(`src/client/takeover.ts:24`)。
影子接管的三条设计事实:
1. **priority = 官方那条 − 1**:`shadowPriorityOf` 取 `(official.options.priority ?? 0) - 1`
(`takeover.ts:41-43`)。官方座位注册表按 priority 升序排序、**最低者渲染**
(`node_modules/.pnpm/@deepseek-ai+dsh-client-ui-slots@0.1.5-rc.1_.../lib/index.js:76-77`、`:130`,
由 `pnpm-workspace.yaml:19` 的 catalog 锁版)。
2. **复用官方一切,只包 `inject`**:组件 / store / locale 原样递回,`inject` 换成包装后的工厂
(`takeover.ts:137-147`);类型表从不改写,因此标题与 `guide` 保持官方原样
(`src/client/shared/ports.ts:79-86`)。
3. **登记成功不等于当值**:`isWinner` 自检当值项必须是带我们 priority 的那条,否则当场退位并出声;
优先级冲突是终态不重试,瞬时登记失败允许再来一次(`takeover.ts:48`、`:112-116`、`:148-176`、
`:197-209`)。
改写面只有两处:`hooks.sessions` 指向按会话的改写源(`inject.ts:62-72`),
`start` / `load` 改成「以生效根播种」(`inject.ts:80-157`)。改写源只动
`byId[sessionId].cwd` 一个字段,且同快照 + 同路径必须回同一对象(`source.ts:33-45`、`:62-75`)。
每个会话一套视图,上限 128,淘汰最冷的那条(`src/client/index.ts:70-102`)。
### 2.4 HTTP 路由面
路由常量与响应形状是双端共享契约的**单点定义**(`src/shared/contract.ts:12-28`):
| 路由 | 方法 | 成功响应 | 失败 | 证据 |
|---|---|---|---|---|
| `/api/dsh-worktree-sidebar/bindings?session=` | GET | `{ revision, worktreePath \| null }` | 缺 `session` 400;非回环 403;方法错 405 | `api/impl/handlers/index.ts:24-42` |
| `/api/dsh-worktree-sidebar/health` | GET | `{ ok, revision, scopeTakeover, scopeChain }` | 同上 | `api/impl/handlers/index.ts:44-67` |
- 围栏用仓库共享层那一份(`shared/host-utils.js:26-36`),**非回环 403 先于方法错 405**
(`api/impl/route/index.ts:41`)。
- `bindings` **不回 `repoRoot`**:客户端只需要目录根,多回一个字段就多一份暴露面
(`handlers/index.ts:1-8`、`src/shared/contract.ts:23-28`)。
- 端点带**自愈副作用**:解析生效根时会顺带摘除已确认失效的登记(见 §3.5),这是让客户端
`revision` 缓存失效的唯一通道(`handlers/index.ts:36`;`scope/impl/own/index.ts:1-16`)。
- 端点异常统一收口成日志 + 500,且只在响应头未发出时补写(`api/impl/route/index.ts:70-78`)。
```mermaid
flowchart TD
ROOT["src/index.ts 组合根
唯一认识 ctx 的地方:按序装配、逆序释放"]
subgraph HOST["宿主端:五个进程内单例域(interface.ts / deps.ts / impl)"]
BINDING["binding 域
绑定表唯一事实源"]
GIT["git 域
worktree 归属查询与增删"]
SCOPE["scope 域
接管 workspaceFileScope"]
TOOLS["tools 域
三个 agent 工具"]
API["api 域
浏览器出口(只读)"]
end
subgraph ADAPTER["宿主适配层 src/server/host"]
AD_AG["agents.ts"]
AD_SE["sessions.ts"]
AD_TY["typert.ts"]
end
ROOT -->|"1 installBinding"| BINDING
ROOT -->|"2 installGit"| GIT
ROOT -->|"3 installScope"| SCOPE
ROOT -->|"4 installTools"| TOOLS
ROOT -->|"5 installApi"| API
SCOPE -->|"binding:get / drop"| BINDING
SCOPE -->|"git:belongsTo"| GIT
TOOLS -->|"binding:get / put / drop,唯一写入口"| BINDING
TOOLS -->|"git:commonDir / addWorktree / resolveCommit 等 8 方法"| GIT
API -->|"binding:revision"| BINDING
API -->|"scope:effectiveWorktree + 状态读数"| SCOPE
ROOT --> AD_AG
ROOT --> AD_SE
ROOT --> AD_TY
AD_AG -.->|"AgentPort:agent/created + list + publish"| TOOLS
AD_SE -.->|"SessionChainPort:父链 + 身份"| SCOPE
AD_TY -.->|"TypertPort:current / subscribe / configure"| SCOPE
subgraph CLIENT["浏览器端:干净模块(apply + inject)"]
C_IDX["client/index.ts
装配 + 会话视图 LRU 128"]
C_TAKE["takeover.ts
影子正文接管"]
C_INJ["inject.ts
hooks.sessions + 播种时机"]
C_SRC["source.ts
只改 byId[sessionId].cwd"]
C_BIND["bindings.ts
拉 revision 与生效根"]
C_PORTS["shared/ports.ts
块间纯类型面"]
end
OFFICIAL["官方 files 正文条目
sidebar.right.pane.tab / kind=files"]
C_IDX --> C_TAKE
C_IDX --> C_INJ
C_TAKE -.->|"deps.wrapInject 注入"| C_INJ
C_INJ --> C_SRC
C_SRC --> C_BIND
C_PORTS -.->|"仅类型"| C_TAKE
C_TAKE -->|"同 key、priority = 官方 - 1 遮蔽"| OFFICIAL
API -->|"fetch bindings"| C_BIND
```
---
## 3. 数据架构(DA)

> 图源 `diagrams/worktree-sidebar-da.html`。上图给完整载体:写入链、记录形状、身份凭据三步、
> 归属三态与两层缓存、自愈三判据与 revision、视图 LRU;本节末的 mermaid 只给生命周期逻辑。
### 3.1 落盘位置与写入协议
- 唯一落盘物是绑定表:`/@wingsky-1/dsh-worktree-sidebar/bindings.json`
(`src/server/shared/paths.ts:9-17`)。路径只认 `DSH_HOME` 一个变量,经
`shared/dsh-home.js` 归一(空白视同未设置,回落 `~/.dsh`;`shared/dsh-home.js:49-52`)。
- 写是**原子写**:补齐父目录 → 写同目录临时文件 `.tmp-<进程 pid>` → `rename` 覆盖
(`src/server/shared/file-io.ts:28-40`)。失败用返回值表达,不抛异常(`:13-17`)。
- 读在装配期同步完成(`readTextFileSync`,`file-io.ts:19-26`),
文件缺失 / 不可读 / 是目录一律归为同一类,回落空表(`file-io.ts:13-14`、`binding/impl/store/index.ts:7-12`)。
- **内存快照是唯一事实源**,落盘只是它的持久化副本:scope 的解析器与 api 的路由读同一个对象,
两端 `revision` 因此不可能各说各话(`binding/impl/service/index.ts:1-10`、`:79-98`)。
- 写盘串行链保证两次并发修改不会互相覆盖;失败时**内存不前移**(`service/index.ts:38-42`、
`:107-121`)。`release` 会等在飞的写盘落定,并用装配代数避免抹掉新一代刚读回的表
(`service/index.ts:43-48`、`:60-77`)。
### 3.2 记录字段与版本
`binding/impl/model/type.ts:18-40` 定义了记录与整表形状:
| 字段 | 类型 | 语义 | 证据 |
|---|---|---|---|
| `version` | number | 表版本;当前 `BINDINGS_VERSION = 2` | `type.ts:16`、`:36-40` |
| `revision` | number | **内容版本**而非写入次数,空表从 0 起;客户端只做相等/单调比较 | `impl/model/index.ts:10-13` |
| `bindings` | Record | 会话 id → 登记 | `type.ts:39` |
| `repoRoot` | string | worktree 所属主仓库根,用于归属校验 | `type.ts:20-21` |
| `worktreeRoot` | string | 视图根要指向的 worktree 绝对路径 | `type.ts:22-23` |
| `branch` | string | 登记时分支名,仅展示与排查 | `type.ts:24-25` |
| `createdAt` | string | 登记时刻 ISO 时间戳(人读) | `type.ts:26-27` |
| `sessionCreatedAt` | number | 会话身份凭据(epoch 毫秒),见 §3.3 | `type.ts:28-32` |
版本与修订规则:
- `version` 不等于 2 一律**整表回落空表**,不做部分恢复、不猜着读(`impl/model/index.ts:33-47`);
v1→v2 无迁移路径,因为本包尚未发布、磁盘上不存在合法 v1(`type.ts:9-15`)。
- 单条记录任一字段不合格就丢弃该条:半条记录比没有记录更危险(`impl/model/index.ts:15-31`)。
- `put` 让 `revision + 1`;`drop` 目标不存在时原样返回(不涨 revision),
否则「清理一个本来就没有的会话」会让全网客户端白刷一次(`impl/model/index.ts:67-86`)。
- 序列化带缩进,让人能直接看这份文件(`impl/model/index.ts:62-65`)。
### 3.3 会话身份凭据与 id 复用风险
官方会话 id 由**实例计数器**生成(`session-${++this.counter}`),重启后新会话会重新拿到 `session-1`;
插件侧同一条事实写在 `src/server/scope/deps.ts:78-86` 与 `src/server/host/sessions.ts:16-23`。
本机官方包可读到计数器与恢复分支的源码位置(`@deepseek-ai/dsh-session/lib/index.js:1313`、`:1377`
与 `:1385` 的 `Session.fromRestore`),这是**安装树上的外部证据、不在 CI 内**,故「真机会发生碰撞」
属推断而非本文验证结论。
因此登记里存了会话 header 的 `createdAt` 作凭据,三步闭环:
1. **写**:工具从执行上下文取 `exec.agent.session.header.createdAt`;
拿不到就**拒绝落盘**,并给出「无法与复用同 id 的另一个会话区分」的原因
(`src/server/tools/impl/session/index.ts:8-33`;`tools/impl/bind/index.ts:104-113`)。
2. **核对**:活会话优先,不在册才回落持久面(`sessionPersistence.stat`);
凭据不同 ⇒ 摘掉这条登记;读不出来 ⇒ 保留(`scope/impl/own/index.ts:68-81`、`:97-112`;
host 适配层 `src/server/host/sessions.ts:68-78`)。
3. **持久面每次现取**:`ctx.get("sessionPersistence")` 在调用当刻取,
提前取一次会让晚挂的后端永久退化成缺席(`src/index.ts:74-81`)。
### 3.4 git 归属三态与缓存策略
`belongsTo` 回**三态而不是布尔**(`src/server/git/deps.ts:38-58`;实现 `git/impl/service/index.ts:160-183`):
| 读数 | 条件 | 调用方处置 |
|---|---|---|
| `same` | 两侧都**确实**给出了公共 git 目录且相同 | 允许落盘 / 保留登记 |
| `different` | 两侧都给出了公共 git 目录且不同 | 摘登记(自愈)/ 拒绝写 |
| `unknown` | 至少一侧没读出来(spawn 失败 / 权限 / 超时) | 保留登记 + 出声;写路径 fail-closed |
`unknown` 还带一个 `notRepo` 标志:至少一侧**明确**回了「不是 git 工作树」,
它只用来把失败文案说准,不参与摘除判定(`git/deps.ts:49-57`)。
限定一条:权限不可读的目录在读数上与「不是工作树」同形,所以那句文案读作
「git 拒绝把它当工作树」,而不是「我们已经验过了」(`tools/impl/bind/index.ts:116-119`)。
缓存策略(`git/impl/service/index.ts`):
| 缓存 | 键 | 缓存什么 | TTL | 上限 | 证据 |
|---|---|---|---|---|---|
| 归属判定 `belongsTo` | `dir + NUL + repoRoot` | **只缓存已定读数**(`same` / `different`);`unknown` 不缓存 | 30s | 256,超限整表丢弃 | `:43`、`:46`、`:160-183` |
| 仓库判定 `commonDir` | `dir` | **只存「拿到了公共 git 目录」**;负结果每次真问 git | 30s | 256 | `:59`、`:62`、`:100-101`、`:126-137` |
- `unknown` 不进缓存的理由:写路径对它是 fail-closed,且文案让调用方「等目录可读后重试」;
把那次 `unknown` 记住,就等于把这句重试建议变成 30s 内的确定性失败
(`service/index.ts:39-41`、`:176-178`)。
- 负结果不缓存的理由(`commonDir`):`git init` 可能就发生在下一次调用之前;「读不出来」本就该重试
(`service/index.ts:56-57`、`:123-125`)。
- TTL 刻意与任何客户端刷新节拍无关:早先同值 5s 导致每轮轮询都踩在过期点上,
一个绑定会话每小时白起约 1500 个 git 子进程(`service/index.ts:29-42`)。
- 归属判定间接吃 `commonDir` 的缓存,故**只有已定读数**的最坏不新鲜期是 60s(两层 TTL 同值叠加);
`unknown` 不受这条叠加影响,它每次都真问(`service/index.ts:36-38`)。
- 公共目录返回值是相对路径时要按入口目录 `resolve` 补全,否则「同一仓库的两个 worktree」会被判成不同仓库
(`service/index.ts:149-151`)。
### 3.5 作用域内清理(自愈)与 revision 传播
失效判据要求**硬证据**才允许摘除(`scope/impl/own/index.ts:1-16`、`:56-94`):
| 判据 | 摘除条件 | 读不出来时 | 证据 |
|---|---|---|---|
| 目录是否存在 | `stat` 说 ENOENT / ENOTDIR | 按存在处理(EACCES / EIO 不等于不存在) | `own/index.ts:37-48` |
| 会话身份 | 登记 `sessionCreatedAt` 与当前会话 `createdAt` 不同 | 保留登记(不下结论) | `:68-81`、`:97-112` |
| worktree 归属 | 两侧**都确实**读到公共 git 目录且不同 | 保留登记 + `logger.warn` | `:82-92` |
摘除动作是 `binding.drop` + `logger.warn`;drop 本身可能失败或抛错,两条路都出声但都不上抛
(`own/index.ts:114-127`)。摘掉而不是仅仅忽略,是因为客户端以 `revision` 判定缓存有效性:
**只忽略不摘的话 revision 不变,树会一直指着已经不成立的根**(`own/index.ts:1-16`;
`api/impl/handlers/index.ts:24-42` 的解析路径带这条副作用)。
静默降级只有一处,且给了落地读数:已结束会话的父链只能从持久面读,读不出来时按「到顶」收口,
于是继承悄悄退回 live-only(`scope/impl/inherit/index.ts:47-56`)。
`health` 的 `scopeChain` 就是它唯一能落地的痕迹(`scope/impl/service/index.ts:67-74`、`:168-178`;
`api/impl/handlers/index.ts:54-67`)。
工具面读的是**同一份解析**,但**不设 takeover 门**:`worktreeOrigin` 在 waiting / abandoned 期照旧
给出登记(那是宿主启动序与第三方占位的读数,不是绑定事实的一部分),因此这一态的「工具说已绑定、
侧边栏仍按 cwd」是**刻意**的,由 `/health` 的 `scopeTakeover` 分辨
(`scope/impl/service/index.ts:193-200`、`scope/interface.ts:31-40`)。
### 3.6 视图缓存(LRU)
客户端每个会话一套视图(改写源 + 生效根读数),同一 id 恒回同一对象;上限
`VIEW_CACHE_MAX = 128`,命中即「触碰变新」,超限淘汰最冷的那条
(`src/client/index.ts:70-102`)。淘汰是安全的:每套视图都是同一份宿主事实的独立读数,
被淘汰的视图若仍被渲染层持有,它读到的仍是宿主当前值(`index.ts:70-74`)。
卸载时 `releaseAllSeedings()` + `views.clear()` 统一收口(`index.ts:112-120`)。
```mermaid
flowchart TD
subgraph MEM["宿主进程内存(唯一事实源)"]
SNAP["BindingsFile 快照
version=2 / revision / bindings"]
LIST["内存里只有一份:scope 解析器与 api 路由读同一个对象"]
SNAP --- LIST
end
subgraph DISK["落盘 DSH_HOME"]
FILE["@wingsky-1/dsh-worktree-sidebar/bindings.json"]
TMP["同目录临时文件 bindings.json.tmp-进程pid"]
end
REC["BindingRecord
repoRoot / worktreeRoot / branch
createdAt / sessionCreatedAt"] --> PUT["binding.put(sessionId, record)"]
PUT --> SNAP
SNAP --> WRITES["写盘串行链"]
WRITES --> TMP -->|"rename 原子覆盖"| FILE
FILE -->|"install 时同步读 + parseTable(损坏回落空表)"| SNAP
SNAP -->|"revision()"| ROUTE["GET /api/dsh-worktree-sidebar/bindings"]
ROUTE --> CLI["客户端 BindingState
revision + worktreePath"]
CLI -->|"revision 单调不减才更新"| SRC["改写源:byId[sessionId].cwd"]
HEAL["自愈三判据
目录存在 / 会话身份 / git 归属"] -->|"只认正面证据才 drop(revision + 1)"| PUT
CACHE["git 归属缓存
正结果 30s / 上限 256;负结果不缓存"] -.-> HEAL
CHAIN["持久面读数 scopeChain
storedReads / storedFailures"] -.-> HEAL
```
---
## 4. 技术架构(TA)

> 图源 `diagrams/worktree-sidebar-ta.html`。上图给完整载体:patch + profile 挂载、type-only 依赖面、
> 构建链与注入、门禁/测试面、耦合点与命门;本节末的 mermaid 只给装配与构建链路逻辑。
### 4.1 挂载方式:cordis patch + profile
插件**不修改 DSH 源码**,经 `cordis.patch.yml` 的一行 `insert` 挂进 `dsh web` profile 名册
(`cordis.patch.yml:7-9`);安装动作是 `dsh plugin --profile web add @wingsky-1/dsh-worktree-sidebar`
([包 README](../../packages/dsh-worktree-sidebar/README.md) 的「安装」),bundle 层只在启动时组合,
改完需重启一次 `dsh web`。
- 宿主端入口 `exports["."]` → `lib/index.js`(`package.json:13-17`),在 Node 进程跑 `apply(ctx)`;
- 浏览器端入口 `exports["./client"]` → `lib/client.js`(`package.json:18-21`),
在浏览器跑 `apply(ctx)`,并声明 `dsh.client.platform = "web"` 与
`dsh.client.inject = ["@deepseek-ai/dsh-client-ui-sidebar-right"]`(`package.json:29-34`);
- 宿主 `apply` 装配五域后用 `ctx.effect` 注册卸载链(`src/index.ts:58-72`);
- 组合层配置只有一个总开关:`WorktreeSidebarConfig.enabled === false` 时一律不接管、不注册工具、不挂路由
(`src/index.ts:41-45`、`:98`)。
### 4.2 依赖面:全 type-only + catalog 锁版
- `src/` 内**没有任何** `@deepseek-ai/*` 的运行时 import(实测见 §5 复现命令 exit 1 = 无匹配);
官方类型一律 `import type`:**宿主上下文 / 服务**类型只在组合根与三个宿主适配层
(`src/index.ts:8-10`、`src/server/host/typert.ts:11-13`、`src/server/host/sessions.ts:13-14`、
`src/server/host/agents.ts:7`),各域只在 `deps.ts` 与工具实现里引官方**工具 / 路由类型**
(`src/server/tools/deps.ts:2`、`src/server/tools/impl/service/index.ts:11`、
`src/server/tools/impl/protocol/index.ts:10`、`src/server/api/deps.ts:2`)。
- 六个官方 peer 全走 `catalog:`(`package.json:52-59`),catalog 锁
`0.1.5-rc.1`(`pnpm-workspace.yaml:16-29`);六个 peer 全标 `optional: true`
(`package.json:60-79`),实际提供方是宿主。
- `@deepseek-ai/dsh-session` 只在 devDependencies(纯类型需求,`package.json:48`)。
- 客户端第三方库走构建期内联,发布物自包含、运行时零 npm 依赖
(`docs/architecture/README.md` 的「通用机制」表「发布物自包含」行)。
### 4.3 构建链与构建期环境注入
`pnpm --filter @wingsky-1/dsh-worktree-sidebar build` 的顺序是
清理 → `tsc` → `bundle-host`(`package.json:8`):
1. `tsc -p tsconfig.json` 产出 `lib/**`(`outDir: lib`,`packages/dsh-worktree-sidebar/tsconfig.json:4`);
2. `scripts/build/bundle-host.ts` 用 esbuild 把宿主端打成自包含的 `lib/index.js`(`--platform=node`),
并按 `dsh.bundle.bannerJs` 注入 `createRequire` 垫片(`package.json:25-28`;
`scripts/build/bundle-host.ts:81-95`);
3. 检测到 `src/client/index.ts` 时走共享预设 `buildClient` 产出 `lib/client.js`,随后递归清理游离产物
(`scripts/build/bundle-host.ts:110-160`、`:213-216`);
4. **`ROUTES` 构建期注入**:bundle-host 从宿主产物读 `mod.ROUTES`,经 esbuild `define` 注入
`__DSH_ROUTES__`,两端路由因此构建期强一致(`scripts/build/bundle-host.ts:132-150`;
宿主侧导出点 `src/index.ts:26-27`);
5. 客户端对注入缺失做 `typeof` 守卫并回落 `src/shared/contract.ts` 的常量——
`declare const` 只活在类型层,非 bundle 环境(源码直引、单测)里裸引用会当场 ReferenceError
(`src/client/index.ts:26-35`)。
### 4.4 门禁与测试面
- 包内测试脚本 `node ../../scripts/test/run-vitest.mjs --min 17`(`package.json:9`),
对应 `test/` 下 **17 个测试文件**(`test/unit` 13 个 + `test/integration` 4 个,实测见 §5)。
- 分层口径与 [ARCHITECTURE-METHOD.md](../ARCHITECTURE-METHOD.md#user-content-8-测试三层与导入面矩阵) 一致:
单元测试白盒直连 `src` 模块(如 `test/unit/git-inspect.test.ts:22` 逐字断言 argv、
`test/unit/scope.test.ts:221` 打自愈与解析),集成测试经组合根与真 git / 真临时目录
(`test/integration/apply-lifecycle.test.ts:209`、`git-real.test.ts:31`、`tools-real.test.ts:86`、
`binding-store.test.ts:46`)。
- 仓库级闸:本包改文档走 `pnpm docs:check`,改源码走 `pnpm gate:pr`(含全仓 build / test / typecheck、
`lint` 复杂度门禁与 `format:check`),详见 [AGENTS.md](../../AGENTS.md) 的门禁分层表。
- **本地 `gate:*` 全绿不等于 CI 绿**:变异只在 PR 上按命中切片强制跑
([AGENTS.md](../../AGENTS.md):86-89)。
- 四条**界面语义**进不了任何自动门禁(`gate:*` 里没有浏览器),只能靠隔离真机验证;
其中 2 条已实测(见下),其余见 §4.7。
- **隔离环境真机读数**(临时 `DSH_HOME` + 独立 profile + 独立端口 + 独立浏览器,走官方 verify-isolated 一键脚本):
1. 隔离实例装上并加载本插件:构建 exit 0;`curl /api/dsh-worktree-sidebar/health` →
`HTTP 200 {"ok":true,"revision":0,"scopeTakeover":"live"}`;`dsh.log` 无报错行。
2. 预置绑定后 Files 页签根 = worktree 目录并列出其文件(会话 cwd 是另一个目录,前后对照成立)——
界面语义「打开 / 刷新页签后列 worktree 内容」首次真机证据。
3. worktree 落在主仓库之外、且也在 `/mnt/ssd/worktree` 之外时同样展示(「任意路径」的真机证据)。
4. `sessionCreatedAt` 与会话不一致 → 端点 `revision 1→2`、`bindings.json` 落盘为
`{"version":2,"revision":2,"bindings":{}}`、`worktreePath:null`、UI 回落 cwd(自愈首次真机闭环)。
证据归档:[819-isolated-bound-worktree-tree.png](../../packages/dsh-worktree-sidebar/docs/archive/819-isolated-bound-worktree-tree.png)(判据 2)、
[819-isolated-identity-mismatch-fallback.png](../../packages/dsh-worktree-sidebar/docs/archive/819-isolated-identity-mismatch-fallback.png)(判据 4)、
[819-isolated-verify-report.md](../../packages/dsh-worktree-sidebar/docs/archive/819-isolated-verify-report.md)(完整报告:结论表 / 临时目录与端口 / 覆盖缺口)。
### 4.5 兼容性耦合点(改版时唯一会失效的地方)
| 耦合点 | 读取方式 | 证据 |
|---|---|---|
| 宿主 `typert` 的 `workspaceFileScope` 键 | `lookups.get/configure/subscribe`;`configure` 前捕获官方 `resolve`,miss 时委托它 | `src/server/host/typert.ts:25-53`;`scope/impl/service/index.ts:202-240` |
| 客户端键控座位 `sidebar.right.pane.tab` 与 `StoredEntry` 形状 | `slots.entries / entriesOfSlot / register / subscribe / onEntryError`,复用 `component/inject/store/locale` | `src/client/shared/ports.ts:19-77`;`takeover.ts:133-178` |
| 会话 hook 源契约 `{ getSnapshot(), subscribe(fn) }` 且引用稳定 | `hooks.sessions` 改写源;`getSnapshot` 同快照 + 同路径回同对象 | `ports.ts:88-92`;`source.ts:33-45` |
| 会话 header 的 `parentSession`(活)与 `sessionPersistence.stat`(已结束) | 两个来源都要;持久面可选,缺席时退 live-only | `src/server/host/sessions.ts:20-33`、`:47-78` |
| 官方客户端包 `@deepseek-ai/dsh-client-ui-sidebar-right` 必须在场 | 不在时整体不激活(抓不到官方 `files` 类型 id 就零注册) | `package.json:29-34`;`src/client/index.ts:127-133`;`takeover.ts:186-195` |
任一点失效时的行为统一是**零注册 / 退回官方**:可感知地什么都不做,好过静默显示错的地方
(`src/client/index.ts:121-124`;`takeover.ts:152-171`)。
### 4.6 命门清单(本页最该记住的七条)
1. 只换**视图根**:cwd 与 `@` / `present` 语义一律不动(`source.ts:62-75`;`register/index.ts:21-29`)。
2. 会话 id **不是**跨进程稳定键(进程内计数器),所以登记必须带身份凭据(`scope/deps.ts:78-86`;
`binding/impl/model/type.ts:28-32`)。
3. 「读不出来」永远不等于「不存在」:三条摘除判据都只认正面证据(`own/index.ts:37-48`、`:82-92`、`:97-112`)。
4. 接管是**遮蔽**而不是顶替:类型表不动,官方条目留在原始账上(`takeover.ts:16-22`、`:101-110`)。
5. 播种时机必须覆盖 `start`,且首帧不能被一次无超时的 fetch 挡住(`inject.ts:133-147`)。
6. 本地 `gate:*` 全绿不等于 CI 绿:变异只在 PR 上按切片强制跑([AGENTS.md](../../AGENTS.md):86-89)。
7. **`` 之后的位置参数仍会被 git 重新解析成选项**:`--` 与 `--end-of-options` 都只保护紧邻的位置参数,
实测起点传 `-f` / `--force` 会 rc=0 却把起点**静默忽略成 HEAD**(报成功、建错基线)。所以起点必须先做
形态 guard(`-` 开头直接拒),再用 `git rev-parse --verify --quiet "^{commit}"` 归一化成 SHA 才进 argv
(`git/impl/inspect/index.ts` 的 `addWorktreeArgs` 注释;`tools/impl/create/index.ts` 的 guard 与归一化)。
### 4.7 遗留风险与未验证项
- **真机启动序**:本次隔离实测观测到 `scopeTakeover: "live"`(provider 已注册、接管成功,见 §4.4);
仍缺**等待态与让位态**(`waiting` / `abandoned`)的真机观察(`scope/impl/service/index.ts:202-240`)。
- **重启后 id 复用**:判据已补到「真文件 + 真组合根」(`apply-lifecycle.test.ts`),
本次真机又补上了「凭据不一致 ⇒ 摘登记 + 落盘 revision + UI 回落 cwd」的完整闭环(§4.4 判据 4);
仍缺的是**跨进程重启**(新进程的 `session-1` 读到上一进程留下的登记)那一次观察。
- **dev HMR 重注册**只有单元级判据(官方组件换对象后重捕 + 当值复检),dev server 真机那一次**未验证**
(`takeover.ts:180-219`)。
- **界面语义部分已实测**:判据「打开 / 刷新页签后列 worktree 内容」与「未登记一侧行为一致」已实测;
**仍缺**:文件预览内容、继承(fork / 子 agent)的真机观察、未安装本插件一侧、双主题 / 窄屏几何
(覆盖缺口清单见 [819-isolated-verify-report.md](../../packages/dsh-worktree-sidebar/docs/archive/819-isolated-verify-report.md))。
- **两条实测操作约束**(都会让验证静默失败):
1. `patchReload: live` **不会重装本插件**(改 `cordis.patch.yml` 后 `revision` 仍为 0)
⇒ 绑定必须在 `dsh web` 启动前落盘。可行路径:先起一次实例取会话 id + `createdAt`
(会话身份跨重启稳定)→ 写 `$DSH_HOME/@wingsky-1/dsh-worktree-sidebar/bindings.json` →
用同一个 `DSH_HOME` 重启。
2. 一键脚本 `--keep` 场景下 SIGTERM **不带走 dsh 子进程**,会占住端口使下次启动静默失败;
隔离验证必须显式确认端口已释放。
- 插件 `logger.warn` 在无 exporter 的组合里**不落盘**,现场痕迹只有 `/health` 的
`scopeTakeover` / `scopeChain` 两个读数(`api/impl/handlers/index.ts:44-53`)。
- 归属 `unknown` 时保留登记是**故意的取舍**:代价是「目录还在但已不是工作树」时文件树报错,
可感知而非静默(`own/index.ts:82-92`)。
- 官方 `dsh-client-ui-slots` 的「最低者渲染」已被真机间接验证(判据 2 的树换根只有在影子条目当值时才会发生);
其源码行号证据仍以仓库内 catalog 锁版副本为准(§2.3)。
```mermaid
flowchart LR
SRC["src/**"] --> TSC["tsc -p tsconfig.json
产出 lib/**"]
TSC --> BUNDLE["scripts/build/bundle-host.ts
esbuild 打包 + 清理游离产物"]
PKG["package.json
dsh.bundle.patch / bannerJs / dsh.client"] --> BUNDLE
BUNDLE --> HOSTJS["lib/index.js
--platform=node 自包含"]
BUNDLE --> CLIJS["lib/client.js
构建期内联 + __DSH_ROUTES__ define"]
SRC -.->|"ROUTES 单一来源"| BUNDLE
BUNDLE -.->|"读宿主产物 mod.ROUTES"| CLIDEF["esbuild define:__DSH_ROUTES__"]
CATALOG["pnpm-workspace.yaml catalog
@deepseek-ai/* 0.1.5-rc.1"] -.->|"peer + 仅 import type"| SRC
PATCH["cordis.patch.yml
insert id=ui-dsh-worktree-sidebar"] --> PROFILE["dsh web profile 插件名册"]
PROFILE --> CORDIS["cordis 组合:宿主端与浏览器端各跑一半"]
HOSTJS --> CORDIS
CLIJS --> CORDIS
CORDIS --> APPLY["宿主 apply(ctx)
装配五域 + ctx.effect 卸载链"]
CORDIS --> CAPPLY["浏览器 apply(ctx)
installTakeover + ctx.effect"]
CAPPLY --> SHADOW["影子正文
priority = 官方 - 1"]
```
---
## 5. 图源与延伸阅读
- 图源归档(每视图一张,源 HTML 自包含内联 SVG;导出件由
`python3 scripts/lib/export-diagram-svg.py <源.html>` 生成,四张共用一套配色 / 字体 / 图例):
- BA:`docs/architecture/diagrams/worktree-sidebar-ba.html` → `worktree-sidebar-ba.svg`
- AA:`docs/architecture/diagrams/worktree-sidebar-aa.html` → `worktree-sidebar-aa.svg`
- DA:`docs/architecture/diagrams/worktree-sidebar-da.html` → `worktree-sidebar-da.svg`
- TA:`docs/architecture/diagrams/worktree-sidebar-ta.html` → `worktree-sidebar-ta.svg`
- 包 README(安装 / 验证 / 安全模型 / 已知限制):`packages/dsh-worktree-sidebar/README.md`。
- 结构方法论(六载体、暴露面与耦合收敛、依赖事实图 × 意图图、测试三层):
[ARCHITECTURE-METHOD.md](../ARCHITECTURE-METHOD.md)。
- 提案长文(各轮读数与遗留判据):`docs/proposals/worktree-sidebar.md`。
- 本文用到的复现命令(均实跑,exit code 见交付说明):
```sh
# 1. 确认 src 内没有 @deepseek-ai/* 的运行时 import(无输出 = 仅 type-only)
grep -rn 'from "@deepseek-ai' packages/dsh-worktree-sidebar/src | grep -v "import type"
# 2. 确认官方座位注册表的 priority 语义(最低者渲染、默认 0)
grep -n "lowest renders" \
node_modules/.pnpm/@deepseek-ai+dsh-client-ui-slots@0.1.5-rc.1_*/node_modules/@deepseek-ai/dsh-client-ui-slots/lib/index.js
# 3. 确认测试文件数(应等于 package.json 的 --min 17)
ls packages/dsh-worktree-sidebar/test/unit/*.ts packages/dsh-worktree-sidebar/test/integration/*.ts | wc -l
# 4. 文档与格式门禁
pnpm docs:check
pnpm exec prettier --check docs/architecture/ packages/dsh-worktree-sidebar/README.md README.md
```