# 架构 > 本文按代码实际结构写,不是按设计稿。凡是本文与代码冲突的地方,以代码为准,并请提 issue。 > 历史设计文档(交接文档、实现粗规划、规划、独立窗口复刻规格等)已归档到仓库外,见第 8 节。 ## 1. 两个半体 一个 DSH 插件包 = 一个 bundle row + 两个半体。两者没有共享模块,只共享字符串常量 (`lib/constants.js`)和一套 HTTP 契约。 | 半体 | 入口 | 由谁加载 | 运行环境 | |---|---|---|---| | Host | `lib/index.js` | Cordis,应用启动时加载一次 | DSH 主进程(Node ESM) | | Client | `lib/client.js` | `window.__ModuleLoader__.load({ id, factory })`,无构建步骤 | 渲染进程(浏览器环境) | Host 半体在 DSH 的 web 服务器上注册**一个**前缀路由 `/session-navigator/api`,所有端点都在 它下面(`lib/index.js:333-337`)。Client 半体往 DSH 的 UI 插槽里注册 5 个组件,并把浮窗 portal 到 `` 上的固定层。 ### 加载链路 ``` package.json ├─ dsh.bundle.patch = "./cordis.patch.yml" │ └─ - insert: [{ id: session-navigator, name: 'dsh-session-navigator', config: {} }] │ → Cordis 按包名 import,取 name / inject / apply │ (lib/index.js:109 name, lib/index.js:114 inject, lib/index.js:123 apply) └─ dsh.client.inject = "@deepseek-ai/dsh-client-ui-slots" dsh.client.platform = "web" → 渲染进程把 lib/client.js 当作 out-of-tree client 模块执行 ``` `cordis.patch.yml` 只做**增量 insert**,不覆盖任何已发布的行(补丁会替换目标行的整个 `config`,覆盖是危险的)。 ## 2. Host 半体:`apply(ctx)` 做了七件事 `lib/index.js:123-353`,顺序有意义: 1. **拿 webServer**(`:122-123`)。`inject = ["webServer"]` 是唯一硬依赖:没有 HTTP 服务器的 profile 就没有浏览器半体,此时直接 return,让 loader 跳过这一行。 2. **开数据目录**(`:127-138`):`resolveDataDir()` → `dataPaths()` → `openDocuments()`。 `openAt(dir)` 让迁移完成后能在**不重启**的情况下切到新目录。 3. **四个可选件**(`:140-184`): - `component`:浏览器组件状态机(Electron 运行时); - `detached`:独立窗口 helper 进程控制器(懒创建); - `bridge`:浮窗 ↔ 独立窗口的命令总线(内存); - `theme`:浮窗上报、窗口读取的调色板。 4. **组装 `api` 对象**(`:187-273`)。这是路由处理器唯一的依赖入口: `ctx / dataDir / paths / documents / migration / reopen / component / detached / bridge / theme / capabilities / reader / content / indexBuilder / summarizer`。`dataDir` 与 `paths` 是 getter,迁移后自动指向新值——内容索引的缓存文件也跟着走(`content` 用 getter 拿路径,见 api.md §4.14)。 `summarizer` 在字面量之后单独赋值(`:250` 附近):它要引用正在定义的 `api`,而且它的 `summaries` / `settings` 必须**每次现读** `documents`——迁移会重新赋值那个变量, 闭包里抓一份旧引用就会写进旧目录。 5. **启动自愈**(`:275-296`):`reconcileTrash(api)` 收尾"索引已写、payload 未搬"的中断; `reconcileMoves(api)` 做同一件事的移动版(payload 已搬到目标、注册表还没跟上); `component.sweep()` 删除上一次卸载时被占用、删不掉的目录。三者都 best-effort、不 await。 6. **注册前缀路由**(`:298-306`),并把返回的 dispose 挂到 `ctx.effect`。 7. **退出时收尾**(`:308-317`):插件被禁用/重载时 `api.detached.stop()`,否则 helper 进程会 活过拥有它的代码。 ## 3. 一次请求的生命周期 `handle(api, req, res)`(`lib/index.js:2184-2231`): ``` routeOf(req.url) // lib/http.js:88 剥掉 /session-navigator/api ↓ null → 404 bad-request compileRoute 匹配表 // lib/index.js:2140 段数相等 + :param 解码 ↓ 无 → 404 no route for 迁移守卫 // 非 GET/HEAD 且非 /datadir* → migration-in-progress assertSameOrigin(req) // 非 GET/HEAD:跨站 / 非 JSON → 403 / 415 readJsonBody(req) // 非 GET/HEAD,上限 8 MiB(lib/http.js:57) handler(api, req, res, query, params) ↓ 正常 → ok(res, result) // { ok: true, result } ↓ 抛错 → statusFor(code) // lib/index.js:2310 错误码 → HTTP 状态 fail(res, status, code, message) ``` 三条不变量: - **信封唯一**:`{ ok: true, result }` / `{ ok: false, error, code }`(`lib/http.js:22-48`)。 客户端只对 `code` 分支,`error` 只给人看。 - **错误码直通**:DSH 原生错误带的命名空间码(`workspace/name-conflict`、 `session/title-invalid`)原样透出,不翻译,避免多一层会漂移的映射(`errorCodeOf`,`lib/index.js:2274`)。 - **SSE 例外**:`GET /bridge/events` 自己接管响应,`handle` 在 `headersSent` 时不再写信封 (`lib/index.js:2218`)。 - **写请求必须同源**:没有 token,所以靠 `Sec-Fetch-Site` 与 `Content-Type: application/json` 这两个浏览器强制的信号拒绝跨站写(`lib/index.js` 的 `assertSameOrigin()`,见 [api.md](api.md#31-写请求的同源约束))。 ## 4. 分层 ``` lib/ ├─ index.js 2417 插件装配 + 路由表(76 条)+ 错误映射(唯一编排层) ├─ http.js 101 信封、body 解析、URL 切分 ├─ constants.js 39 API_PREFIX / PLUGIN_ID / REPO_URL / FIXED_NODES / WORKSPACE_NODE_PREFIX ├─ capabilities.js 73 Cordis 服务的防御式探测 ├─ paths.js 365 数据目录解析、`.location` 指针、原子写临时目录清理 ├─ fs.js 52 存在与列举:宿主与组件安装共用的两个判断 │ ├─ sources/ 读 DSH 的数据(只读,除 move 的受控重写) │ ├─ heads.js 233 L2:sessions 目录遍历(按实际版本名取最新日志,并发 8,结果排序) │ ├─ projcache.js 228 L1:storages/session_projcache 的每会话小 JSON │ ├─ workspaces.js 127 workspace registry:优先活服务,回退 storages/workspace.json │ ├─ logfacts.js 154 日志只回答一件事:这个会话有没有过一轮对话(空白会话),按 path|size|mtime 有界缓存 │ ├─ live.js 122 哪些会话真的在跑:只信 ctx.agents 的 status,绝不从 ctx.sessions 或日志推断 │ ├─ artifact.js 227 会话文件路径编码/解码、读日志、重写 header │ ├─ content.js 389 会话内容索引:按 path|size|activityAt 判失效,逐份构建、可续查,总是落盘 │ └─ reader.js 313 三源合并 + TTL 缓存策略(/bootstrap 的数据源) │ ├─ domain/ 纯规则,无 IO,可直接单测 │ ├─ sessions.js 310 合并三源 → 扁平会话列表(header 定身份,cache 只填空) │ ├─ tree.js 477 树投影:workspace 节点 + 固定节点 + 用户文件夹 │ ├─ tree-rules.js 487 规则表 R1–R5 的唯一实现(accepts / scope / ref 装饰) │ ├─ folders.js 335 新建/重命名/移动/删除文件夹(改 tree draft) │ ├─ shortcuts.js 259 快捷方式:引用而非移动 │ ├─ annotations.js 276 标签、备注、收藏/待办 │ ├─ settings.js 439 设置文档形状与归一化(搜索索引只剩 indexIdle 一个开关) │ ├─ trash.js 210 回收站条目形状、恢复阶梯 │ ├─ subagents.js 107 子智能体绑定:isBound / descendantsOf / withDescendants / boundIds │ ├─ export.js 681 日志 → Markdown / JSONL(surfaceOp 与压缩只报告、不过滤) │ ├─ summaries.js 763 AI 字段的提示词契约、回答解析、预算、花费账本、待补清单(纯函数) │ └─ session-state.js 68 单个会话的布尔位(给 DSH 自己的按钮用,含 subagent) │ ├─ store/ 自己的持久化 │ ├─ sidecar.js 238 原子写 + revision + 串行化 + mtime/size 缓存 │ └─ documents.js 151 五份侧车文档的注册表 │ ├─ host/ 需要动文件系统或跨进程的操作 │ ├─ actions.js 701 改名/派生/归档/置顶 → 调 DSH 自己的服务 │ ├─ trash.js 496 删除/恢复/清空的字节搬运 │ ├─ move.js 777 跨 workspace 移动/复制(重写 header cwd + 换登记) │ ├─ move-journal.js 101 移动的意图日志:崩在两次写之间时,启动收尾而不是留下孤儿 │ ├─ datadir.js 367 数据目录检查与七步迁移 │ ├─ bridge.js 170 命令环形缓冲 + 游标 │ ├─ session-sync.js 238 跟 DSH 保持同步:订阅 session/* 与 agent/* 事件,合并成一次刷新 │ ├─ storage-watch.js 146 盯 storages/ 目录的 mtime:DSH 改了投影缓存就重读,事件漏了也有兜底 │ ├─ theme.js 103 调色板令牌白名单与校验 │ ├─ native-reveal.js 107 koffi → `SHOpenFolderAndSelectItems`,"在文件夹中显示" │ ├─ reveal-agent.js 203 常驻 PowerShell:省掉每次 reveal 一秒多的进程启动 │ ├─ update.js 210 更新检查:版本比较、GitHub release 解析、5 分钟缓存 │ ├─ open-link.js 161 链接白名单(repo / star / issues / releases)→ 系统浏览器(带降级链) │ ├─ fsutil.js 22 psQuote / dshHomeOf:两条 PowerShell 命令行共用的引用与 home │ ├─ maintenance.js 229 缓存清理:内存三件 + `.tmp` / 下载目录 / staging,跳过在装的组件 │ ├─ summarize.js 787 AI 字段队列:模型目录、路由、单飞 worker、重试与 token 落账(调 DSH 的 llm 服务) │ └─ reset.js 209 清除数据:五份侧车 + 回收站净内容 + 隔离区 + `.tmp`,`DELETE` 把关 │ ├─ component/ 可选 Electron 运行时:下载一份,或借用别人装好的 │ ├─ layout.js 236 路径、版本、SHA-256、镜像、体积 │ ├─ state.js 182 state.v1.json 生命周期机 │ ├─ downloader.js 339 curl 流式下载(断点续传、镜像、进度) │ ├─ install.js 558 校验 → 解压 → 原子发布;helper app 的两份落点 │ ├─ borrow.js 258 只读扫描别的插件已装的 Electron(有界 readdir) │ └─ manager.js 790 单飞状态机(install/cancel/remove 共享一把锁)+ scan() │ ├─ detached/ │ ├─ launcher.js 424 Host 侧:spawn / 重试 / 聚焦 / 关闭、net 控制通道 │ └─ main.cjs — 跑在 Electron 运行时里,拥有 BrowserWindow │ └─ dsh/ ├─ home.js 39 DSH home 解析(与 kernel 同规则) └─ zstd.js 197 zstd 帧扫描器(只为跳过帧,不解码) ``` **依赖方向是单向的**:`index` → `host` / `store` / `domain` / `sources`; `host` → `domain` + `store`;`domain` 不 import 任何 IO 模块;`sources` 不 import `domain` 的规则 (只 import 纯合并函数)。`domain/*` 全部是"传入 draft、返回 draft",所以测试不需要文件系统。 ### 4.1 唯一一次向外发请求:AI 字段 AI 字段(标题 / 描述 / 标签)是插件里**唯一**会把会话内容送到模型的地方,也因此是唯一需要 `llm` 服务的功能。 - 依赖是**可选**的:`inject` 仍然只有 `webServer`(api.md §2),`llm` 与 `sessionController` 都用 `getService(ctx, …)` 现取。把它们写成硬依赖会让没有模型服务的 profile 整个插件都不加载, 而树的浏览、标签、导出都不需要模型。 - 模型目录优先问**宿主自己那一套**:`sessionController.modelCatalog()`(与 dsh-orb 同源), 拿不到才回退到 `llm.listProviders()` / `listModels()` / `resolveModelInfo()`。理由是别在宿主 已经维护的目录之外再维护一份判断。 - 依赖方向没有破:`domain/summaries.js` 只拼字符串、解析回答与算账(纯函数,可单测), `host/summarize.js` 才碰 `LLM` 与文件;客户端只认 `state` 里的几个数字。 - 队列**单飞**:一次一个请求,因为并发写同一个 `summaries.v1.json` 只会换来 `revision-conflict`, 而写字段不是越并发越快的东西——它是用户在等的一小段文字。 - 每个请求带 180 秒的 `AbortSignal.timeout`:一个卡住的 provider 不该让队列永远停在 "正在生成 3/12"。**不设 `maxTokens`**:推理模型把思考算进同一个预算,小上限买到的是空正文 而不是短回答(自带的 `dsh-pet` 插件在 `whisper.ts` 里记下了同一个坑);提示词只要三个短字段, 时长由超时兜住。 - 请求**声明零个工具**(`tools: []`):这是一次文本改写,给了工具模型就会去调它。有些模型 即使没有工具也会回一个 tool-call 块——那种块被当作"没有正文"处理而不是错误,旁边的文字照收; 两次都没有正文才报 `llm/tool-only` / `llm/empty`。系统提示词只有本插件这一份,DSH 的 agent 提示词不参与(pi-ai 适配器的 `splitSystemPrompt()` 只拼 `options.system` 与请求里的 system 消息)。 - 标签**先问已有的**:`existingTags` 读 `tagVocabulary()`,最多 40 个进系统提示词,回答里的标签再由 `alignTags()` 按现有写法归一。理由是同义词比没有标签更糟——"导出"/"会话导出"/"导出功能"会让选择器 长出十几行意思一样的词,而这个库正是下一轮生成要参考的"已有标签"。 - 答案是**读出来的**,不是**猜出来的**:提示词的契约就是一个 JSON 对象,`extractJsonObject()` 剥掉 围栏取第一个 `{` 到最后一个 `}`,`normalizeFields()` 逐字段校验;缺字段就重来一次,而且第二次 请求会附上一句"上次的回答不是那个 JSON 对象,不要调用工具"(原话重复只会得到原样的错); 两次都缺则 `ai/bad-answer`,一个半成品都不写——半成品会让这个会话永远不再重试。DSH 的 `GenerateOptions` 没有 response-format 字段,所以这里能给的最硬保证就是"校验 + 重试 + 拒绝"。 - `summaries.v1.json` 是**文档**不是缓存(见 data-directory.md §1.1):它是花过钱换来的文字, 所以坏文件进 `quarantine/`、不随缓存清理消失,也不因为会话被删而删(钱已经花了)。 ### 4.2 一件只能 host 做的事:更新检查 设置页「关于」组的三个入口里有两个**不能**由客户端完成,这是它们单独放在 `host/` 的原因: - **更新检查**:客户端是个普通网页,直接问 `api.github.com` 会撞 CORS;独立窗口里 `window.open` 与 `fetch` 也被主进程限定。所以由 `host/update.js` 代问、代比较, 结果缓存 5 分钟——设置页每打开一次就问一次的话,未登录的 60 次/小时会很快用完。 - **打开链接**:`host/open-link.js` 用系统浏览器打开,但**只认四个名字** (`repo` / `star` / `issues` / `releases`),不收 URL。这条 API 挂在 DSH 的端口上、没有 token, 接受任意 URL 就等于送给每个网页一个"用系统浏览器打开这个地址"的按钮。 打开本身按平台走一条降级链(Windows:`rundll32 url.dll,FileProtocolHandler` → `Start-Process`, macOS `open`,Linux `xdg-open` → `gio open`),认的是子进程**退出码**而不是"spawn 成功"—— 后者会让一个起得来、却打不开任何东西的壳子也报成功,用户看到的就是"点了没反应";全链失败才 报错,且错误消息里带着那个 URL,面板原样显示,至少还能复制。 第三个(缓存清理,`host/maintenance.js`)固然是 host 的活,但它有一条同样值得记住的边界: 只清**插件自己的**缓存(内存里的三处与 `.tmp` / 下载目录 / staging),`quarantine/` 与 DSH 自己的 `storages/session_projcache` 一律不碰;组件正在下载/校验/解压时跳过那两项。 浏览器缓存也不归它管:面板的 `/ui/*` 本来就不缓存,其余属于 DSH 的渲染进程和独立窗口。 ### 4.3 唯一不可逆的操作:清除数据 设置页最后一组(「危险操作」)只有一个按钮,它删的是用户自己的东西:**虚拟树**(文件夹、工作区 摆放与顺序)、标签、备注、设置、摘要与它的花费账本、回收站索引,以及回收站里的**会话目录本体**。回收站是 move 不是 copy(`host/trash.js`), 那些目录曾经就是 DSH 的会话目录——这一步没有"再点一次还原"可退。 所以判断全在 `host/reset.js`,客户端只把用户打的字原样送上去: - 字面上的 `DELETE`(去掉首尾空白)是**第一件**检查的事,不是删到一半才检查; - `api.dataDir` 必须过 `isUsableDataDir()`——越是要清空一个目录,越要先确认那是它自己的; - 它清空的三个目录(`trash/` / `quarantine/` / `.tmp/`)保留目录本身:插件下次还得往里写; - 浏览器组件不在其中:那是安装,不是用户数据,它的按钮是清理缓存; - 删完立刻 `invalidateMemory(api)`:面板每张列表都是 memo 过的,不抖掉的话,被删掉的文件夹 会一直答到进程重启。 页面上的红按钮与输入框只是礼貌。这条路由没有 token,真正的门必须在 host: ```js if (typed !== RESET_CONFIRMATION) throw codedError("confirm-required", "…"); // 400,一个字节都没动 if (!isUsableDataDir(dataDir)) throw codedError("reset-refused", "…"); // 409 ``` ## 5. Client 半体:`apply(ctx)` 注册了什么 `lib/client.js:14190-14363`。`inject = ["slots", "locale", "uiWorkspace"]`(`:13067`)。 **插槽**(order 决定与 DSH 自带条目的相对位置): | 插槽 | id | order | 组件 | 作用 | |---|---|---|---|---| | `sidebar.footer.action` | `session-navigator-footer` | 40 | `FooterAction` | 侧栏底部入口,开/关浮窗 | | `settings.section` | `session-navigator` | 510 | `SettingsSection` | 设置页分组(label 是 thunk,跟随语言);组序 打开方式 → 列表 → 会话界面 → 浮窗 → 浏览器组件 → 数据目录 → 关于 → 危险操作 | | `settings.onboarding` | `session-navigator-opener` | 900 | 匿名 | 只为拿到 `openSection` 后立即 `complete()`,不留 UI | | `conversation.session.header.actions` | `session-navigator-header` | 60 | `SessionHeaderActions` | 会话标题栏五个按钮(样式跟随设置 `shell.header.style`) | | `sidebar.workspaces.session.menu.item` | `session-navigator-rows` | 500 | `SessionMenuItems` | 会话行 "…" 菜单两条 | **effect**(随插件卸载自动清理): - 主题上报:`reportTheme()` + `MutationObserver` 监听 `documentElement` 的 `class` / `data-theme` / `style`(`:12913-12926`); - 桥订阅:`subscribeBridge(...)`,处理 `open-session` / `new-session` / `open-settings` / `reveal` / `window-closed`(`:12928-12985`); - 词典注册:`ctx.locale.register(NS, { zh, en })`(`:12986`)。 **导出**:`apply` / `inject` / `panelStore` / `SettingsSection`(`:13066-13070`)。 浮窗本体是 `Panel`(`lib/client.js:6356`,单函数约 5500 行,内部按功能分段), 它被 portal 到 `` 上的固定层——侧栏有 `overflow: hidden`,原地渲染会被裁掉 (`lib/client.js:879` 的注释)。 ### 5.1 面板与独立窗口的双语:两套机制,一条链路 按「这一屏的标签是不是固定的」分工: - **设置页**用 DSH 的命名键字典(`t("setStarHint")`):一屏固定表单,键名可读比字面量值钱。 - **面板与独立窗口**用 `T(中文原文, params)`(`lib/client.js:2196` 起的注释块下方):键就是中文原文。 三百多条散在渲染树里的短标签,命名键只会多一层间接;而且改字时字典未命中的后果是 「显示中文」而不是「在页面上印出 key」。插值统一 `{0}` / `{1}`,和设置页的 `{size}` 风格互不干扰。 `T` 读模块级的 `panelLang`,而 `Panel` 每次渲染都从 props 的翻译器刷新它: `panelLang = typeof t === "function" && t("langCode") === "en" ? "en" : "zh";`(`lib/client.js:6359`)。 语言开关一动,DSH 用新的 `t` 重渲染插槽,下一帧就是新语言。代价是模块级表(`FIXED_META`、 `COLUMNS`)的 `label` 必须写成 `get label() { return T("…"); }`——直接赋值会在模块加载时求值、 永久冻在中文。英文表在 `panelDict()`(`lib/client.js:13228`)。 宿主发来的名字也属于同一类,只是来源不同:固定目录(所有会话 / 未分组 / 待办 / 收藏 / 回收站 / 根目录) 的 `name` 在 host 那边是标识符、写法就是中文,所以 `nodeLabel()` 不印它,而是取 `FIXED_META[node.id].label`。默认工作区连固定 id 都没有——注册表里它的标题是 `default-workspace`, host 画成「默认工作区」——节点因此带一个 `default: true` 标志,`nodeLabel()` 看到标志就取 `T("默认工作区")`;用户自己改过名的工作区保留原名,这也是判据用标志而不是字符串的原因。 独立窗口是另一侧:`assets/detached` 是静态页 + 冒充 DSH 的 shim,它读不到 DSH 的语言开关, 也不会跟着 DSH 重渲染。所以语言由面板在 `POST /detached/open` 里报给 host(`uiLang()`, `lib/client.js:2216`),经控制器 `lib/detached/launcher.js` 放进 helper 的 env(`DSH_SE_LANG`), helper 拼进页面 URL(`?lang=`,`lib/detached/main.cjs` 的 `pageUrl()`),shim 从 URL 读出 `LANG`, 之后每句都过 `L(zh, en)`。中途切换语言时控制器用控制通道广播 `{ type: "lang" }`,helper 直接 `win.loadURL()` reload——字典是页面加载时定死的,reload 是唯一能让新字典生效的方式。 `index.html` 仍然随中文首发(HTML 在任何脚本之前就被解析完),英文窗口的标题与启动语由 shim 事后改写。 `test/panel-copy.test.mjs` 守着这条链的可见后果:`T()` 的每个键都在表里、表里没有空值或中文、 占位符两边一致,以及渲染树里不许有裸中文字面量(注释与样式表除外)。 ### 5.2 列表的「带」:先切数据,再画标题 列表不是一棵树长出来的,是一串**带**(band)。`ListPane` 先把行分组,再统一画成 `button.grp`(标题行:折叠箭头、图标、名字、计数)+ 该带的行,行本身由 `rowsOf()` 生成: ```js groups.push({ key, title, icon, count, rows }); ``` 带是渲染前的数据结构,不是 CSS 分组,「这一带有什么」和「这一带画不画」因此在同一个对象里 回答。带按固定优先级排:置顶、搜索/筛选结果、特殊文件夹、容器(文件夹 / 工作区 / 快捷方式)、 `所有会话` 的行、已归档(`lib/client.js:4599-4700` 的九处 `groups.push`)。 `所有会话` 在没有搜索词、没有筛选时不是一条平带,而是**一条带一个工作区**: `workspaceBands(sessions, tree)`(`lib/client.js:3962`)按 `session.workspaceId` 分桶, 标题取会话视图里的 `workspaceTitle`——跟树画的是同一个名字,视图过期时回落到 id; 顺序用 DFS 从整棵树取(进到 workspace 节点就记位置、不再下钻,所以拖进用户文件夹的工作区 仍留在文件夹的位置上),树不认识的工作区排在已知之后,没有工作区的沉到最末。 搜索与筛选一旦生效就退回单带(`lib/client.js:4672` 的 `byWorkspace`):那些列表回答 「哪几行匹配」,每行一条工作区标题只是复读。 折叠只改画不改数据。`foldedBands` 是 `Panel` 的 `Set` 状态(`lib/client.js:6483`,初值来自 布局记忆),键是 `scope + "\u0000" + bandKey`(`lib/client.js:4337`)——同一个「会话」带在 根目录和某个工作区里是两个开关,否则折一次全站没有行。折起来的带在**建行之前**短路: `rows: bandFolded("normal") ? null : rowsOf(normal)`(`lib/client.js:4690`)。所以不是渲染完 再藏起来——被折的行既不在 DOM 里,也不进框选,删不掉看不见的东西。状态随布局记忆落盘 (`foldedBands: [...foldedBands]`,`lib/client.js:6672`)。 ## 6. 数据流 ``` ┌─ L2 heads(sessions 目录 + zstd 第一帧) ← 真相:存在性、id、cwd reader.snapshot() ←───┼─ L1 projcache(每会话小 JSON) ← 便宜:标题、时间、计数 └─ workspace registry(活服务 → 文件) ← 归属、归档、置顶 ↓ 合并(domain/sessions.js) ↓ 叠加侧车(tree / annotations / trash index) GET /bootstrap → 一次往返把面板需要的全部东西给客户端 ``` 客户端**不重算任何规则**:`tree-rules.js` 在节点上装饰 `accepts` / `scope` / `ref` / `hasWorkspace`,客户端只读这些字段、只沿 `children` 走两件事(子树、父链)。 `test/tree-mirror.test.mjs` 守着这条线:客户端可以留一个"拖拽时先灰掉"的谓词,但不能有第二套规则表。 归属(`workspaceId`)在 `domain/sessions.js` 里定,且**沿父链继承**:DSH 的 `workspace.sessionIds` 只登记用户从工作区打开的会话,子代理是父会话创建的,永远不在里面。 所以一个 `isSubagent` 且没有登记的会话,按 `parentSession` 向上找最近一个已归属的祖先, 继承它的 `workspaceId` / `workspaceTitle`;整条链都没有归属时才退回到 `cwd` 与工作区 路径的**精确**比较。fork 不是子代理(只有 `parentSession` + `isSeeded`),不参与继承。 子智能体的**操作**也绑定在父会话上,规则同样只在一处:`domain/subagents.js`。它回答三个问题—— `isBound`(能不能单独动)、`descendantsOf`(父会话走了谁跟着走)、`withDescendants`(级联的实际 id 列表)。路由侧统一用 `assertStandalone()` 拒绝单独操作(`subagent-unsupported`),回收站三条 路由再用 `withDescendants()` 把整条委派链一起带走;归档不写子会话,靠 `sessions.js` 的 `archivedUp` 从祖先派生。客户端只消费 `session-state` 里的 `subagent` 位,不自己判断。 **状态不在 `/bootstrap` 里。** 一行会话是"进行中 / 等待审批 / 已完成 / 空闲"这件事,只有 DSH 自己的客户端知道:`uiSession.sessionStatus` 是一个 `HostObservable`,插件用 `useSyncExternalStore` 订阅它(`useDshSessionStatus`),再用 `withDshStatus` 把 `pendingInteraction` / `running` / `completionUnread` / 运行中的子会话数折进列表行,`statusOf` 按 DSH 的阶梯取第一档。这样侧栏和面板 看的是同一份内存状态,不必等下一次扫描,也不会各自推断出不同的答案——日志只回答"有没有过一轮", 不再回答"这轮是不是断了"。 ## 7. 硬约束(写代码前先读这一节) 1. **零运行时依赖**。`dependencies` 为空,`peerDependencies` 只有 Cordis(optional)。 React 由宿主提供,`assets/vendor/` 里的两个文件只给独立窗口用。 2. **无构建步骤**。`lib/client.js` 直接是浏览器可执行的文件;`node --check` 只证明能解析, 行为靠 `test/render-smoke.mjs` 与几个真实渲染测试兜底。 3. **`lib/client.js` 是单文件,且是刻意的**。模块加载器只给一个 factory,拆文件就要自己实现 模块系统。不要在"整理代码"时拆它。 4. **绝不背着 kernel 写 DSH 的存储**。标题、归档、置顶、派生都调 DSH 自己的服务 (`lib/host/actions.js`)。两个受控例外:`host/move.js` 移动会话文件并重写 header 的 `cwd` (必须同时走 registry 的写路径,否则下次启动会话就丢了),`host/trash.js` 把会话目录整体 移进/移出回收站(DSH 与插件对"它不在了"的理解因此天然一致)。两个例外都用"先记账、后动手" 兜住崩溃:回收站写 `pending` 条目,移动写 `move-journal.v1.json`,启动时各自对账。 5. **侧车可删**。`tree.v1.json` / `annotations.v1.json` / `settings.v1.json` / `trash/index.v1.json` 只存 DSH 不知道的东西;删掉它们不会丢会话。 `move-journal.v1.json` 是例外:它记录的是**正在进行**的一次移动,正常情况下是空文件(不存在)。 6. **服务探测全部防御式**。缺服务只降级一个功能(`/bootstrap` 的 `capabilities` 会告诉客户端 哪些不可用),不能让整个插件挂掉。 7. **副作用都要挂 `ctx.effect`**,否则热重载会漏掉清理(路由 dispose、主题 observer、桥订阅、 helper 进程)。 ## 8. 文档地图 | 文件 | 内容 | |---|---| | [README.md](README.md) | 索引、阅读顺序、三条最容易违反的约定 | | [api.md](api.md) | 全部路由、信封、错误码 → 状态、revision 契约 | | [data-directory.md](data-directory.md) | 数据目录解析、侧车文件、原子写、七步迁移 | | [tree-and-placement.md](tree-and-placement.md) | 树结构、R1–R5、节点 id、快捷方式、虚拟放置 | | [bridge-and-theme.md](bridge-and-theme.md) | 浮窗 ↔ 独立窗口的命令总线与调色板同步 | | [component.md](component.md) | 浏览器组件:下载或借用运行时、校验、安装、独立窗口进程 | | [testing.md](testing.md) | 测试矩阵与怎么跑 | 历史文档(已移出仓库,归档在 `C:\Users\mi\Desktop\project_old\docs-archive\`:`交接文档.md`、 `实现粗规划.md`、`规划.md`、`独立窗口复刻规格.md`、`重构方案-核心UI复用.md`、 `session-manager-prototype.html` 等)。它们描述的是开发过程中的计划,与当前代码有多处不一致, **不要照着它们改代码**——这正是这套文档改为"照代码写"的原因。