dsh-claude-style 0.7.2 → 0.9.0

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.
@@ -1,150 +1,226 @@
1
1
  # 架构决策记录
2
2
 
3
- 本文件记录本仓库**为什么**是现在这个样子:每条决策写背景、决定、代价,以及什么情况下允许重审。
4
- 操作手册看 README,硬性规则看 AGENTS.md,视觉令牌看 docs/STYLE.md;本文件只管「权衡」。
3
+ 本文件只记录**当前有效**的架构决定:每条写决定、理由、代价,以及什么情况下允许重审。操作手册看 README,代理规则与工作流程看 AGENTS.md,视觉令牌看 docs/STYLE.md。
5
4
 
6
- 执行约束:
5
+ 维护约束:
7
6
 
8
- - 任何重构/拆分方案若与本文件已记录的决策冲突,必须先修改对应条目、说明旧决策为何失效,再动手。禁止静默推翻。
9
- - 新决策追加在文末,编号递增,不删旧条目;被推翻的条目改为「已废弃 + 指向新决策」。
7
+ - 重构或拆分方案若与这里的决定冲突,先修改对应条目、写明原决定为何不再成立,再动手;不静默推翻。
8
+ - 编号是稳定标识,代码注释与其他文档按编号引用:内容随决定更新,编号不重排;一条被另一条整体取代时保留编号,写明由哪一条取代。新决定追加在文末。
9
+ - 事故经过与演变过程写在提交说明里,这里只写结论需要的理由。
10
+
11
+ 按主题:
12
+
13
+ - 构建与源码:D1 拼接构建 · D5 模型文案是数据 · D18 源码布局与共用部件层 · D4 输入框样式门控
14
+ - 宿主边界:D2 只做浏览器半边 · D3 宿主选择器纪律 · D19 宿主契约与构建编号 · D10 设置传输 · D11 私有路由与字符串安全
15
+ - 运行时:D6 单一调度器 · D13 功能契约 · D12 快速失败与功能隔离 · D9 样式性能
16
+ - 功能:D14 账号表面 · D15 HDSL 契约 · D16 弹层基准 · D17 权限档位 · D20 滑动高亮 · D21 已归档列表 · D22 搜索面板 · D23 轮次状态行
17
+ - 分发:D7 行为层与皮肤同包 · D8 多主题
10
18
 
11
19
  ---
12
20
 
13
- ## D1. 零构建工具链,构建期逐字拼接
21
+ ## D1. 零构建工具链:构建期逐字拼接成单文件
14
22
 
15
- - **背景**:DSH Web 的模块加载器没有相对 require、没有资产 URL 机制,常规打包器(esbuild/rollup)产出的 chunk 拆分与资产引用无处安放。
16
- - **决定**:`scripts/build.mjs` 把 `src/` 碎片按固定顺序逐字拼接成单个 `lib/client.js`;所有碎片共享一个工厂作用域,禁止 import/export,React 只能经加载器 `require('react')` 取得;资产(SVG/字体)构建期内联为 data URI 或走宿主路由。
17
- - **代价**:碎片写法受限(ES5 风格、4 空格基础缩进、`%%TOKEN%%` 占位);没有 tree-shaking,bundle 体积靠自律控制。
18
- - **重审条件**:DSH 加载器原生支持 ES module 相对导入与资产 URL 之时。
23
+ - **决定**:`scripts/build.mjs` 按 `FRAGMENTS` / `STYLE_FILES` 的顺序把 `src/` 下的 JS 片段与样式表拼成 `lib/client.js`。所有片段共用一个工厂作用域:不写 import/export,顶层名字在整个产物里唯一;React 由构建头部经加载器 `require('react')` 取得,其他宿主包在用到它的功能里直接 `require`。片段用现代语法(`const` / `let`、箭头函数、可选链),基础缩进 4 空格。品牌 SVG、螃蟹的帧条 PNG 与厂商锁定标在构建时内联,字体与模型文案走宿主半边的路由。
24
+ - **构建检查**:`src/` 下每个 `.js` / `.css` 都在两份清单里;`%%TOKEN%%` 全部替换;输入框样式门控(D4);`:has()` 位置(D9);产物能被 `vm.Script` 解析;模型文案文档校验(D5)。产物写入构建编号(D19)。
25
+ - **理由**:DSH 的插件加载器没有相对 require,也没有资产 URL,打包器产出的分块与资产引用无处安放。
26
+ - **代价**:顶层名字共用一个作用域,靠命名约定避免冲突(D18);没有按需裁剪,产物体积靠自律控制。
27
+ - **重审条件**:加载器原生支持 ES module 相对导入与资产 URL。
19
28
 
20
- ## D2. 纯浏览器半边实现,不动宿主
29
+ ## D2. 只做浏览器半边的皮肤,不改宿主
21
30
 
22
- - **背景**:主题是皮肤,不应 fork DSH;宿主升级要快跟随。
23
- - **决定**:一切效果通过 CSS 覆盖与客户端 DOM override 实现;宿主半边(`lib/index.js`)只提供静态路由(模型文案 JSON、位图图标)。(后来宿主半边还承担了字体、设置读写与系统用户名路由;私有路由的安全约束见 D11。)
24
- - **代价**:依赖宿主带哈希的 CSS-module 类名,宿主改版可能击穿选择器——用 D3 的纪律和 probe 回归对冲。
25
- - **重审条件**:DSH 官方开放主题 API / 插槽覆盖所 target 的区域时,逐步迁移过去。
31
+ - **决定**:一切效果靠 CSS 覆盖与客户端 DOM 覆盖实现,不修改 DSH 引擎、apiproxy 与官方 UI 包。宿主半边(`host/`)只做浏览器做不到的事:提供模型文案、字体、系统用户名、HDSL 契约、用量汇总、删除会话这些私有路由(D11),以及设置表单用的 `Config`(D10)。
32
+ - **理由**:主题是皮肤,宿主升级时要能快速跟进。
33
+ - **代价**:依赖宿主带哈希的类名与界面结构,宿主改版可能打断选择器,由 D3 / D19 的纪律与 smoke、probe 兜住。
34
+ - **重审条件**:宿主开放主题 API,或为需要覆盖的区域提供插槽时,逐步迁移过去。
26
35
 
27
- ## D3. 宿主选择器纪律:最长稳定片段
36
+ ## D3. 宿主选择器纪律
28
37
 
29
- - **背景**:宿主类名带哈希(`_54WpYG_imageItem`),短子串(`[class*="row"]`)极易误伤不相关组件,曾出过事故。
30
- - **决定**:子串匹配只用最长稳定片段(`[class*="_row"]`);不得覆盖 `[class*="viewArea"]` 的活跃期布局契约;新增子串选择器必须检查误伤面。
31
- - **代价**:选择器冗长;需要人肉维护「哪些片段稳定」的经验。
32
- - **重审条件**:宿主提供稳定的 data-* 契约后全面迁移。
38
+ - **决定**:
39
+ - 定位宿主元素的优先顺序:宿主的稳定契约(`[data-slot]` 插槽定位点、宿主自己的 data-* 属性,见 D19)→ 插件在每轮刷新里打的标记 → 带哈希类名的子串匹配。
40
+ - 子串匹配用最长稳定片段。宿主 CSS 模块的类名是 `<哈希>_<局部名>`,按组件定位时写 `[class*="_trailing"]`、`[class*="_row"]`;不带 `_` 的片段会误中别的词(`row` 命中 `…_grow` 曾把输入框高度锁在 28px;`rail` 命中 `trailing`;`tag` 命中 `stage`)与其他插件的类名。只有有意作用于全局的规则(全局字体、角标这类「全局刷子」)才用不带 `_` 的宽匹配。新增子串选择器要在真实页面里比对匹配到的元素,确认没有误伤。
41
+ - 不按界面文字匹配宿主元素:文字随界面语言与宿主版本变化。需要区分的宿主控件由刷新按结构打标记(D19)。
42
+ - 不覆盖 `[class*="viewArea"]` 在会话进行时的布局约定(`flex: 1 0 auto; min-height: auto`),它负责把输入框固定在滚动区底部。
43
+ - 带宿主按钮类名的链接(`_linkButton`,例如设置页的「充值」)是填充或描边按钮,自带文字颜色;全局链接颜色排除这个类名,否则按钮文字会和填充色同色。
44
+ - **理由**:宿主类名只有局部名稳定;界面文字与短片段都已出过实际问题。
45
+ - **代价**:选择器更长;标记要等刷新写入(刷新在同一帧、绘制之前执行)。
46
+ - **重审条件**:宿主为这些区域提供稳定的 data-* 契约时全面迁移。
33
47
 
34
- ## D4. composer 样式构建期门控(composer-gate)
48
+ ## D4. 输入框样式的构建期门控
35
49
 
36
- - **背景**:composer 是性能与正确性最敏感的区域;皮肤规则若在未启用皮肤时泄漏到宿主 DOM 会造成事故。
37
- - **决定**:composer 相关规则必须位于 `/* @composer-gate */` 标记之下,构建把 `[%%COMPOSER_ATTR%%]` 门控盖到标记以下每条规则,漏盖即构建失败。
38
- - **代价**:写 composer 样式多一道心智负担;构建脚本要维护门控逻辑。
39
- - **重审条件**:无(这是安全网,不是权衡)。
50
+ - **决定**:输入框相关规则必须写在 `/* @composer-gate */` 标记之下,构建把 `[%%COMPOSER_ATTR%%]` 门控盖到标记以下的每条规则上,漏盖即构建失败。门控属性由输入框功能按「输入框重绘」偏好与当前页面写在 `<body>` 上。
51
+ - **理由**:输入框是性能与正确性最敏感的区域;偏好关闭或功能退役时,这些规则必须整体失效。
52
+ - **代价**:写输入框样式要记得放在标记之下;构建脚本维护门控逻辑。
53
+ - **重审条件**:无(这是安全网)。
40
54
 
41
- ## D5. 模型文案是数据,不进 bundle
55
+ ## D5. 模型文案是数据,不进产物
42
56
 
43
- - **背景**:模型目录日新月异,文案更新不应要求改 JS 发版。
44
- - **决定**:`src/model-descriptions.json` 构建期校验后复制到 `lib/`,浏览器首次绘制选择器时经宿主路由 fetch;查找按 精确条目 → 家族规则 → 档位规则 → 目录自带文本 逐级降级;不写「最强/旗舰」等最高级(钉住版本的精确条目除外)。两条附加纪律:**文案是「线」的文案,不是「版本」的文案**——同一条产品线(如 DeepSeek 的 Flash / Pro)按名字模式映射到同一句,版本迭代与退场都不改这句话,因为过时版本会命中同一条规则,给某个版本写的描述最终会挂在别的版本上;**不写我们自己加的档位前缀**(「旗舰档:」「顶级档位:」),文案要么是厂商自己的定位句,要么直接说它做什么——前缀是我们的定位话术,会随产品线换代变成假话;**描述也不重复模型名**——行里已经写着模型名,文案再以「X 系列:」「X 档:」开头就是重复,只写后半句(Kimi K3 那条就是官网模型页标题去掉「Kimi K3:」之后的部分)。
45
- - **代价**:首次绘制选择器有一次异步 fetch;文案体系有学习成本。
46
- - **重审条件**:宿主模型目录 API 直接提供本地化文案时。
57
+ - **决定**:`src/model-descriptions.json` 在构建时校验后复制到 `lib/`,浏览器第一次绘制选择器时经宿主路由读取。查找顺序:精确条目 → 家族规则 → 档位规则 → 目录自带文本。文案纪律:
58
+ - 文案属于产品线,不属于某个版本:同一条产品线按名字模式映射到同一句,版本迭代与退场都不改。
59
+ - 不写自造的档位前缀(「旗舰档:」),不重复行里已有的模型名。
60
+ - 家族规则有顺序且必须限定范围(例如 `flash` 规则只作用于 deepseek);「最强」「旗舰」这类最高级只允许出现在绑定具体版本号的精确条目里。
61
+ - 同一文档的 `brands` 把模型绑定到厂商锁定标;没有规则认领的模型不画锁定标。
62
+ - **理由**:模型目录变化快,更新文案不应要求改 JS 发版;给版本写的描述最终会挂到别的版本上。
63
+ - **代价**:第一次绘制选择器有一次异步读取;文案规则有学习成本。
64
+ - **重审条件**:宿主的模型目录 API 直接提供本地化文案。
47
65
 
48
- ## D6. 单一 scheduler 统一所有 override 的生命周期
66
+ ## D6. 单一调度器
49
67
 
50
- - **背景**:多个 override 各自挂 observer/listener 会互相踩踏、泄漏、重复扫树。
51
- - **决定**:`overrides/scheduler.js` 持有唯一的 MutationObserver(body 子树、attributes 过滤到 aria-label/aria-selected),用 requestAnimationFrame 合并为每帧一次 pass,统一驱动各 `ui.*.sync()`;teardown 统一清理。
52
- - **代价**:每个 sync 必须有廉价的 early-out;新增 override 要接入同一调度器而不是自立门户。
53
- - **已知代价与对策**:流式输出期间每帧一次全量 pass 是性能热点,见 D9。
68
+ - **决定**:`src/core/scheduler.js` 持有唯一的 MutationObserver(`<body>` 子树,属性只看 `aria-label` / `aria-selected`),用 requestAnimationFrame 把变化合并成每帧一次刷新,按功能顺序调用各功能的 `sync()`。全局的指针、键盘、焦点、滚动与尺寸监听也只注册在这里,经功能句柄上的钩子分发(D13)。键盘路径只认用户的按键:皮肤自己代发的键盘事件带标记(账号表面对宿主菜单派发的 Escape 带 `__dshHostMenuEscape`),调度器见到标记即跳过——那次代发只送达宿主的菜单,不得顺带触发全局 Esc 路由。刷新状态在调度器开头声明:订阅偏好时,已就绪的设置表单会同步通知并触发 `schedule()`。
69
+ - **理由**:各功能自挂观察者与监听会互相干扰、重复扫描、难以卸载干净。
70
+ - **代价**:每个 `sync()` 必须能快速提前返回,并且只在值变化时写 DOM(同值写入也会让元素样式失效);页面持续变化时每帧都有一次刷新,其开销见 D9。
54
71
 
55
- ## D7. UI 行为优化内置在本插件,不拆独立插件
72
+ ## D7. 行为层与皮肤同包发布
56
73
 
57
- - **背景**:模型选择器、权限分段、账户抽屉等「UI 优化」与皮肤共享同一套宿主锚点(选择器纪律)、同一调度器(D6)、同一 popover 工具与 teardown。
58
- - **决定**:行为层(`src/overrides/`)与皮肤层(`src/styles/`、tokens、品牌资产)在源码内保持分离,但发布为同一个包。不想要 Claude 皮肤的用户用设置里的品牌切换回到接近宿主的观感。
59
- - **代价**:包名与主题绑定,「只用 UI 优化不要皮」的诉求没有独立入口。
60
- - **重审条件**:出现第二个真实消费者(另一个主题包或宿主官方)需要复用 overrides 层时,把 overrides 抽成独立包,皮肤包依赖它。
74
+ - **决定**:模型选择器、权限分段、账号弹层等行为层与皮肤层共享宿主定位点、调度器与弹层工具,在源码里分目录,发布为同一个包;不想要 Claude 外观的用户在设置里切换品牌。
75
+ - **代价**:「只要行为不要外观」没有独立入口。
76
+ - **重审条件**:出现第二个真实使用者(另一个主题包或宿主官方)需要复用行为层时,把它抽成独立包。
61
77
 
62
- ## D8. 多主题走「单仓库构建期分包」
78
+ ## D8. 多主题走单仓库、构建期分包
63
79
 
64
- - **背景与决定**:做第二个主题时,多合一会让包名(claude)名不副实,分仓库会让共享机制(build.mjs、scheduler、popover-utils、选择器纪律)多处漂移,抽 npm 运行时公共包则违反 D1。所以届时把本仓库改为单仓库多主题:共享碎片留仓库级 `src/`,主题私有碎片(tokens、品牌资产、copy、主题特有 overrides)收进 `themes/<name>/`,`build.mjs` 参数化 `--theme`,每个主题产出自包含单文件、各自发 npm 包;`dsh-claude-style` 包名不动。具体改造步骤到立项时再写。
65
- - **触发条件**:仅当新主题有**行为分叉**(不同的 DOM override、不同的 composer 结构)才动手。若只是换色板与 logo,先用现有品牌切换机制(settings.js)在包内消化,不提前改造。
66
- - **代价**:改造时 build.mjs 与目录布局有一次性手术;两个主题之后共享碎片的改动需要双主题回归。
80
+ - **决定**:只有新主题出现行为上的分歧(不同的 DOM 覆盖、不同的输入框结构)时才改造:共用片段留在仓库级 `src/`,主题私有片段收进 `themes/<名字>/`,`build.mjs` 按 `--theme` 参数化,每个主题产出自包含的单文件并各自发包。只换色板与标志时,用现有的品牌切换在包内解决。
81
+ - **代价**:改造时构建脚本与目录有一次性调整;之后改共用片段需要两个主题都回归。
67
82
 
68
- ## D9. composer 的 :has() 分支改 JS 写属性
83
+ ## D9. 样式性能:结构判断交给刷新,`:has()` 只放在最后一段
69
84
 
70
- - **背景**:皮肤在流式输出期间的渲染压力主要来自两处:D6 的每帧全量 pass,以及 CSS 中约 70 处对 DOM 结构敏感的 `:has()`(绝大多数是 `[class*="composerStack"]` 上的 hero/inline 分支)——每次 DOM 变更都触发昂贵的选择器重算。
71
- - **决定**:把「结构感知」从 CSS 移到 JS:`composer.js` 的 pass(每轮第一个同步)为卡片与 composerStack 祖先写 `data-composer-variant="hero|inline"` 属性(hero 以宿主会话根上的 `data-phase="hero"` 为准;observer 的 attributeFilter 不含 data-*,不会反触发;写前比较旧值防抖动;同轮去重避免多卡命中同一 stack 反复写),CSS 改为读属性。附件同理:同一个 pass 找出输入栏里的附件缩略图并标上 `data-dsh-claude-attachment`,缩略图样式只读这个属性,不再在 CSS 里用 `:has(img)` 逐次判定。交互敏感的 `:has()`(`:hover`、`:focus-within`)与低频的 dialog `:has()` 保留。
72
- - **落地**:已执行完毕。仍未消除的结构感知 `:has()` 还有两处:placeholder 那几条(`card` 与 inline 规则上的 `:has([data-composer-placeholder])`)——`copy.js` 本就管理 placeholder,可在它的 sync 里同步写 `data-has-placeholder` 再改 CSS;以及 hero 分支上的 `*:has([data-composer-card])`。两处都属可选的后续优化。
73
- - **重审条件**:实测证明 :has() 不再是热点,或宿主提供 hero/inline 的稳定属性契约。
85
+ - **决定**:
86
+ - 依赖 DOM 结构的判断在刷新里算好、写成属性,样式只读属性:输入框形态 `data-composer-variant`、附件 `data-dsh-claude-attachment`、草稿为空 `data-dsh-claude-draft-empty`、控件 `data-dsh-claude-control`(D19)、视图标签条 `data-dsh-view-tabs`。
87
+ - `:has()` 只能出现在选择器的最后一段(`A:has(B)`、`A :has(B)`),后面不能再接后代或兄弟选择器(`A:has(B) C`、`body:not(:has(B)) C`)。构建里的 `checkHasPlacement` 拒绝违规写法。
88
+ - **理由**:在无界面 Chrome 里打开会话、每帧追加一个字(等同流式输出或打字机效果)实测:违规写法单条就让每帧样式重算多出 7–13ms,与括号里测的是结构还是 `:hover` / `:focus-within` 无关;位于最后一段的每条只有 0.1–0.5ms。按本条改完后,整张样式表下每帧样式重算 1.9–2.7ms(不加载样式表时 1.4ms),主线程每帧总耗时 5–7ms。
89
+ - **代价**:状态要等刷新写入(同一帧、绘制之前)。
90
+ - **重审条件**:浏览器对非末段 `:has()` 的失效处理实测不再明显慢于末段写法。
74
91
 
75
92
  ## D10. 设置传输只走官方 Config 表单
76
93
 
77
- - **背景**:0.1.7 删掉了 `settings.register(ns, schema)` 的旧契约——命名空间是 profile entry id,schema 就是插件导出的 `Config`,只有 `.volatile()` 字段进表单,值写进 profile 的 Cordis patch;客户端服务 `settingsScope` 改名 `configForms`,插件设置席位从 `settings.plugin.item` 变成 `plugins.bundle.config`(键 = 包名)。没有 `configForms` 的旧宿主曾让客户端保留一条自建 `/prefs` 路由作为第二传输。
78
- - **决定**:客户端只有 `configForms` 一条传输——`ctx.configForms.get(entryId)` 绑定官方表单,读、写都走控制器(值 + 写队列 + revision 栅栏),插件自建的 `/prefs` 路由与其回退分支一并删除。
79
- - 宿主半边导出 `Config` 作为命名空间的 schema:八个偏好字段,`volatileField()` 逐字段探测 `.volatile()` 存在才加标记;schemastery 用顶层 await 守卫导入,解析不到就让 `Config` 为 `undefined`,皮肤照常加载。命名空间取成 `ctx.fiber.entry.id`(读不到回落 patch 里的常量),并调 `settings.configure({ auto: false }, ctx.fiber)` 声明自带页面。`configForms` 只服务宿主已注册的命名空间,所以这个导出是偏好可用的前提。宿主仍拥有旧的命令式注册表时,保留 `settings.register('claude-style', schema)` 分支,当前宿主不走它。
80
- - 设置席位:当前宿主注册成 `plugins.bundle.config`(键 = 包名,渲染在插件页上);`settings.section` 整页席位保留,但只在宿主没有 `configForms` 时注册。
81
- - **代价**:`Config` 的顶层 await 让宿主半边模块求值晚一步(loader 本就 await 导入,无实际影响);schemastery 的守卫导入是 AGENTS 记录在案的那条例外,它要在解析不到时保住皮肤,不让整包失败。
82
- - **重审条件**:宿主重新提供不需要 `Config` 的设置注册方式时,或 `configForms` 契约再变时,重写这一层。
94
+ - **决定**:客户端只有 `configForms` 一条传输:绑定宿主实际提供的命名空间(候选依次为加载器入口 id、包名、`cordis.patch.yml` 插入的 id,以宿主的命名空间目录为准),读写都经表单控制器(值、写队列、修订号栅栏)。命名空间晚到时订阅目录,到达即绑定。宿主半边导出 `Config` 作为 schema:只有 `.volatile()` 字段进表单;schemastery 用顶层 await 加守卫导入,解析不到时 `Config` 为 `undefined`、皮肤照常加载——这是导入规定的唯一例外。设置席位注册为 `plugins.bundle.config`(键为包名);宿主没有 `configForms` 时才注册整页的 `settings.section`。
95
+ - **代价**:`Config` 的顶层 await 让宿主半边晚一步求值(加载器本就等待导入,无实际影响)。
96
+ - **重审条件**:宿主提供不依赖 `Config` 的设置注册方式,或 `configForms` 契约再变。
97
+
98
+ ## D11. 私有路由借宿主的请求栅栏;外来字符串只以文本上屏
99
+
100
+ - **决定**:
101
+ - 宿主半边的私有路由(`/username`、`/usage`、`/session-search`、`/hdsl`、`/hdsl-skin.png`、`POST /session-delete`)处理前先调 `connection.requestRejection(req)`,拒绝即回 401/403;宿主没有该服务时,插件自带的替身只放行回环请求(回环 Host、无跨站标记、Origin 与 Host 一致)。模型文案与字体是公开的静态资产。
102
+ - 删除会话另加四道:只接受 POST;id 必须符合宿主的会话 id 格式;正在打开的会话拒绝;目录解析后必须留在会话根目录内。删除发生在宿主半边,浏览器只提交一个 id。
103
+ - 用量汇总只读:第三方插件的账本只读不写,自己的缓存放在 `$DSH_HOME/cache/dsh-claude-style/`。
104
+ - 客户端:来自设置、账号服务、系统或其他插件的字符串只用 `textContent` 或元素属性写入;图标复制节点,不重新解析标记。
105
+ - **理由**:插件路由不在宿主自己的栅栏之内;`dsh web` 绑定 `0.0.0.0` 时局域网可直接访问,而页面能驱动执行 shell 命令的智能体。
106
+ - **代价**:路由依赖宿主的 connection 服务。浏览器同源请求带会话 cookie、桌面壳经 `forwardWebRequest` 转发到回环 Host,两种都已核对能通过。
107
+ - **重审条件**:宿主提供自带鉴权的路由注册时迁移过去,删掉本地替身。
83
108
 
84
- ## D11. 插件自有路由借宿主的请求栅栏;宿主/用户来源的字符串只以文本上屏
109
+ ## D12. 快速失败与功能隔离
85
110
 
86
- - **背景**:`webServer.register()` 交给插件的是裸请求。宿主自己的 `/api` 挂在 Host/Origin 栅栏与浏览器会话 cookie 之后(`connection.requestRejection()`),插件路由不在其内。本插件的 `/username`(读系统用户名)因此曾经完全无鉴权:`dsh web` 绑定 `0.0.0.0` 时局域网里任何人都能直接读。读到的用户名又被客户端拼进 `innerHTML`——实测在 GUI 页面里执行了脚本,而这个页面能驱动执行 shell 命令的智能体。
87
- - **决定**:宿主半边 `/username` 处理前先调 `ctx.get('connection').requestRejection(req)`,拒绝即回 401/403。宿主没有该服务时,插件自带的替身只服务回环(回环 Host、无跨站标记、Origin 与 Host 一致)。模型文案与字体等静态资产保持公开。删除会话的私有路由 `POST /dsh-claude-style/session-delete` 同样先过栅栏,另加四道:只接受 POST,id 必须匹配宿主自身的会话 id 形状,正在打开的会话拒绝,目录解析后必须留在会话根目录内(删除发生在宿主半边,浏览器半边只提交一个 id)。首页用量汇总路由 `GET /dsh-claude-style/usage` 与用户名路由同级:它回的是本机会话历史的聚合结果(每日 token 用量、调用次数、活跃天数),属于用户数据,因此同样先过栅栏,并且只读——第三方插件的账本(`dsh-cost-meter` 的 `ledger.json`)只读不写,自己的汇总落在 `$DSH_HOME/cache/dsh-claude-style/`。客户端:一切来自设置、账号服务、系统或第三方插件的字符串只用 `textContent` / 元素属性写入,图标复制节点而非重新解析 markup。
88
- - **代价**:路由依赖宿主的 connection 服务。已核对两种壳都能通过:浏览器同源请求带会话 cookie;0.1.7 桌面壳经 `forwardWebRequest` 转发到回环 Host、剥掉页面 Origin 并自带 cookie(宿主真实的 `isTrustedApiRequest` 对这两种请求放行,对跨站与 DNS 重绑定请求拒绝)。
89
- - **重审条件**:宿主为插件提供自带鉴权的路由注册(如 `connection.fetch.register`)时,迁移过去并删掉本地替身。
111
+ - **决定**:
112
+ - 出错的地方直接抛出,不吞错误、不静默回退。宿主服务可能缺席时,用可选链与 `typeof` 检查表达「没有这个服务」,不用 try/catch。
113
+ - 功能隔离:teardown 最先经 `ctx.effect` 注册且幂等;每个功能单独安装,装不上的报告一次并退役;每轮刷新里每个 `sync()` 单独执行,连续失败 3 轮即报告并退役(`ui.retire`)。调度器自身装不上时整体回滚到宿主原样。
114
+ - 退役就是运行该功能自己的 teardown,并交还它接管的宿主界面:页脚接管(`FOOTER_ATTR`)与输入框重绘(`COMPOSER_ATTR`)的门控强制关闭;权限控件替换的宿主控件由它自己的标记把守,随它的 teardown 交还。每个功能的 teardown 是它 DOM 与标记的唯一清理。
115
+ - 需要隔离、但错误必须可见的地方用 `reportError`:`notifyAll` 逐个通知监听函数,某个出错就报告、其余照常;卸载时单个功能失败同样报告后继续。
116
+ - 允许的 catch 只有以下几类,每处写明原因:上述隔离;宿主以抛错表达的预期状态(设置表单的 `set()` 拒绝不属于该配置的字段;`modelDirectories.directoryFor` 对还没建立的会话抛错,下一轮重试;`sessionQuery.readSession` 以 `SESSION_QUERY_CORRUPT_SESSION` / `SESSION_QUERY_SESSION_NOT_FOUND` 表示某个存档读不了或在列出后被删,内容搜索只把这一个会话记为空并记一条警告);Promise 上表示「宿主半边没有应答」或「宿主自己会提示」的失败回调。
117
+ - **理由**:被吞掉的错误会在别处以更难排查的方式出现;隔离只负责不让一个功能拖垮其他功能,失败本身必须留下记录。
118
+ - **代价**:功能失效时界面上没有提示,只有控制台记录;依赖同一宿主服务的功能会一起退役(smoke 的「会话列表故障」用例覆盖)。
119
+ - **重审条件**:宿主提供插件级的错误上报时,把报告接过去。
90
120
 
91
- ## D12. 特性级失败隔离:一个特性出错只关掉它自己
121
+ ## D13. 功能契约:FEATURES 表与调度钩子
92
122
 
93
- - **背景**:`apply()` 依次安装全部特性,teardown 却在最后才交给 `ctx.effect`;每轮 pass 里各 `sync()` 也没有逐个兜底——一个抛错,排在它后面的全部跳过,而且每轮如此。实测:宿主 `remote.account.getProfile()` 返回非 Promise 时 `apply()` 中途抛错,样式表与 body 属性留在页面上、调度器没装上、teardown 没注册(关掉插件也清不掉),前面装好的特性的监听器一并泄漏。effort-picker 那次「Loading plugins…」卡死是同一类问题。
94
123
  - **决定**:
95
- - teardown 最先经 `ctx.effect` 注册且幂等;每个特性单独 try/catch 安装,装不上的报一次 `console.error` 并退役。调度器本身装不上时整体回滚到宿主原样——没有调度器,其余特性都不会同步,留着只是一张半套的皮。
96
- - 每轮 pass 里每个特性的 `sync()` 单独 try/catch,连续失败 3 轮即报一次并退役(`ui.retire`)。
97
- - 退役 = 跑该特性自己的 teardown,并把它接管的宿主界面还回去。页脚接管(`FOOTER_ATTR`,由偏好写)与 composer 重绘(`COMPOSER_ATTR`,由 composer 的 pass 写)都会隐藏宿主控件,所以退役 `footer` / `composer` 时对应的闸门强制关闭、不再随偏好打开。权限控件替换掉的宿主控件(访问模式按钮、统计弹窗、详细统计行)另由它自己的标记把守——body 上的 `PERMISSIONS_ATTR` 与统计行上的模式标记,两者都由它的 teardown 摘掉——所以权限控件或会话统计出错时,只有它们交还宿主,composer 重绘照常。特性的 teardown 因此必须撤干净自己的 DOM 与标记(模型选择器此前只清变量,已补上);调度器拆除时不替任何特性清扫,每个特性的 teardown 就是它 DOM 与标记的唯一清理,smoke 的「拆除后不留节点」检查因此直接覆盖到每个特性。只做装饰的 pass 用自己的句柄名(`ui.settingsNav`),退役它不会卸掉设置页。
98
- - **代价**:特性失效时界面上没有提示,只有控制台一行;依赖它的特性(例如都读 `ui.composer.isActive()`)会各自连续失败、依次退役——降级而非崩溃。
99
- - **重审条件**:宿主提供插件级的错误上报 / 健康面板时,把报告接过去。
100
-
101
- ## D13. 特性碎片的拆分布局与调度契约
102
-
103
- - **背景**:重构开始时,三个特性碎片都越过 750 行上限——`account-footer.js` 1357 行(账户资料、宿主账号菜单桥接、其他插件页脚条目的镜像、抽屉壳、行构建五份工作挤在一个闭包里)、`model-picker.js` 900 行(模型目录订阅与行构建也塞在里面)、`permissions.js` 868 行(会话统计卡也在里面);`composer/inline.css` 847 行与 `components/model-picker.css` 767 行两条 CSS 同样超限。调度器同时把每个特性的触发调用硬编码在手写清单里:`PASS_FEATURES` 按名列 pass 序、与 entry.js 的安装序分开维护,另有 21 处特性专属调用散在各触发分支里,而且已经与特性漂移——`ui.heroMenu.close` 是被守卫着的死调用(heroMenu 的句柄只有 `{ sync, reposition }`)。`ui` 是跨特性共享的服务注册表,却没有任何文档说明句柄有哪些方法、谁可以读谁。
104
- - **决定**(重构 Phase 0–3 已落地):
105
- - **辅助碎片导出顶层 `createX(...)` 工厂**,仿 popover-utils 的 `createHoverIntent`:状态收在工厂自己的闭包里,返回一个小对象;访问器与回调经参数传入(如 `{ isOpen: fn, onChange: fn }`),绝不伸手进别的闭包。特性的 `installX` 负责把工厂接起来(现有十个:`createAccountProfile`、`createHostAccountMenu`、`createAccountRows`、`createFooterMirror`、`createAccountSurface`、`createModelCatalog`、`createModelRows`、`createEffortControl`、`createEffortMatrix`、`createSessionStats`;`createEffortMatrix` 由 `createEffortControl` 接起来)。
106
- - **拆出多个碎片的特性建一个子目录**(`overrides/account/`、`overrides/model/`、`overrides/effort/`);只拆出一个辅助碎片的特性把它放在特性旁边(`overrides/session-stats.js`)——单文件目录是噪音。FRAGMENTS 里列在特性碎片紧前面;顶层名对整个 bundle 全局唯一、以特性起名(`createAccountProfile`,不是 `createProfile`)。移动就是移动:注释随行、风格与名字不变,只有闭包变量必须变成参数时才改签名。
107
- - **特性契约**:entry.js 的 FEATURES 表(`{ name, handle?, install }`)统一安装序与 pass 序——pass 序就是安装序过滤出句柄带 `sync` 的特性(`settings` 安装到 `ui.settingsNav`)。scheduler 只认 FeatureHandle 的可选钩子(typedef 在 scheduler.js 头部):`sync` / `owns` + `close('outside')` / `onPointerDown` / `close('escape')` / `close('composer')` / `onInput` / `reposition('viewport' | 'composer')` / `onCopyChange` / `onKey`;没实现的钩子直接跳过,每个特性保住自己原有的关闭路线(permissions 没有外部点击关闭,quickProviders 只在 composer 聚焦时关)。调度器自己的触发源同样不点名特性:时钟每分钟跑一轮整体 pass(hero 问候语这类跟着时钟变的文案靠它翻页),composer 卡片的尺寸变化经 `reposition('composer')` 派发,卡片上的点击聚焦与消息列表跟随都是 composer 特性的钩子。`retire` 按 name 或 handle 匹配:纯 handle 命中只停 sync、不拆安装(settingsNav 的显式分支——失败计数器已拒绝后续 pass,安装继续跑、设置页不卸);退役 `footer` / `composer` 仍强制归还 body 属性(FOOTER_ATTR / COMPOSER_ATTR)。
108
- - **理由**:拆分前「加一个特性」要改两处清单(entry.js 安装序列 + scheduler 的 PASS_FEATURES)再往各触发分支加调用;现在变成往 FEATURES 表加一行、在句柄上实现钩子——scheduler 不再认识任何具体特性,手写清单无从漂移。拆分把千行闭包变成状态自持的工厂加薄编排,750 行上限重新可守。移动就是移动(注释随行、闭包变量变参数才改签名),搬运提交的 diff 因此可审:搬运里不该出现逻辑改动。本决策不推翻 D1/D6/D12:仍是单文件逐字拼接(D1)、仍是单一调度器统一驱动(D6,钩子只是把硬编码调用变成句柄方法)、特性级失败隔离与 retire 语义原样保留(D12)——它在三者之内工作。
109
- - **代价**:多一层间接——特性内部状态要经工厂参数表交接,动状态时多过一遍参数;「加一个特性」前要先读这条决策与 scheduler.js 的 typedef。
110
- - **重审条件**:钩子表继续膨胀(例如第二类键盘事件或第二种观察源进场)时,重审契约粒度——按事件域分组,或让特性自己声明要订阅的触发器。
124
+ - `src/entry.js` 的 FEATURES 表(`{ name, handle?, install }`)同时决定安装顺序与刷新顺序:刷新顺序就是安装顺序中句柄带 `sync` 的那些。
125
+ - 调度器只认功能句柄上的可选钩子(类型定义在 scheduler.js 开头):`sync` / `owns` + `close('outside')` / `onPointerDown` / `close('escape')` / `close('composer')` / `onInput` / `onFocusIn` / `reposition('viewport' | 'composer')` / `onCopyChange` / `onKey`;没实现的钩子直接跳过,每个功能保留自己的关闭方式。
126
+ - `retire` 按 name 或 handle 匹配:只匹配到 handle 时只停止 sync、不拆安装(设置页的 `settingsNav`)。
127
+ - 功能拆出的部分写成顶层工厂 `createX(...)`:状态留在工厂自己的闭包里,访问器与回调经参数传入,不伸手进别的闭包;名字以功能开头(`createAccountProfile`)。
128
+ - **理由**:加一个功能只需在 FEATURES 表加一行、在句柄上实现钩子;调度器不认识任何具体功能,没有要手工同步的清单。
129
+ - **代价**:功能内部状态经工厂参数交接,多一层间接。
130
+ - **重审条件**:钩子继续增多(例如出现第二类键盘事件或第二种观察来源)时,按事件类别分组,或让功能自己声明要订阅的触发器。
111
131
 
112
132
  ## D14. 账号表面:一套行模型,两个挂载点
113
133
 
114
- - **背景**:DSH 0.1.7 的桌面端自带账号区——侧栏页脚里的账号行,以及它自己的账号弹层。此前的做法是隐藏宿主账号行、由插件自建抽屉,并把宿主菜单里的条目读出来镜像进抽屉:隐形点开宿主菜单、等它的 portal 出现、复制文案与图标、再把点击转发回去。这套驱动依赖宿主菜单的渲染时序与双语标签,先后出过图标逃逸、文案过期、行重复等问题。
115
- - **决定**:插件的行(账号头部、其它插件的页脚条目、设置行)只在弹层里出现,弹层有两个挂载点,由 `overrides/account/surface.js` 每轮判定。宿主有账号区时,宿主账号行就是入口(插件只给它打标记,供样式表重绘),插件把容器追加到宿主账号菜单列表的首位,宿主自己渲染它那几行;宿主没有账号区(Web)时,插件自建账号行与弹层,容器就是弹层主体。宿主的行不复制、不移动、不转发点击;插件的行被点击后需要收起菜单时,派发一次 Escape 交给宿主的菜单处理。卡片与行的外观由插件的样式表按标记重绘,两条路径共用同一套样式。
116
- - **理由**:宿主的菜单自带定位、动画、键盘遍历、Esc 与外部点击关闭,追加进去的行会加入同一套键盘遍历;删除驱动与镜像之后,账号区不再依赖宿主菜单的渲染时序与标签文案。
117
- - **代价**:宿主菜单关闭即卸载,容器每轮重新确认并追加;宿主的列表是 React 管理的子树,追加位置固定在列表首位,宿主重渲染后由每轮同步自愈。
118
- - **重审条件**:宿主为账号菜单提供追加行的插槽时,改用插槽注册,去掉 DOM 追加。
134
+ - **决定**:插件的行(账号头部、其他插件的页脚条目、设置行)只出现在账号弹层里,由 `features/account/surface.js` 每轮判定挂载点。宿主有账号区时(桌面端),宿主的账号行就是入口(插件只打标记供样式重绘),插件把容器插到宿主账号菜单列表的首位,宿主自己渲染它的那几行;宿主没有账号区时(Web),插件自建账号行与弹层。宿主的行不复制、不移动、不转发点击;插件的行被点击后需要收起菜单时,派发一次带 `__dshHostMenuEscape` 标记的 Escape 交给宿主菜单处理(D6:调度器不认代发事件)。账号头部打开封号彩蛋时,覆盖全窗的浮层让指针「离开」行与菜单——悬停收起在浮层打开期间停摆,屏幕退出后页脚保持进入时的样子。
135
+ - **理由**:宿主菜单自带定位、动画、键盘遍历与关闭;插进去的行加入同一套键盘遍历,账号区不依赖宿主菜单的渲染时序与标签文字。
136
+ - **代价**:宿主菜单关闭即卸载,容器每轮重新确认并插入。
137
+ - **重审条件**:宿主为账号菜单提供追加行的插槽时,改用插槽注册。
119
138
 
120
139
  ## D15. HDSL 账号契约:宿主半边转发,浏览器半边只认一条回退顺序
121
140
 
122
- - **背景**:HDSL 启动器用 `HDSL_` 前缀的环境变量向实例公布「这是谁」:账号显示名、供应商、账号种类、头像来源,以及玩家头像贴图的绝对路径;`HDSL_ACCOUNT_CONTRACT` 是唯一必需键,读不到它整组作废。浏览器半边读不到进程环境,贴图又只有绝对路径,两者都只能由宿主半边转发。启动器的皮肤是一张 64×64(或整数倍)的贴图集,不是成品头像:脸只是其中 (8,8) 起的 8×8 贴图块,帽子层在 (40,8)。
123
141
  - **决定**:
124
- - 宿主半边读 harness 启动时填好的启动环境快照(`ctx.launchEnvironment`,也就是 `launchEnvironmentOf(ctx)` 内部读的那个槽位),只信 `process` 与 `user-env` 两层(项目目录的 `.env` 会随仓库被克隆,没有资格声明玩家是谁),契约版本不等于 `1` 时回 `{ contract: false }`,其余字段一律不看。**不 import** harness 自带的启动环境包:`link:` 安装的插件解析不到它(与 `resolveSchemaFactory` 同一类问题),而那个包要读的正是这个槽位;宿主没填槽位就是没有契约。
125
- - 新增两条私有路由,与 `/username` 同级过 `refusalOf` 栅栏:`GET /dsh-claude-style/hdsl` 回账号元数据,**不含**头像文件的绝对路径;`GET /dsh-claude-style/hdsl-skin.png` 回贴图字节——路径只来自环境,永远不来自请求,读不到就回 404 让浏览器回退。契约在进程生命周期内不变,宿主半边读一次并记住。
126
- - 浏览器半边把昵称与头像各收敛成一条回退顺序:昵称 = 自定义昵称 → 官方账号昵称 → HDSL 昵称 → 探测昵称缓存 → 探测昵称 → `User`;头像 = 官方账号头像 → HDSL 头像 → Claude 徽标(样式表里的底色标记)。官方账号资料与 HDSL 契约各自推给 `src/context/host.js` 的身份存储,两个消费点(欢迎语、账号行)只读这一处。
127
- - 头像不是直接贴上去的:启动器给的是贴图集,浏览器按启动器账号列表同样的裁法取头部(脸的 8×8 贴图块按盒子 1/18 内缩、帽子层铺满整盒)画进 64×64 画布再缩到账号位的尺寸,方形绘制不被圆角裁切;贴图只以裁出的头部出现,加载完成前、图取不到、契约没带图、文件被删时都显示徽标。画完主动叫一次 pass:贴图加载不改 DOM,没有 pass 会自己来。
128
- - **理由**:契约读一次就够,所以路由只转发、不轮询;把两条链收在一处,欢迎语、账号行、封号彩蛋不会各自漂移;缓存探测到的系统用户名,是为了刷新页面时欢迎语不再从 `User` 跳一次;裁脸几何与启动器账号列表一致,同一个玩家在启动器与皮肤里长得一样。
129
- - **代价**:桌面端宿主的账号行由宿主自己渲染,插件只在自建账号行上画头像,所以 HDSL 头像在桌面端只作用于插件自己的表面;只有玩家自选的本地图片能画,启动器内置的默认形象(steve/alex 等)没有像素可用,回退到徽标;契约在进程生命周期内不变,玩家中途换皮肤要下次启动才生效。
130
- - **重审条件**:HDSL 契约递增版本(其 v2 计划里有按指纹取图的媒体通道与账号 UUID)时,取图改为优先走该通道、以指纹作缓存键,按玩家存储的数据改用 UUID 作键;宿主提供「显示名 / 头像」的统一服务时,改接那一处,删掉本插件的转发路由。
142
+ - 宿主半边读 harness 启动时填好的启动环境快照(`ctx.launchEnvironment`),只信 `process` 与 `user-env` 两层(项目目录的 `.env` 会随仓库被克隆),契约版本不为 `1` 时回 `{ contract: false }`。不 import harness 的启动环境包(`link:` 安装的插件解析不到它)。
143
+ - `GET /dsh-claude-style/hdsl` 回账号元数据,不含头像文件路径;`GET /dsh-claude-style/hdsl-skin.png` 回贴图字节,路径只来自环境、永远不来自请求。契约在进程生命周期内不变,读一次并记住。
144
+ - 浏览器半边:昵称 = 自定义昵称 → 官方账号昵称 → HDSL 昵称 → 探测昵称缓存 → 探测昵称 → `User`;头像 = 官方账号头像 → HDSL 头像 → Claude 徽标。两者都经 `src/core/host.js` 的身份存储,欢迎语与账号行只读这一处。
145
+ - 启动器给的是贴图集:按启动器账号列表同样的裁法取头部(脸的 8×8 贴图块按盒子 1/18 内缩,帽子层铺满),方形绘制;画完主动请求一轮刷新。
146
+ - **理由**:契约只需读一次;两条回退链收在一处,各处显示不会各自漂移;裁法与启动器一致,同一个玩家在两处长得一样。
147
+ - **代价**:桌面端的账号行由宿主渲染,HDSL 头像只作用于插件自己的表面;启动器内置的默认形象没有像素可用,回退到徽标。
148
+ - **重审条件**:HDSL 契约升级版本(按指纹取图、账号 UUID)时改接新通道;宿主提供统一的「显示名 / 头像」服务时改接宿主。
131
149
 
132
- ## D16. 弹层共用两条基准:一个停留时长,一次只开一张卡
150
+ ## D16. 弹层基准:一套外壳、一个停留时长、一次只开一张
133
151
 
134
- - **背景**:皮肤的弹层从六处各自长出来(模型选择器、推理强度、权限、账户、会话统计、hero 行的宿主菜单,另加设置页的快捷供应商),每处自己写停留时长与收起宽限。停留原本是 50ms,指针以平常速度扫过就已经超过它,于是位于指针必经之路上的触发器(工作台版面把上下文行贴在输入卡片正上方)看上去没有停留;同时「同时只开一张卡」此前只在模型与推理这一对上硬编码(两侧各自调用对方的 close),换一对弹层就会两张卡叠在一起。
135
152
  - **决定**:
136
- - 停留与宽限收到 `overrides/popover-utils.js` 的 `POPOVER_OPEN_DELAY`(100ms)与 `POPOVER_CLOSE_DELAY`(100ms)。100ms 是指针以平常速度扫过 28px 触发器所需的时间量级,路过不再展开,停在上面照常立即展开。两处例外连同理由写在使用处:模型选择器的两级卡片收起宽限 150ms(指针要横穿一级卡片才能到二级),会话统计卡片展开停留 300ms(统计句位于输入行中部,路过容易误触)。
137
- - 同一文件里维护一张弹层登记表:`registerPopover(name, close)` 登记每张弹层的关闭路径,`closeOtherPopovers(name)` 在打开前关掉其余弹层。一个弹层的多层卡片按一项登记(模型选择器的一级与二级同名 `model`),宿主的菜单由驱动它的特性登记(hero 菜单、账户菜单),关闭动作各自复用既有路径——皮肤自己的卡片直接收,宿主的菜单再次点击其触发位或派发 Escape。
138
- - hero 行的一项登记对应两个宿主菜单(工作文件夹与预设模式),所以 `hero-menu.js` 自己再分一次:打开前先收起行内另一个已展开的触发位,悬停离开时只收悬停打开的那一个,每轮同步里若发现行内两个触发位同时展开也收掉多余的一个。判定按「已展开的触发位」而不是「body 下的菜单卡片」——后者数得到宿主别处的菜单,前者才是这一行的选择器。
139
- - 登记表只存关闭函数,不记「谁开着」:每个关闭函数在弹层未开时是空操作,所以用户用 Escape 或外部按下关掉的卡片不会留下过期状态。按名字覆盖登记,使客户端热重载后残留的旧一代闭包被新一代覆盖。
140
- - **理由**:停留时长是同一个交互问题(路过还是停留),分处写必然漂移;互斥此前成对硬编码,新增弹层时要记得改 N 处,登记表把它变成一次登记。
141
- - **代价**:登记表是模块级状态,特性卸载必须 `unregisterPopover`,否则旧一代的关闭函数留在表里。
142
- - **重审条件**:宿主提供统一的弹层管理(全局互斥或焦点陷阱)时,改接宿主的机制,删掉本插件的登记表。
153
+ - 外壳:插件自建的卡片(权限、模型、推理强度、账号、会话统计、快捷供应商)都带 `.dsh-claude-popover-card`,共用底色、边框、圆角、阴影、内边距、层级、开关动画与暗色配色;行、角标、勾号、状态行、主体用中性类名(`dsh-claude-popover-item` 等)。各功能的样式只写位置、宽度与展开方向。
154
+ - 停留与宽限统一为 `shared/popover.js` 的 `POPOVER_OPEN_DELAY` / `POPOVER_CLOSE_DELAY`(100ms);例外连同理由写在使用处(模型选择器两级卡片的收起宽限 150ms;会话统计的展开停留 300ms)。
155
+ - 互斥:`registerPopover(name, close)` 登记每张弹层的关闭方式,`closeOtherPopovers(name)` 在打开前关掉其余的。一个弹层的多层卡片按一项登记;宿主的菜单由驱动它的功能登记。登记表只存关闭函数(弹层未开时是空操作),按名字覆盖登记,热重载后旧一代的函数被新一代替换。
156
+ - hero 行一项登记对应两个宿主菜单,`hero-menu.js` 自己再做一次互斥,判定依据是「已展开的触发器」。
157
+ - **理由**:同一个交互问题分处写必然漂移;共用外观用中性类名,改一个功能的样式不会波及借用它类名的其他功能。
158
+ - **代价**:登记表是模块级状态,功能卸载时必须 `unregisterPopover`。
159
+ - **重审条件**:宿主提供统一的弹层管理(全局互斥或焦点陷阱)时改接宿主。
143
160
 
144
161
  ## D17. 权限档位以宿主目录为准,皮肤只做表现层
145
162
 
146
- - **背景**:权限控件(输入框分段控件与它的弹层)此前把四档写死在两张常量表里,只对内置 `auto`(Auto review)做一次目录判定。于是第三方插件注册的权限档位看不见也点不到——例如 auto mode 插件的 `auto-mode`(宿主 `permissionPresets` 目录里的一等条目:确定性规则 + 两阶段分类器),而且会话已经处于该档位时,触发键上会显示机器值 `auto-mode`。宿主的目录是「当前可选档位」的权威:`PermissionCatalog.options` 由宿主按贡献顺序给出,`PresetOption` 只有 `value` / `name` / `description`(部署打上 automode 提供的补丁或宿主将来原生支持时另有 `icon`)。
147
- - **决定**:段位与弹层行都按宿主目录构建——目录里有的档位才画,宿主没提供的整条不出现(不再有「画出来再隐藏」);`PERMISSION_SEGMENTS` 只描述**格子**,每格列出可绑定的档位、按先后取第一个宿主提供的(部署自己的自动档优先于宿主内置的 Auto review),一格都绑不上就不画。档位名与说明来自皮肤的 `PERMISSION_PRESETS`,皮肤不认识的档位用目录自带的 `name`/`description`,永不把机器值上屏(宿主的 `custom` 用宿主自己的词)。行的内容是纯文字:档位声明的 `icon`(部署方写在预设上的 SVG 路径,宿主原生会忽略、由第三方补丁开启)**不画**——同一条列表里只有第三方档位带图标会读成两种东西,档位之间的区别交给名字与说明。切换仍走宿主的 `/permission <preset>`(与 automode 的 `/auto` 是同一条写入路径)。
148
- - **理由**:目录变化时皮肤不再漏档位;第三方档位成为一等公民而皮肤不需要认识它;名字由皮肤给、档位由宿主提供,两件事各自独立,接入新档位不用改皮肤代码,也不必让第三方为图标去打宿主 `node_modules` 的补丁。
149
- - **代价**:档位集合变化时要重建行与段位(此前只改显隐);皮肤的表现表是一张白名单,不认识的档位会以宿主给的名字出现(可能不够 Claude 风味)但不会消失。
150
- - **重审条件**:宿主原生支持预设 `icon`、且界面上有了统一的档位图形语言时,再考虑把图标画回来。
163
+ - **决定**:段位与弹层行都按宿主 `permissionPresets` 目录构建:目录里有的档位才画,没有的整条不出现。`PERMISSION_SEGMENTS` 只描述格子,每格列出可绑定的档位、取第一个宿主提供的(部署自己的自动档优先于宿主内置的 Auto review),一个都绑不上就不画。档位名与说明来自皮肤的 `PERMISSION_PRESETS`,不认识的档位用目录自带的名字与说明,机器值永不上屏;档位声明的 `icon` 不画,列表保持纯文字。切换走宿主的 `/permission <preset>`。
164
+ - **理由**:目录变化时不漏档位,第三方档位(例如 auto mode 插件的 `auto-mode`)成为一等条目,接入新档位不用改皮肤代码。
165
+ - **代价**:档位集合变化时重建行与段位;不认识的档位以宿主给的名字出现。
166
+ - **重审条件**:宿主原生支持档位图标、且界面有了统一的档位图形语言时,再考虑画图标。
167
+
168
+ ## D18. 源码按功能归档,共用部件单独成层
169
+
170
+ - **决定**:
171
+ - `src/core/`:宿主访问、偏好、模型文案、i18n、调度器。`src/shared/`:多个功能共用的部件,JS 与 CSS 放在一起——`dom.js`(`buildElement`、`createStamp`)、`notify.js`(`notifyAll`)、`popover.js` / `popover.css`(定位、悬停意图、弹层登记表、卡片外壳与行,D16)、`sliding-pill.js` / `sliding-pill.css`(D20)。`src/theme/`:不属于任何功能的全局外观。`src/features/<功能>/`:一个功能的安装器、拆出的工厂与样式表放在一起,主文件以功能命名。`host/`:手写的宿主半边。`lib/`:只放构建产物。
172
+ - 拼接顺序:共用样式表排在所有功能样式表之前,功能自己的规则在同优先级时后出现、取胜。
173
+ - 共用:同一段 DOM 查询或同一种写法出现第 3 次时收进 `shared/`(宿主访问收进 `core/host.js`);功能之间不借用类名,共用外观用中性类名。
174
+ - 文件大小:片段接近 750 行时,在它的功能目录里按职责拆出工厂(D13),不按行数机械切分。
175
+ - 搬动与逻辑改动分开提交:搬动提交里只有位置与路径变化,并用构建产物逐行比对确认。
176
+ - **理由**:一个功能的 JS 与样式放在一起,读代码时只需看一个目录;共用写法只有一份,改动不会漏改。
177
+ - **代价**:路径更深;新文件要登记到构建清单(构建会拒绝漏登的文件)。
178
+ - **重审条件**:D1 的限制解除时,改为真正的模块导入。
179
+
180
+ ## D19. 宿主契约与构建编号
181
+
182
+ - **决定**:
183
+ - 插槽定位点:宿主渲染器保证每个插槽外面有 `[data-slot="<key>"]` 包装层,供外部样式定位(ui-renderer 的 scoped-slots)。例如访问模式按钮按 `[data-slot="conversation.input.permission"]` 查找,跳过插件自己插进去的按钮。
184
+ - 控件标记:输入框的刷新按宿主 InputBar 的结构给按钮打 `data-dsh-claude-control`——`commands`(工具行里唯一打开 listbox 的按钮)、`stop` / `send`(提交区的主按钮,按图标区分:停止画 rect,提交箭头画 path;排队与插话是同一个提交按钮换了文字)、`access`(权限插槽里的按钮)。样式写成 `button[data-dsh-claude-control="…"]`,优先级与按文字匹配时相同。
185
+ - 弹层角色随开合写撤:宿主把文档里每个 `[role="menu"]`(ui-primitives 的 `modalSelector`,与 `[role="dialog"][aria-modal="true"]` 同列)当作占据前景的菜单——快捷键派发、`closeTopModal`、Esc-Esc 停止序列与 dock 标签菜单都按它仲裁。皮肤为量宽高而常驻 `<body>` 的弹层卡片关闭时只是视觉上藏起来,仍会命中这份查询,让宿主把整套快捷键判给一个看不见的菜单;因此 `role="menu"` 经 `setMenuPopoverOpen`(src/shared/popover.js)与 `data-open` 同写同撤——卡片开着才持有角色,折起即交还。宿主自己的菜单关闭即从文档摘除,皮肤以撤销属性达到同一效果;`quickProviders` 的卡片本就开时挂载、关时摘除,无需经过它。
186
+ - 构建编号:构建取产物内容的哈希写成 `BUILD_ID`,运行时写到 `<body data-dsh-claude-style>` 的值上;页面运行的是哪一版以它为准。热重载会替换插件代码而不重新加载页面,页面的加载时间说明不了代码版本。
187
+ - **理由**:界面文字与哈希类名随语言、版本变化,结构与契约更稳定。
188
+ - **代价**:结构判断依赖宿主 InputBar 的现状,宿主改版时需要核对;smoke 的替身宿主按宿主真实结构搭建,结构变化会先在那里暴露。宿主按 `modalSelector` 仲裁快捷键这一侧不被替身复现,冒烟改查皮肤自己的不变量:关闭的弹层卡片不持有 `role="menu"`。
189
+ - **重审条件**:宿主给这些控件提供自己的 data-* 标记时,直接读宿主的标记。
190
+
191
+ ## D20. 分段控件共用滑动高亮
192
+
193
+ - **决定**:视图标签、权限分段、进行中 / 已归档与设置页的分段控件共用 `createSlidingPill`:量出当前项相对控件内边距盒的位置与宽度,写成两个自定义属性,控件的伪元素用 transform 与 width 过渡滑过去。属性与首次位置在同一次调用里写入,所以初次出现不滑入;控件隐藏或没有当前项时撤掉属性,当前项恢复自己的底色。宽度因字体加载而变化时由 ResizeObserver 重新定位。滑动不受「减少动态效果」设置影响(用户要求始终播放)。React 渲染的控件(设置页)在布局副作用里同步,赶在绘制之前。
194
+ - **代价**:每轮刷新读两个元素的位置;在已经排过版的帧里开销很小。
195
+
196
+ ## D21. 已归档列表跟随宿主的两份客户端数据
197
+
198
+ - **决定**:侧栏「已归档」是插件自己的平铺列表,订阅宿主 `workspaces.list`(归档集合)与 `sessions.list`(标题、时间、来源),两者都就绪后按宿主「仅已归档」的规则重算:只收已有会话摘要、非子代理、非空白占位的归档会话,按更新时间排序,内容不变时不重建。宿主自带的筛选状态存在 ui-workspace 私有的视图 store 里,不是客户端服务,只能靠点它的菜单改,因此不用。取消归档走 `workspaces.unarchiveSession`;删除走宿主半边的私有路由(D11),失败记录警告。点击已归档行显示宿主的同一条提示:用宿主的 Toast 组件与 `workspace` 文案表里的 `toast.archivedNotOpenable`(宿主的提示通道是私有的)。
199
+ - **代价**:宿主「仅已归档」的规则变化时需要同步。
200
+ - **重审条件**:宿主把视图筛选或提示通道开放为客户端服务时改用宿主的。
201
+
202
+ ## D22. 搜索面板:宿主的 Modal、宿主的数据与导航
203
+
204
+ - **决定**:
205
+ - 侧栏搜索框插在宿主品牌行(`[data-slot="sidebar"]` 里的 `_logoRow`)的品牌旁边,只在这一行带宽版品牌时放置,侧栏收起成窄条时不放;这一行改成网格,品牌与搜索框同占第一格,两者的淡入淡出交给样式表的 `[data-slot="sidebar"]:hover`,不另外监听指针。
206
+ - 宿主的搜索快捷键(`session.search`)没有可替换的公开入口,它的效果是展开宿主自己的侧栏搜索并聚焦其输入框。宿主的这块搜索因此保持挂载、只从视觉与布局里拿掉;调度器的 `onFocusIn` 钩子见到焦点落进它的输入框时,搜索面板打开,并经宿主自己的清除按钮把它收回。这样快捷键改绑后也跟着走。
207
+ - 面板是宿主 ui-primitives 的 `Modal`(headless):遮罩、焦点归还、Esc 与模态层都用宿主的,面板开着时宿主的快捷键把它当作前景对话框;卡片里的行由皮肤自己搭。它是模态对话框,不套 D16 的弹层外壳,但登记进弹层互斥表。宿主的 `Modal` 关闭即卸载,没有退场动画,所以关闭时面板先给遮罩层打上标记、保持挂载淡出,淡出结束后再卸载;标记在卸载之后才撤,否则卡片与遮罩会重播一帧入场动画。
208
+ - 数据读宿主的客户端服务:`sessions.list` 与 `workspaces.list`(去掉已归档、子代理与空白占位)、`remote.pluginInventory` 与 `remote.pluginManager`、当前会话的 `remote.skills`、`shortcuts.catalog`。远程命名空间在面板打开时用 `ctx.get('remote.<名字>')` 读:在根上下文里直接读 `remote` 的子属性,cordis 会以「没有 inject」拒绝。
209
+ - 选中后走宿主自己的导航:`uiWorkspace.openSession` / `startSession`、`pluginNavigation.openBundle`、`layout.selectPanel`,Skill 经会话输入的 `setDraft` 与 `focus`;设置页与快捷键列表经它们在插槽登记里声明的 store(`slots.entries(key)` 条目上的 `store.create()`)打开。宿主的快捷键命令没有公开的执行入口,所以快捷键行打开快捷键列表,不代为执行命令。
210
+ - 匹配沿用宿主侧栏搜索的子串规则,排序为标题开头、标题中的词开头、标题其他位置、第二个键(路径、包名、描述、别名)。宿主 `/` 菜单的子序列排序在标题、路径和描述上会命中大量无关结果,因此不用。
211
+ - 消息内容由宿主半边的 `/session-search` 路由搜(host/search.js)。宿主自己的内容索引(`sessions.search`,SQLite FTS5)在出厂的 Web 组合里是关的(`openAt: never`);就算打开,它的 `unicode61` 分词只按整词匹配,一串不带空格的中文是一个词,句子中间的几个字永远搜不到。路由改经宿主的 `sessionQuery.readSession` 读原始日志,只取用户与助手消息里的文本块(助手消息里的工具调用参数不算),匹配规则照搬 `sessionQuery` 自己 `text` 过滤的写法:字面、不分大小写、空白可伸缩。每个会话的消息文本连同变化标记留在内存里:存档用持久化后端 `list()` 给的修订号(JSONL 是文件 stat),宿主开着的会话用日志长度 `seq`;搜索时只重读标记变了的会话,子代理会话不读。面板打开时先发一次不带查询的请求,让宿主半边提前读完;每个会话只回最新的一处命中,附一行摘录与命中位置,面板把命中的字加粗。
212
+ - **理由**:面板要做的每件事宿主都已有一条路:模态层、列表数据、打开会话与页面。借用这些路,面板的行为与宿主各处一致,也不重复实现。内容搜索是唯一的例外:宿主的索引对中文不可用,读日志与匹配规则仍用宿主的,只把逐字匹配放在插件这边。
213
+ - **代价**:依赖上面这些服务、store 与品牌行结构的现状,宿主改名或收回时需要跟进;smoke 只覆盖放置、打开与卸载,数据与导航在真实页面核对。内容搜索在进程启动后的第一次要把全部会话读一遍(本机 128 个会话约 16 秒),之后每次查询约 0.1 秒;消息文本常驻宿主进程内存,大小约等于全部对话的文字量。
214
+ - **重审条件**:宿主提供自己的全局搜索或命令面板时,改为打开宿主的;宿主的内容索引能按子串匹配中文、并在出厂组合里打开时,内容搜索改回 `sessions.search`。
215
+
216
+ ## D23. 轮次状态行:宿主的控件、宿主的数据
217
+
218
+ - **决定**:
219
+ - 宿主把每一轮的「过程」控件(ui-chat 的 turn-process:进行中显示「深度求索中,用时 N 秒」,停止与失败后显示「已停止」「处理失败」)排在这一轮的最前面。进行中、已停止与失败的轮次里,这个控件改做状态行、排到这一轮的工作之后;正常结束的一轮保留宿主的控件与位置,它负责折叠这一轮的工作。
220
+ - 皮肤不另建元素,也不移动节点:对话列本就是纵向 flex,刷新按文档顺序给列里的行写内联的 `--dsh-claude-turn-order`,样式表只对带这个属性的行取 `order: var(--dsh-claude-turn-order)`。第一个要移动的控件之前的行不写、保持 0;每个要移动的控件排在它这一轮最后一行(页脚 `turn-tail` 除外)之后,从那里起的行(页脚、后面的轮次、排队中的消息)逐级加一层,所以历史里多处停止或失败的轮次各自排对。标记与顺序每轮刷新重算,不再需要的当场撤掉。
221
+ - 控件改画成 Claude Code 的状态行:火花标记加一行文字。进行中为「用时 · 输出 tokens · 当前动作」,火花转动;已停止或失败为「宿主的已停止 / 处理失败 · 用时 · 输出 tokens」,火花静止。状态写在按钮的 `data-dsh-claude-turn-state`(`live` / `stopped` / `failed`),文字写在 `data-dsh-claude-turn-status`,样式表用 `attr()` 画出来;宿主自己的标签(和它每秒跳动的时钟)留在 DOM 里、只是不显示,React 的节点不被改写。
222
+ - 数字只读宿主的聊天快照:`uiConversation.binding(会话 id).target('chat')`,会话 id 取对话区外层的 `data-conversation-session`,轮次的状态与结束原因取快照里这一轮的 `status` 与 `turn/end` 的 `reason.kind`(`aborted` / `error`)。用时取这一轮 `turn/start` 到现在(进行中)或到 `turn/end`(已结束);输出 tokens 是这一轮已经结束的各步上报的 `usage.outputTokens` 之和,正在输出的那一步结束后才计入,不做估算;当前动作按最新一步的助手输出判断(它在输出过程中原地增长):最后一块是推理为「思考中」,推理之后开始输出正文或工具调用时为「思考了 N 秒」,没有推理时为「输出中」或「准备调用工具」;这一步没在输出时,这一轮有进行中的工具调用为「运行工具中」,否则为「等待模型」。快照不带推理的时间戳,「思考了」的时长是本页面看到推理出现到结束的时间。时长的写法(进行中秒数不补零,结束后补零)与「已停止」「处理失败」两个词用宿主 `chat` 文案表里的原文,与宿主自己的显示一致。
223
+ - 状态行没有自己的计时器:宿主的时钟每秒改写一次标签文字,这次改动触发的刷新顺带更新状态行。
224
+ - **理由**:状态行在工作过程的末尾,正在发生的事、停在哪里、为何失败都与它的状态挨在一起,和 Claude Code 一样;借用宿主的控件与数据,完成、停止、失败与折叠都还是宿主的行为。
225
+ - **代价**:依赖对话列是纵向 flex、行上的 `data-chat-flow-kind` / `data-chat-turn` 与聊天快照的现状;读屏与 Tab 键仍按文档顺序先经过这个控件;历史里有停止或失败的轮次时,它之后的每一行都带一个内联顺序值。
226
+ - **重审条件**:宿主提供可替换的轮次状态插槽,或把这个控件移到这一轮末尾时,改用宿主的。
@@ -33,6 +33,7 @@ import { homedir, userInfo } from 'node:os'
33
33
  import { dirname, join, resolve, sep } from 'node:path'
34
34
  import { fileURLToPath } from 'node:url'
35
35
  import { createUsage } from './usage.js'
36
+ import { QUERY_MAX, createSessionSearch } from './search.js'
36
37
 
37
38
  export const name = 'dsh-claude-style'
38
39
 
@@ -107,6 +108,12 @@ const SESSION_DELETE_PATH = `${ROUTE_PREFIX}/session-delete`
107
108
  const USAGE_PATH = `${ROUTE_PREFIX}/usage`
108
109
  /** How long a computed roll-up is served before a background refresh is kicked. */
109
110
  const USAGE_TTL_MS = 5 * 60 * 1000
111
+ /**
112
+ * Message-content search for the search palette (host/search.js). `?q=` is
113
+ * the query; without one the route only brings its message cache up to date,
114
+ * which the palette asks for as it opens so the first real query is quick.
115
+ */
116
+ const SESSION_SEARCH_PATH = `${ROUTE_PREFIX}/session-search`
110
117
  /** Session ids are the harness's own shape; anything else is refused before it reaches a path. */
111
118
  const SESSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/
112
119
  /** Largest deletion request body read; a real one carries one id. */
@@ -512,7 +519,8 @@ async function deleteSession(ctx, req, res) {
512
519
  */
513
520
  export function apply(ctx) {
514
521
  const here = dirname(fileURLToPath(import.meta.url))
515
- const file = join(here, COPY_FILE)
522
+ // The copy document is build output beside the client bundle in lib/.
523
+ const file = join(here, '..', 'lib', COPY_FILE)
516
524
  const fontsDir = join(here, '..', 'fonts')
517
525
 
518
526
  /**
@@ -546,6 +554,7 @@ export function apply(ctx) {
546
554
  const warn = (message) => ctx.logger?.warn?.(`dsh-claude-style: ${message}`)
547
555
  const usage = createUsage(ctx)
548
556
  const hdsl = createHdslAccount(ctx)
557
+ const sessionSearch = createSessionSearch(ctx)
549
558
 
550
559
  try {
551
560
  disposers.push(scope.webServer.register({
@@ -755,6 +764,38 @@ export function apply(ctx) {
755
764
  warn(`usage route unavailable: ${error?.message ?? error}`)
756
765
  }
757
766
 
767
+ try {
768
+ // The search palette's message hits. Read-only, behind the same fence
769
+ // as the usage route: the answer quotes the user's own conversations.
770
+ disposers.push(scope.webServer.register({
771
+ kind: 'exact',
772
+ path: SESSION_SEARCH_PATH,
773
+ handler: (req, res) => {
774
+ if (req.method !== 'GET') {
775
+ res.writeHead(405, { allow: 'GET' })
776
+ res.end()
777
+ return
778
+ }
779
+ const refused = refusalOf(ctx, req)
780
+ if (refused !== undefined) {
781
+ sendJson(res, refused, { ok: false, error: refused === 401 ? 'unauthorized' : 'forbidden' })
782
+ return
783
+ }
784
+ const query = (new URL(req.url ?? '/', 'http://local').searchParams.get('q') ?? '').trim().slice(0, QUERY_MAX)
785
+ const answer = query === ''
786
+ ? sessionSearch.warm().then(() => ({ sessions: [] }))
787
+ : sessionSearch.search(query)
788
+ void answer.then((value) => {
789
+ sendJson(res, 200, { ok: true, ...value })
790
+ }, (error) => {
791
+ sendJson(res, 500, { ok: false, error: String(error?.message ?? error) })
792
+ })
793
+ },
794
+ }))
795
+ } catch (error) {
796
+ warn(`session search route unavailable: ${error?.message ?? error}`)
797
+ }
798
+
758
799
  return () => {
759
800
  try {
760
801
  usage.dispose()