omdsh-base

by omdsh-plugins

0 会话与消息github 检测到 manifest package.json#dsh收录于 08-16

DeepSeek Harness会话模式系统:每个模式插件注册的段注册表、渲染它们的开关,以及为它们的对话着色的侧边栏点

The session-mode system for the DeepSeek Harness web GUI: the segment registry every mode plugin registers into, the switch that renders them, and the sidebar dots that colour their conversations

安装

dsh plugin --profile web add github:omdsh-plugins/omdsh-base

GitHub 源码安装:首次需按提示配置 allowBuilds 构建授权后重试

安装与环境配置指引、插件开发教程见 DSH 中文社区文档 ↗

安装即在你的机器上以你的权限运行第三方代码——它可读写文件、使用凭据、访问网络,DSH 的工具审批不会为插件代码加沙箱。「检测到 manifest」仅代表发现 dsh.bundle / dsh.plugin 清单,不构成兼容性或安全审查;安装前请审阅源码,不熟悉的插件先在不含密钥的环境试用。

README

目录

English | 中文

DeepSeek Harness 网页版的会话模式系统:所有模式插件注册进去的那个分段注册表、渲染它们的那个开关,以及给它们的会话上色的侧栏圆点。

它自己不发明任何一个模式。 Chat 随 omdsh-chatmode 到来,Code 随 omdsh-codemode 到来,两者在这里谁也不比谁更"原生"。它唯一贡献的那个姿态,是本来就已经在屏幕上的那个:Work,也就是 harness 自己的那根会话列——把它做成一个分段,是为了让开关有个地方可以切回去。它只在需要的时候才出现,见基线姿态。

它提供什么

界面 从哪来
sessionModes 服务 ctx.provide,每个模式插件注册自己那个分段所走的那扇门
模式开关 shell.overlay 里的一个条目——ui-layout 那层横跨整个框架的浮层;它自己对准会话列居中,没人靠近时自动让位
Work 分段——harness 自己的那根列 registerBaseline:这个包替自己注册的一段,一旦有模式插件带来这个姿态就立刻撤走
侧栏每一行前面那个按模式着色的圆点 session-dots.ts——是画到 harness 自己的行上去的,不是渲染出来的,由注册表的 tone 和 owns 驱动
新建会话先递给正占着列的那个模式 对 workspaces.startSession 的一层接管:先问当前分段要不要,没人要时才交给框架,并把这件事广播出去
sessionModes.column —— 会话列此刻真正显示的是什么 当前分段自己声明的 scope,没声明就是被选中的那段对话

harness 一行没改。它注册进去的那个槽位是公开的座位,接管用的是原型方法上盖一个自有属性,撤掉这一行两样都原样交还。

它同样不注册任何 settings 命名空间,而这是刻意的,不是漏掉的。一个分段注册表身上没有什么是需要人去配置的——哪个姿态占着列是每个标签页自己推导的,而「存在哪些姿态」是 profile 的属性——所以它在插件中心里的卡片没有表单。

为什么要单独成包

它原本是 Chat 模式的一部分,而那是一次分层倒置,代价很具体。

模式插件需要注册表。如果注册表装在某一个具体的模式里,那么其他每一个模式,为了往开关上放一个小药丸,都得依赖那个模式的整个包——它的托管工作区、它的 agent preset、它的输入框上方那行说明。"想要 Code 模式但不想要 Chat 模式"这件事没有答案;而且这样组出来的 profile 不只是少一个分段:omdsh-codemode 的浏览器半边会因为一个没人组装的服务永远停在 pending,客户端的启动审计会因此让整个页面失败。

把座位和姿态拆开,依赖关系才诚实:每个模式插件依赖这个包,这个包不依赖任何模式,而"存在哪些姿态"变成了 profile 的属性,不再取决于注册表恰好归谁所有。

基线姿态

开关得有个地方可以切回去。以前只装了这个包和 omdsh-codemode、别的什么都没装的 profile,拿到的是一个只有 Code 一颗药丸的控件——只有一段,从终端里出不来,侧栏里每一条会话也都没有圆点,因为没人认领它们。而缺的那个姿态,从来就不该由某个插件来贡献:它就是 harness 自己的那根会话列,本来就在那儿的那块屏幕。

所以这个包把它注册了进来,用的是 omdsh-chatmode 给它的那个名字——Work——连同那个插件的措辞、颜色和图标,好让人分辨不出自己这块开关上的它是哪个包给的。按下它就是把列交还回去,显示当前选中的那段对话;它不开启什么、不记住什么,也不需要 profile 提供任何东西。

它只在需要的时候才立在那儿,而这句话的两半同样重要:

  • 别的什么都没注册时,根本没有开关。 只有基线自己是不渲染的——一个只有一段的控件切不了任何东西;这也守住了原来的承诺:只装了模式系统、没装任何模式插件的 profile,屏幕上什么都不显示。
  • 有人认领"其余一切"这个姿态时,它让位。 omdsh-chatmode 的 Work 就是这个姿态外加一份"你上次离开的是哪段对话"的记忆,所以渲染出来的是它那一段;任何声明了 fallback 或占了同一个 id 的注册都会顶掉基线,而那个插件卸载时基线立刻回来。两个"其余一切"分段会让同一段对话挂上两种颜色的圆点。

它的 active 是推导出来的,不是写进去的:当它立着、并且没有任何贡献者拿走列时,它就占着列。正因如此,一个模式插件崩掉、卸载、或者只是自己不再 active,列都会自动交还,不需要谁记得去还。

契约

一个模式插件注册一个分段,并为自己作答:

// 绝不写进顶层 inject —— 见约定第 9 条。
ctx.inject(['sessionModes'], (mctx) => {
  const modes = mctx.get('sessionModes') as SessionModes | undefined
  if (modes === undefined) return

  mctx.effect(() => modes.register({
    id: 'code',
    order: 20,
    label: t('mode.code'),          // 已经是读者的语言
    hint: t('mode.code.hint'),
    tone: 'var(--dsw-alias-state-error-primary)',
    icon: createElement(IconCodeOutline16, { size: 14 }),
    owns: isCodeSessionId,          // "这段对话是我的吗?"
    available: true,
    enter: () => { /* 一次按下所执行的导航 */ },
    newSession: (workspaceId) => { /* 自己起了一个就返回 true */ },
  }))
})

SessionModes 是一个类型,来自 @omdsh-plugins/omdsh-base/client。用 import type 引入,永远不要作为值引入:跨插件的值导入,要么把这个包的运行时再内联一份进你的产物,要么去问 shell 那张冻结的模块表要一个它答不上来的 specifier,而客户端产物的纯度门会因此让构建直接失败。这也正是上面那段用字符串 'sessionModes' 解析服务、而不是用这个包导出的 SESSION_MODES 常量的原因——服务名是与运行时共享的线上名字,不是与某个包共享的符号,本集合里的两个模式插件都为此各自把它写成字面量。

写之前有四件事值得知道。

同一时刻只有一个分段是激活的,而且这条规则由注册表强制执行,而不是指望贡献者们自觉:把一个标成激活会清掉其余所有的。这同时也是一个贡献者得知自己丢了列的方式——它看着自己那一条变成 false,然后把自己的界面收起来。

文案是已经本地化好了才递进来的。 开关照单渲染 label、hint 和 unavailableHint;它自己只有两个词(switch.aria,以及某个模式不可用又没说明原因时的兜底),并且不知道任何一个模式叫什么。记得在 locale/change 时重新 update 你的分段。

按下是一次导航,绝不是一次状态写入。 enter 被调用,然后这个分段负责把世界变成真的——打开一段对话、起一段新的、或者把列拿过来——之后由推导出它 active 标志的那个东西去汇报。这里没有任何东西跨刷新记住模式;故意没有 node 半边,所以也就不存在一份两个标签页会吵起来的存储姿态。

owns 是按显示顺序逐个会话问的,第一个认领的赢;标了 fallback 的分段接走所有没人认领的。这就是为什么侧栏能按模式标满整张列表,而画那些行的代码里关于任何具体模式一个字都没有。某个判别器抛异常,只表示它放弃这段对话,而不会把整个浏览区拖下水。

「列在显示什么」和「选中了什么」不是一回事

sessions.current 回答的是"哪段对话被选中了",而这跟"屏幕上是什么"只有在所有模式的列都是网页对话时才是同一个问题。Code 模式的列是一个终端,而且它故意从不选中那个终端驱动的对话——被选中的对话是 Web host 会去 resume 的对话,而那份日志归另一个进程所有。于是选中项停留在终端背后那段对话上。

因此,一个立在列旁边、却去读选中项的界面,错得无声无息而不是缺一块:omdsh-sidepanel 的文件树会立在一个项目的终端旁边,描述另一个项目。column 就是那个诚实的答案:

// 列不是网页对话的模式,自己声明它在显示什么。
modes.register({ id: 'code', /* … */, scope: controller.scope })

// 列旁边的一切都跟着它走,而不是跟着选中项。
const scope = modes.column.getSnapshot()   // { sessionId, cwd }

只在该分段激活时才读,这是刻意的:贡献者的 scope 通常无论它占没占着列都活着(Code 模式会一直推导它将会显示什么),把那个报出去就是在描述一个没人在看的屏幕。没有声明 scope 的模式——Chat 和 Work,它们的列就是网页对话——会被报告为选中项,所以消费者只需要一条路,而不是两条。

点一条会话,意思是「让我看它」

光靠选中项承载不了这个请求,而这道缝隙是一分钟就能被人撞见的 bug:在某条工作对话上进入 Code 模式——那条对话仍然是选中的,因为终端不是它——然后去侧栏点它那一行。运行时选中的是已经选中的东西,什么都没变、什么都没发布,终端就继续盖在这次点击想要的那条对话上面。唯一的回去方式变成了那个模式开关,而这不是侧栏一行的含义。

所以 sessions.open 在这里被包了一层:每一次 open 都把列交给"显示这条对话的那个模式"(showConversation),不管选中项动没动。声明了自己 scope 的模式会被跳过——这种点击它自己会先接住(omdsh-codemode 对它自己的那些 id 就是这么做的),能走到这里说明它已经拒绝了。

新建会话属于按下它时所在的那个模式

"再来一段和这个一样的对话"在不同姿态下含义不同:随包发布的那几个模式要的是框架的空白会话,而一个列里跑着终端的姿态要的是框架压根没听说过的东西。所以这个请求先递给当前激活的分段(newSession),只有没人接才落到框架手里。

一个分段拒绝了,就连同这个请求一起把列交出去——这是对的默认:用户要的是一段对话,而框架马上就要显示一段。

落到框架的这条路会被广播出来(onNewSession),因为这个手势是唯一一个什么痕迹都不留的导航:startSession 会复用工作区里已有的空白对话,于是在已经身处其中时按下新建会话,不会移动任何选中项、不改任何列表、不发布任何 store。一个从"用户在哪"推导自己标志的模式,会继续汇报着那段用户正想离开的对话。"问了"这件事本身就是全部的事实。

安装

这个包在 npm 上,按名字装:

dsh plugin --profile web add @omdsh-plugins/omdsh-base

把这个开关填满的那些模式插件不在 npm 上,所以它们通过插件中心 的命令装——它会从这套集合的 registry 里逐个解析,并把 git 安装需要的那条 pnpm 构建白名单写好:

npx @omdsh-plugins/omdsh-plughub add omdsh-chatmode omdsh-codemode

按老写法点它们的名字——dsh plugin add @omdsh-plugins/omdsh-chatmode——在发布 之前只会回 ERR_PNPM_FETCH_404,而且什么都不会改。

两个都是可选的,开关如实反映组进来的东西:只装这个包什么都不显示,加上 omdsh-codemode 显示 Work · Code,加上 omdsh-chatmode 显示 Chat · Work,两个都装则是 Chat · Work · Code。

顺序只是可读性上的偏好,不是要求:模式插件是在受限 fiber 上按名字解析 sessionModes 的,所以排在这个包前面的那些只会等,不会失败。

也可以从检出安装,这是尚未发布的构建要走的路。dsh web 启动前 lib/ 必须存在——loader 直接 import lib/index.js,而按路径安装的包不会跑 prepare,没有任何环节替你构建:

pnpm install && pnpm run build
dsh plugin --profile web add "$PWD"

同理,改完源码要重新构建。

卸载同理:

dsh plugin --profile web remove @omdsh-plugins/omdsh-base

之后每个模式插件都会自己安静下来——分段和开关没了,各插件其余的界面照常站着。这就是受限 fiber 买来的东西,也是"模式关掉了"和"页面死了"之间的差别。

命令

pnpm install
pnpm run build       # tsc 产出 lib/types,tsdown 打包浏览器半边
pnpm run typecheck   # 先包源码,再测试
pnpm run test        # vitest

对着哪个 harness 编译是一个开关:

pnpm run harness:npm                             # 提交状态:锁定的已发布版本
pnpm run harness:local ../../deepseek-harness    # 同级检出,用于开发
pnpm run check:harness-pin                       # 只要还有 link: 就失败

只有 registry 状态可以提交。 link: 是相对声明它的 manifest 解析的,提交一条就等于把某台机器的目录布局写死进包里——而且 pnpm 不会大声报错:它建出悬空符号链接、报告安装成功,然后构建阶段每个 harness import 都是 TS2307。check:harness-pin 就是用来在提交前拦住这件事的。

已知限制

  • 开关的座位是借来的。 shell.overlay 也横跨侧栏和详情面板,所以这枚药丸自己对准带 data-conversation-scroll 的那个盒子居中,并在指针不在附近时隐藏。一个不带这个属性的列会让它失去锚点,于是开关会弹到整个框架的正中间去。
  • 圆点是画上去的,不是渲染出来的。 侧栏那些行是 harness 的,所以模式标记是通过 MutationObserver 写到 DOM 上的,而不是组装进去的。harness 改了行的结构,这个包就得跟着改选择器。
  • 圆点读的是一个私有结构。 一行是哪个对话,取自它的 React props——那不是公开契约;将来浏览器改版或 React 换了形状,结果是行上不标,而不是标错,开关里的字形也不受影响。另一条路——按行标题匹配——比不标更糟:同一个项目里没有标题的会话彼此同名。
  • 搜索结果不带圆点。 圆点画在浏览用的行上;搜索结果是两行的堆叠,第二行本来就写着所属工作区。
  • 没有模式能挺过一次刷新。 激活的姿态是每个标签页从"当前对话住在哪"推导出来的,而且各贡献者各推各的。打开应用永远落在当前对话所指的地方,而不是你上次待着的地方。
  • 基线的 Work 什么都不记。 按下它只是把列交还回去,显示当前选中的那段对话——它不会带你回到上一段工作对话;新标签页里什么都没选中时,落到的是工作区选择页。那份记忆是 omdsh-chatmode 的 Work 的,这也正是装了那个插件之后由它的分段来顶替这一段的原因。

原始 README: https://github.com/omdsh-plugins/omdsh-base/blob/main/README.zh.md ↗