# omdsh-sidechat [English](README.md) | 中文 在 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 网页界面的**任何位置**按 ⌘L,就地开一条**独立的对话**。它带上你正看着的东西作为锚点,问的话和得到的回答都留在它自己的 session 里;在没保存之前,这条对话**不会出现在左侧边栏**——让它在工作区下面落一行的,是面板右下角的保存按钮。想带上你正在跑的那条对话的上下文也行——标题栏上有分叉按钮,但**默认不开**:侧边对话默认独立,除非你点名要它成为一根 fork。 ## 它提供什么 | 界面 | 从哪来 | |---|---| | 在任何位置生效的 ⌘L | 本插件自己在 window 上装的监听器;在文本框里、在任何挂了 `data-omdsh-sidechat-yield` 的子树里让位,装了 `omdsh-shortcuts` 时整个交出去 | | 一块可拖动、召唤到应用之上的面板 | `shell.overlay` 上 order `100` 的条目:落在选区旁边,位置写进 localStorage 跨刷新记住 | | 会话标题栏工具位上的一个图标 | `conversation.session.header.utilities` 这个 list 座位,order `105`,排在 omdsh-sidepanel 两个开关的里侧 | | 那一排不在时的同一个图标 | `shell.overlay` 上 order `-8` 的替身,在空白会话和 Code 模式下占住那个角落 | | `sidechat` 服务——`registerAnchorSource`、`summonChord`、`setSummonChord` | 浏览器侧的 `ctx.reflect.provide`;知道自己在哪的面板可以直接说出来 | | `Chat` 工作区里一条被藏起来的侧边会话——或者当前会话的一根 fork 分支——以及一个把它放进侧边栏的保存按钮 | `ISessions.fork`、host 的 `session.create`(开新的)、`IWorkspaces.archiveSession`(藏起来)和 `ISession.prompt`,都是浏览器本来就握着的面——没有宿主侧 | | `sidechat.open` 命令 | `shortcut` 服务在场时注册给 `omdsh-shortcuts`,图标的 tooltip 也从那里读回当前绑定 | harness 的输入框只有一个,在对话列底部,而它属于你正在进行的那件事。于是每次「这个函数到底在干嘛」,你都要付两次代价:把手从正在看的地方挪开、滚回输入框,以及**把这个问题塞进那条对话的上下文里**——它会一直待在那儿,占着窗口,影响后面每一轮。 这个插件把两次代价都删掉:**在原地召唤一条自己的对话。** 它是一条完整的会话,有多轮,有历史,住在 `Chat` 工作区里(嵌入上下文时则作为 fork 落在源会话的工作区)。在保存之前它**不会出现在侧边栏**;保存与否,它都是自己的一条——只不过恰好是从你正指着的那一行旁边召唤出来的。 ## 它显示什么 一问一答,以及工作时的一行字。就这些。 harness 自己的对话列是一个**工作台**:推理、工具调用、结果、计划、重试,每一步都摊开,因为那一列是你监工的地方。这里不是。这里是问一句、读答案,中间那整套装置压缩成三个词: ``` 你:这个 slot 的 scope 为什么是 session? Deep diving... 因为视图要跟着当前会话走,而 … ``` - **`text` 块是回答**; - **`reasoning` 块永远不显示**,不是折叠,不是藏在展开条后面——是丢掉; - **`tool-call` 也不显示**,而且一个只调了工具、没说话的步骤**不产生任何一条记录**,否则记录里会长出一串空回答; - **模型在忙、还没吐出正文时,就是一行 `Deep diving...`**。哪个工具在跑,恰恰是这个界面答应不带的东西。 这条规则是纯函数(`transcript.ts`),所以它有自己的一组用例,逐条对应上面这几句话。 样式上一个字节都不是自己发明的:回答走 harness 自己的 `MarkdownText`,你自己的话走 `MessageText`,每一个颜色都是 alias 变量。所以它跟着 dsh 的主题走,而这个包并不知道当前是哪个主题。 ## 它住在哪 一条**自己的 session**,落在宿主托管的 `Chat` 工作区里——就是 [`omdsh-chatmode`](https://github.com/omdsh-plugins/omdsh-chatmode) 创建、[`omdsh-sidepanel`](https://github.com/omdsh-plugins/omdsh-sidepanel) 用来推导模式的那一个,这里按用户读到的**标题**去认,而不是靠 import。标题是个产品事实(它就是侧边栏里的分组标题),三个包因此能在它身上达成一致,谁也不依赖谁。 没装 chat 插件的部署没有这个工作区,那就退到**当前会话所在的工作区**里开第二条 session。上下文照样是分开的,只是两条对话共享一个目录。这个插件也不会因此偷偷依赖上那一个。 **嵌入上下文的时候,它是一条 fork。** `sessions.fork` 是 harness 对「开一条带着这段历史的对话」的官方动词——host 侧的 live-session fork API,harness 自己的对话列也在用它。子会话拿到源会话的完整历史作为种子、继承它的工作目录,血统记在 `parentSessionId` 上,所以侧边栏里它**嵌套在源会话下面**。这个插件不拷贝任何东西,也不往被监工的那条里写一个字——fork 切出来的就是一根只读分支。 **保存之前是藏起来的。** 不管哪条路开出来的会话,一连上就被归档——归档是工作区注册中心自己的动词:日志和工作区的记账都原样保留,只是所有分组界面都不再画它。它不是轻量草稿纸,而是一条真实落盘的 session,只是侧边栏约好不显示。唯一的例外是 host 拒绝归档:会话保持可见,按「已保存」对待——Save 本来要给它造的那一行,它已经有了。 **跨刷新记住**:会话 id、它 fork 自哪一条、嵌入开关的当前偏好、以及是否已保存,都在 localStorage 的同一条记录里(旧版本写的裸 id 照读,当作「没嵌入、偏好关、已保存」;保存功能之前的记录没有保存标记,按已保存读——那时候的会话从不被藏)。记住的 id 会先拿会话列表验一遍——被删掉的对话不是错误,是一条结束了的对话。 **记住的还有它说过的话。** harness 只为「在台上」的那条会话拉历史,而侧边这条按设计永远不上台——所以面板在绑定时自己去把这个窗口要来(`transcript-source.ts`)。少了这一步,记住的对话回来时是活的,但空着:刷新之后说的都在,刷新之前说的一句不剩。 ## 标题栏上的四个按钮 **嵌入上下文** —— 那个分叉图标,也是这条对话的**状态**:它显示这条侧边对话当前是否带着另一条会话的上下文,并且自己就是开关。 - **亮着**(品牌蓝):这条侧边对话是当前会话的一根 fork,上下文就在窗口里。按下去就是**断开**——嵌入的上下文从窗口里消失,开一条不带它的新对话;被丢下的那根 fork 留在暗处,和任何没保存的侧边对话一样。 - **暗着**:这条对话是独立的。按下去就是**嵌入**——fork 当前正在监工的那条,开一条带着它上下文的新对话。 - **Code 模式下变灰**:那一列是终端,没有「当前会话」可以嵌入。按钮不可按,也永远不会 fork。 两个方向都以**开一条新会话**落地,因为一个会话的上下文就是它的历史——既没法事后拼进去,也没法原地摘掉。每个模式都默认暗着——也就是独立——打开嵌入靠的就是这个按钮;Code 里永远灰着;没装 `omdsh-basemode` 的部署没有模式系统,按 Chat/Work 处理。 **在 Chat 窗口中展现** —— 跳到主界面里的这条对话。它本来就是一条普普通通的会话(独立会话在 `Chat` 工作区,fork 在它源会话的工作区),所以「展现」就只是导航(`sessions.open`):没有导出,没有复制,同一批消息没有第二种呈现。想接着长聊、想看完整的推理和工具调用,就去那边——那边是工作台,这里是侧边。还没连上会话时这个按钮是禁用的。没保存的会话这样打开后,侧边栏里依旧没有它的行——希望它留在列表里,就先在面板里按保存。 **新对话** —— 见下。 **关闭** —— 只收起面板,对话本身一动不动。 ## 保存 面板右下角的那个控件——状态行旁边一个小小的 **保存**——是页脚里唯一和提问无关的东西。 按下去之前,这条侧边对话**在任何模式下都没有侧边栏里的行**。按下它,就是给藏起来的那条对话切一根 fork:子会话带着它的历史、继承它的工作目录和工作区,**不**被藏起来——面板接着往子会话里说。藏起来的原会话留在暗处。harness 没有「取消隐藏」这个动词,所以保存出来的对话本质上是一根新分支;从面板的视角什么都没丢,侧边栏里的行落在对话自己的工作区下面——独立对话在 `Chat`,嵌入对话在源会话的工作区。 保存过之后,控件显示 **已保存** 加一个对勾,在这条对话活着期间一直如此。它在两种时候拒绝:会话还空着(没什么可保存的,而且工作区账本里躺着的一条空白 session,正是 harness 留给 New Session 复用的东西),或者回答还在进行(fork 是最后一个**已完成**轮次的快照——此刻保存会丢掉正在飞的这一轮);按钮在这两种状态下本来就是禁用的。host 拒绝保存时,对话留在暗处,状态行上会说一句;按钮继续提供下一次机会。 ## 新对话 面板标题栏上的那个按钮。按下去开一条新的,旧的留在原地——没保存的留在暗处,保存过的留在侧边栏里。**新对话服从嵌入开关的当前偏好**:开关开着,开出来的就是当前会话的一根新 fork;关着,才是下面说的那种空白会话。 独立会话这条路的底层,是在主工作区里调 `session.create` 开一条全新的会话,外加本插件**自己的空白复用**——什么都没说就连按两下,你还是待在同一条空对话里,而不是在 `Chat` 里撒下一串被藏起来的空壳。这跟 harness 自己的 New Session 是同一条规矩,只是范围圈在本插件自己的对话上:这里有意绕开了 harness 的 `connectWorkspace` 复用,因为它可能把用户正看着的那条会话递回来,而一个马上要藏会话的面板,绝不能把用户的那条藏起来。fork 没有这种复用:一根分支本来就是新的。 ## 锚点从哪来 锚点是一条关于位置的事实:路径、行号范围、你选中的文字。三样可以各自缺席。 优先问**注册进来的锚点源**(后注册的先问),都没有答案时,落到内置的那一个——**读浏览器自己的选区**: - 选中的文字就是引用; - 从选区往上找最近的 `data-omdsh-anchor`,那是路径; - 从选区首尾各自往上找最近的 `data-omdsh-anchor-line`,那是行号。 两个属性都是可选的,缺一个降一级。全都没有时锚点为空——面板照样召唤,照样问得出去,只是不带位置。 这两个属性是**公开约定**,和 omdsh-sidepanel 伸手去够 `#root` 和 `[data-slot="conversation"]` 是同一种做法:**是发布出来的锚点,不是类名、也不是 DOM 形状**;不在就跳过,不是错误。想让自己的面板可锚定,加一个属性就完了。 公开的属性一共四个,另外两个管的是「这个事件归谁」而不是「这一行在哪」:`data-omdsh-sidechat-yield` 标记一棵自己收着召唤键的子树,`data-omdsh-sidechat` 标记本插件自己的浮层——正是它把「草稿框里选中的文字」读成「这个人在重读自己写的东西」,而不是一段引用;也正是它让召唤键在面板内部变成关闭,而不是让位给光标底下那个 textarea。 面板如果知道得比 DOM 能表达的更多,还可以直接说: ```ts ctx.sidechat.registerAnchorSource(() => ({ origin: 'element', path: currentFile })) ``` ## 它把什么发出去 两条对话之间**只过去一样东西**:锚点,作为文本。上下文不在这里传——它走 fork:切分支的时候,源会话的整个历史已经是新会话自己的历史了,之后问的每一句都是这根分支上普普通通的一句。`PromptContentPart` 只有 `text` 和 `image`,所以锚点没法作为结构传,那就让它长成一个人会手写的样子: ```` /w/proj/src/client/apply.ts:199-205 ```ts children: { 'conversation.view': { kind: 'list', scope: 'session' }, ``` 这个 slot 的 scope 为什么是 session? ```` **注意那是绝对路径。** 这里有两个工作目录,而且通常不是同一个:锚点来自你正在看的工作区,独立会话住在 `Chat`;一根 fork 则**继承源会话的工作目录**,两个目录重合,路径自然落成相对形式。所以路径先按**来源**还原成绝对,再按**接收方**相对化——两个目录相同,自然得到相对路径;不同,自然得到绝对路径,两种情况都不用特判。接收方缩不短的绝对路径不是降级,而是**正确答案**:它是唯一还能指对文件的形式。 **引用会被夹断,不会被灌进去**。超过 60 行掐掉中间,超过 4096 字节掐掉尾部,两种都会在文本里写明白掐了多少,而且**在按下回车之前就显示在面板里**。 夹断的标记是英文的,不跟界面语言走:那两行是给 agent 读的,不是给人读的。 草稿是单行、以 `/` 开头、且不带锚点时,走 `session.command()`;命令没匹配上就照原样当文本发。带着锚点时**不**走命令路径——命令只吃一行,把你指着的东西悄悄丢掉,是这个界面绝不能干的事。 ## 排队还是插话 | 侧边对话 | Enter | ⌘⏎ | |---|---|---| | 空闲 | 直接发 | 直接发 | | 正在回答 | `queue`(排到本轮之后) | `steer`(插进本轮) | 注意这一栏说的是**侧边对话自己**忙不忙。你的主会话在跑,恰恰是这个插件存在的场景,它绝不会被读成「侧边对话在忙」。 发送失败(模型不支持、会话已经关了)**保留错误码**。问题本身已经进了记录,那是比任何提示条都好的回执,而且一分钟后还读得到。 ## 它坐在哪 `shell.overlay`,ui-layout 的全框浮层。这是个 **list** 座位,所以坐上来不占别人的位子:omdsh-sidepanel 的两块面板已经在这一层,大家按 order 排开(面板 `-10`,omdsh-sidepanel 的替身 `-9`,[本插件图标的替身](#这一排不在的时候它去哪) `-8`,面板 `100`——前三个是家具,面板是召唤出来的)。 - **第一次**出现在选区旁边:默认落在选区下方,放不下就翻到上面,钳进视口;没有选区时落在屏幕上方三分之一处居中。 - **之后就不再自己动了。** 位置只被两件事改变:你拖它,或者窗口小到装不下它(那时被拉回视口内——否则标题栏跑到屏幕外,就再也拖不回来了)。召唤只是把它叫回来,不是重新摆一次。位置**跨刷新记住**,所以「第一次」是真的第一次。 - **拖标题栏移动**。用 pointer capture 而不是 window 监听:指针跑得比面板快也不脱手,而且手势底下不会顺手选中一片文字。标题栏里那几个按钮不是把手。 - **没有遮罩**。遮罩会盖住你正要问的那段代码。整层本来就是穿透的,面板自己开 `pointer-events` 就够了。 - **点面板外不关**,`Escape` 关,发送成功**不关**——回答就要在这里出现。 - **锚点跟着选区变,面板不跟。** 开着面板再拖一段新的,锚点那一行实时换,窗口一动不动。你打字时还在滑来滑去的窗口,就得靠人去追了。 - **关掉不清空对话**。再打开还是刚才那一条——这才叫对话。从头开始是「新对话」的活,而它做成一个按钮,正是为了让这件事必须有意为之。 ## 标题栏那个图标 会话标题栏右侧的工具位上有一个图标,点它就召唤。位置和 omdsh-sidepanel 的两个开关是同一排,并且排在它们里侧(`105` 对它们的 `110`);那是个 **list** 座位,所以这是纯加法:**sidepanel 一行没改,也没被 import**。 没绑快捷键时它就是唯一入口;tooltip 里带着当前绑定(mac `⌘L`,别处 `Ctrl+L`),用鼠标找到这个功能的人可以就此不用鼠标。 难的不是这个按钮,是**按它的时候别把选区弄没了**。按下按钮会在 click 处理器跑起来之前就收起选区、移走焦点——不拦住这一步,从这里点开的面板永远锚定到空。所以在 `pointerdown` 上调了 `preventDefault`,跟格式工具栏的按钮是同一个动作。 ### 这一排不在的时候,它去哪 这一排并不总在屏幕上,而它缺席的两种情况都很日常: - **空白会话**:harness 会为 hero 把整个标题栏清掉——于是你看到的第一屏反而没有入口; - **Code 模式**:`omdsh-codemode` 用终端遮住了整个 `conversation` 座位,标题栏一起没了。快捷键在那儿也是让给终端的,所以没有替身的话,只要 Code 模式开着,这个面板就够不着。 所以同一个按钮在浮层上有一个**替身**,占住工具位那一排的角落——用标题栏自己量出来的 padding、那一排自己的高度——于是标题栏来了又走的时候图标不会跳。**面板在 Chat / Work / Code 三种模式下都在,行为只有一处不同**:嵌入上下文在 Chat 和 Work 里可开关、默认关,在 Code 里关闭且按钮变灰——那一列是终端,没有可嵌入的会话。入口在三种模式下都在。 两者永远不会同时出现,而替身等的是标题栏那一份的**挂载回报**,不是把「harness 什么时候藏标题栏」这条私有规则再推导一遍。这份回报同时也是重新测量的信号:Code 模式接管时中栏的几何一模一样,光看它的盒子什么都看不出来。 这个角落是共用的——omdsh-sidepanel 开关的替身也在那儿,omdsh-usage 的花费条会从里侧贴过来。把它们排开不靠任何注册表,靠的是这条规则:**只测真正占着角落的那一个(右边缘最靠右的),然后贴到它里侧。** 反过来取最左边的话,测到的会是那些自己已经贴进来的面,而其中一个又在测这一个,于是两边会一直互相往外推。角落空着的组合什么都测不到,图标就直接占住它。 ## 那个键 **这一节说的是「没装键位层」的情形。** 装了 `omdsh-shortcuts` 之后,下面这些全部让位,键归那个插件——见[换一个键](#换一个键)。 harness **没有键位注册中心**(`ui-commands` 是斜杠命令的契约,不是 keybinding),所以单独跑的时候,这个插件在 window 上自己装了一个监听器。规矩: - **只拿 ⌘L**(外加 Escape,且只在面板开着时); - **在文本里让位**:input、textarea、contenteditable,以及任何挂了 `data-omdsh-sidechat-yield` 的子树。在那里面事件既不消费也不 `preventDefault`; - **关着的时候什么都不消费**; - **在自己的面板里是切换**,否则让位规则会把这个键交给面板自己的 textarea。 **⌘L 同时是浏览器的地址栏快捷键**,这是有意做的取舍,上面几条规矩就是为它付的账。`Ctrl+L` 在终端里是清屏——终端挂上 `data-omdsh-sidechat-yield` 就把这个键完整收回去。 ## 换一个键 **对使用者来说,答案是 [`omdsh-shortcuts`](https://github.com/omdsh-plugins/omdsh-shortcuts)。** 把它装在这个插件旁边,召唤就变成它那份文档里普普通通的一行,在设置面板里改绑,不写代码也不用刷新: ```sh dsh plugin --profile web add @omdsh-plugins/omdsh-shortcuts ``` 装上之后这边会发生什么,写在下一小节。本节剩下的部分是给插件作者的路子——一次服务调用,给那些想把这个键收走的包。 `CmdOrCtrl+L` 只是默认值: ```ts ctx.sidechat.setSummonChord('CmdOrCtrl+Shift+K') // 改绑 ctx.sidechat.summonChord() // → 当前绑定 ctx.sidechat.setSummonChord(null) // 交出这个键 ``` 写法是 **Electron accelerator 语法**——[`omdsh-shortcuts`](https://github.com/omdsh-plugins/omdsh-shortcuts) 的 `MenuItem.accelerator` 用的就是这一套,将来键位搬到原生菜单项上时不需要翻译层。 `null` 是完全交出(原生菜单抢走这个键时,页面里再有个 handler 跟它抢,是最难查的 bug);改绑立刻生效,旧键在同一刻释放;写错的 accelerator 抛异常,而不是被忽略。 ### 装了键位插件的时候 有键位层的组合里,键该由**一个**地方决定——两个监听器抢同一个键,正是那个地方要防的事。所以只要 `shortcut` 服务在场(由 [`omdsh-shortcuts`](https://github.com/omdsh-plugins/omdsh-shortcuts) 发布),这个插件就把键交出去(`setSummonChord(null)`,此后它自己的监听器一个事件都不消费),改为注册一条命令 **`sidechat.open`**。快捷键于是变成那边文档里普普通通的一行,而 tooltip 照样教这个键:绑定是从那边的总机里读回来的,每次变动都重新读一遍,所以在设置面板里改绑,不刷新也能落到图标上。 在那边还没有给 `sidechat.open` 绑上键之前,入口就是标题栏那个图标——这是诚实的状态,不是坏掉的状态:内置的 ⌘L 之所以消失,正是因为键位现在归别人决定了。 **没装那个插件,这里什么都不变。** 这次交接跑在 `apply` 里起的受限 fiber 上,绝不写进顶层 `inject`:别的插件有没有提供某个服务,是**组合**的属性;一个 loader entry 去等一个没人组合的服务,会永远停在 `pending`——那会让启动自检失败、整页跟着挂掉,而不只是少一个功能。所以没有键位层的组合保留内置的 ⌘L;运行时把键位层卸掉,也会把这个键还回来,而不是让面板一个键都不剩。 ## 没有宿主侧 `src/index.ts` 是一个空 `apply()`。 面板通过 `ISession.prompt` 提问、通过 `SessionFace`(它本身就是 `ObservableSnapshot`)读回答、通过 `ISessions.fork` 或运行时自己的 `session.create` 安家——藏起来和保存走 `IWorkspaces.archiveSession` 和再一次 fork——全是浏览器本来就握着的公开面。模式来自 `omdsh-basemode` 发布的 `sessionModes` 服务,在受限 fiber 上按名去够,够不着就按「没有模式系统」处理。锚点则是拿浏览器自己的选区拼的,不读文件系统。所以**没有路由要开,没有工作目录要设栅栏,也没有这个插件需要新够到的东西**。 空 `apply()` 存在的唯一理由,是让这个包成为 Loader entry——`dsh-client-modules` 扫描 `dsh.client` 时,找的就是这个集合。 运行时依赖:**零**。它画界面用的每一样东西,都是 harness 已经发布出来的面。 ## 安装 ```sh npx @omdsh-plugins/omdsh-plughub add omdsh-sidechat ``` 这就是[插件中心](https://github.com/omdsh-plugins/omdsh-plughub)的安装器,只是入 口从按钮换成了 argv。它从这套集合的 [registry](https://github.com/omdsh-plugins/registry) 里解析出这个插件、从它的 GitHub 仓库装上,并把那条 pnpm 构建白名单写好——裸的 `dsh plugin add github:…` 会把这一步留给你,而那条记录里带着 pnpm 解析出来的 commit,只能从报错里抄,事先 写不出来。 `dsh plugin --profile web add @omdsh-plugins/omdsh-sidechat` 现在**还不是**那条命令:这个 包不在 npm 上,pnpm 会回 `ERR_PNPM_FETCH_404`。同样的安装也可以是一个按钮—— 只要 profile 里已经有插件中心,它就在**设置 → 插件 → 插件中心**里这个插件的卡片 上。 或者从 checkout 装: ```sh pnpm install && pnpm run build dsh plugin --profile web add "$PWD" dsh web --port ``` 卸载是同一条路: ```sh dsh plugin --profile web remove @omdsh-plugins/omdsh-sidechat ``` 它需要一个**有浏览器的界面**:这里的一切都在浏览器侧,而那一侧的 `inject` 只写 harness 自己的服务(`slots`、`sessions`、`workspaces`、`locale`)。在没有浏览器的界面上——TUI、headless——客户端那一半根本不会被拉取,宿主那一半是空的,这是对的:这里没有东西要跑。 每一个伴生插件都是可选的,缺谁都有答案,不会致命。没有 `omdsh-chatmode` 就没有 `Chat` 工作区,独立侧边对话改在当前会话所在的工作区里开;没有 `omdsh-basemode` 就没有模式系统,嵌入按 Chat/Work 处理(永远可嵌入、默认关、按钮可用);没有 `omdsh-shortcuts` 就保留内置的 ⌘L;没有 `omdsh-sidepanel`,标题栏那个角落只是空一点。它们都不出现在顶层 `inject` 里,所以全部没装的 profile 照样能起来,这个插件也照样能用。 **它不注册任何设置命名空间。** 这里没有一样东西是表单画得出来的——唯一可调的是那个键,而它要么是内置默认值,要么是 `omdsh-shortcuts` 文档里的一行——所以插件中心会列出这个包,但不提供任何控件,这比摆一张空表单诚实。 ## 命令 ```sh pnpm install pnpm run harness:local ../../deepseek-harness # 先在那边 pnpm run build pnpm run build pnpm run typecheck pnpm run test pnpm run check:harness-pin # 只要还链着就报错 pnpm run harness:npm # 提交前切回 registry pin ``` `link:` 的路径是**参数,永远不是提交进去的值**——它按声明它的 manifest 解析,写死一次就把一台机器的目录结构烤进包里,而且失败得悄无声息。`pnpm run check:harness-pin` 就是拦这个的。 规格刻意保持在 registry pin 下也能跑:纯逻辑那几个模块对 harness 的 import 全是 `import type`,所以裸 clone `pnpm install && pnpm test` 就行。`transcript.ts` 把内容分类器作为**参数**接收而不是 import,正是为了守住这一条——那条显示规则是这个包里最值得检验的东西。嵌入的决策同理是纯函数(`embed.ts`),也有自己的一组用例。 ## 已知限制 - **它永远不碰你正在跑的那条对话。** fork 只读源会话的历史,问题从不会发进主会话;反过来也一样——这里没有办法把问题发进主会话,只能切一根自己的分支。 - **嵌入是切分支那一刻的快照。** fork 之后,源会话又说的每一句都不会流进来;要跟上,就再按一次嵌入按钮,切一根新的。 - **保存也是一次 fork。** harness 没有「取消隐藏」的动词,所以保存是切一根分支,面板继续在分支上说话;藏起来的原会话——包括保存之后说的话——不在侧边栏里,这个面板也没有办法删掉它。 - **没保存的会话是看不见,不是不存在。** 它们是侧边栏约好不画的真实落盘会话;按新对话会把旧的留在暗处,侧边栏里没有能点回去的行,只有面板自己的 localStorage 记录还够得着它。 - **不显示推理,也不显示工具调用。** 是丢掉而不是折叠。想看的时候,标题栏那个按钮会把你带到摊开这一切的工作台上。 - **它不读文件。** 锚点只是页面已经显示给你的东西;这里没有自己的 `@` 补全,也没法附上页面没在显示的文件。 - **一次一个锚点,一个文件。** 横跨两个文件的引用就是两个问题;图片则完全不收——那是 composer 的能力,而这里不是 composer。 - **单靠它自己,这个键改不了。** 想改绑,要么装 `omdsh-shortcuts`,要么从别的插件里调 `setSummonChord`;这个包自己不注册任何设置命名空间。 - **一个 frame 只有一块面板。** 它是单个浮层条目,只记一个位置、一条对话,所以没有第二块侧边对话可以并排摆着。