huangmiuXyz/dsh-edit-retry

dsh-edit-retry

Right-click any user message to edit it and retry from that point — a client-side plugin for DeepSeek Harness (DSH). · 右键编辑任意一条用户消息并从那里重试。

未分類deepseekdeepseek-harnessdsh

dsh-edit-retry

重发任意一条用户消息。 右键会话里的一条消息,可以重试,也可以先改再重试 —— DSH 都从这条消息之前的位置分叉出一个新会话,把文本当作第一句话发出去。「编辑并重试」的编辑发生在窗口底部那个自带的输入框里,按发送的那一刻才分叉。

一个 DeepSeek Harness(DSH)客户端插件。


它做什么

在会话记录里右键点击任意一条用户消息,会弹出一个菜单:

菜单项行为
重试原样重发这条消息,不做任何编辑
编辑并重试把原文放进窗口底部的输入框,你在那里改完按发送
重试后删除原会话一个开关,勾上后上面两条动作完成后会删掉源会话,并让新会话继承它的名字

「编辑并重试」不在气泡上开编辑器了 —— 它用的是 DSH 自带的那个底部输入框(就是平时打字发消息的地方),跟元宝的「编辑」一个逻辑:

  1. 选了这一项,原文就出现在底部输入框里,光标停在末尾,可以直接改;
  2. 消息里的图片会进到输入框自己的附件栏(跟你自己粘贴一张图一样);
  3. 你按发送的那一刻才分叉 —— 分叉出来的新会话收到的,就是你改完按下发送时输入框里的那段文字。

所以「点了菜单会不会就分叉出去一个空会话」的答案是不会:在按下发送之前,什么都没发生;改主意了,把输入框清掉就行,什么都不会留下。

「重试」这一项不走输入框,还是老样子:直接分叉并把原文发出去。

编辑时对话会变模糊

选了「编辑并重试」之后,对话区会蒙上一层模糊(半透明 + 背景模糊),只有底部输入框和正在编辑的那条消息是清楚的 —— 跟元宝一样。视线自然落到你正在打字的地方。

Esc 退出编辑模式:模糊撤掉,输入框里的内容清空,挂起的重试也作废(不然下次发送会把它当普通消息发出去)。

模糊的做法是照抄元宝的做法(从它的页面上读出来的,不是猜的):在内容容器上加一个遮罩类,用一个伪元素做背景模糊 ——

.agent-chat__list__mask .agent-chat__list__content::before {
  backdrop-filter: blur(7.5px);
  background: rgba(255,255,255,0.5);
  inset: 0; content: ""; position: absolute; z-index: 11;
}

—— 而它的输入框在 z-index: 12,所以输入框在遮罩上面。

DSH 的布局容得下同一套安排:遮罩是 chat 区域上的一个伪元素(这个区域本来就是 position: relative,同时装着对话和输入框),输入框那格被提到遮罩之上。产品自己的规则一行没动 —— 输入框那格的 z-index 只在「有回合在跑」时才有,所以插件只在编辑模式期间给它补一个。

正在编辑的那条消息豁免(打一个属性标记,位于遮罩之上):它是你唯一还在看的东西,糊掉它等于把正在改的字藏起来了。

两条动作的后续完全一样:

  1. 从这条消息之前的那个已闭合的 turn 分叉出新会话(见下面的「为什么重试前面不会多一行」);
  2. 打开这个新会话;
  3. 把源会话当前选中的模型和权限预设装到新会话上(见下面两节),再把文本(原文或改后的)作为提示词发进去;
  4. 如果开关是勾上的,最后删掉源会话。

整个过程复用的是 DSH 自带「分支」按钮的同一条路径;唯一多出来的动作,是下面两节说的那两件事 —— 把源会话当前的模型和权限预设显式带到新会话上。

选择后菜单就关闭了,因此进度和失败会显示在气泡下方(「正在重试…」/「正在删除原会话…」/「正在继承原会话名…」/「重试失败:原因」/「删除原会话失败:原因」/「改名失败:原因」)。走输入框的那条路径,进度和失败显示在输入框下方(「已放入底部输入框,修改后按发送即可重试」/「正在重试…」/「重试发送失败:原因」)。

编辑在底部输入框里发生,是怎么做到的

插件要动的是 DSH 自带的那个输入框,不是自己画一个。两处接口(都读自 DSH 自带的包,不是猜的)让它成立:

  • 气泡能写进输入框。 conversation.chat.node 是一个 session 作用域的槽位,所以气泡本来就能拿到这个会话的标准 props,其中就有 inputActions —— 输入框自己的动作接口。inputActions.setDraft(text) 是往输入框的真实编辑器里写字的正规路径(光标落在末尾)。于是「选中这一项 → 原文进输入框」不需要插件模拟任何键入。
  • 插件能在发送的那一刻接管发送。 conversation 服务把输入 hub 挂在自己的 input 字段上,而 input.for(会话作用域) 解析出那个会话常驻的输入 shell。这个 shell 就是自带输入框拿到的 keyboard,而 shell.submit 是所有发送的唯一起点 —— 回车键位(installDraftKeymap)和发送按钮(onPrimary)都走它。所以插件在 shell 实例上改写 submit,自带输入框(编辑器、模型控件、权限控件、附件栏、dock、回车、发送按钮)原样渲染、一行不改。

于是流程是:选中「编辑并重试」→ 文字出现在底部输入框 → 你在那里改 → 按发送时才分叉,新会话收到的就是你批准的那段文字。

插件刻意不遮蔽任何输入框槽位。 遮蔽 conversation.composer.bar 这条路走不通:遮蔽的条目既不继承自带条目的 inject(keyboard 就来自那里),也不继承它的 children(自带 InputBar 需要它:模型控件、权限控件、附件栏、dock 都是通过那些子槽位渲染的,渲染器在组件要一个「自己的条目没声明」的子槽位时会抛 SlotOwnershipError),而重复声明那些 children 会被注册表直接拒绝(「槽位已被声明」)。这个失败从外面看像什么都没发生:遮蔽的条目根本没进槽位,自带输入框原样回来,插件一声不吭。所以插件改的是 shell 上的方法,不是槽位。

包 shell.submit 时有一处容易踩的坑:那个 shell 是个类实例,submit 会写它自己的实例字段(this.attachmentFlightSeq、this.attachmentFlights)。所以包装对象不能靠原型链糊弄过去(那样 this 会指向包装对象,shell 自己的计数器就停在旧值上),必须把方法绑回真实 shell,并把实例字段做成透读(shell 是重新赋值这些字段的,复制一份会读到过期值)。附件栏也一样:读的是 shell 自己的 attachmentIds 字段,不是 snapshot 里那份副本。

补丁只在真的挂起了一次重试时才装(也就是用户选了「编辑并重试」的那一刻),而且是每个会话各装一次 —— 包装器每次发送都问一句「有没有挂着重试」,没有就原样放行。

为什么重试前面不会多一行「用时 N 秒」

分叉点默认是「这条消息的前一个事件」(seq - 1),而它必然落在消息自己那个 turn 内部:DSH 先 turn/start、再把提示词 splice 进 inbox,最后才写下转录用来挂气泡的 user/message。切在半个 turn 上,DSH 的 buildForkSeed 就会用 openTurnClosers 补一条合成的 step/end + turn/end {kind:"forked"} —— 背后没有任何用户消息,转录把它渲染成重试内容前面的一行空「用时 N 秒」(实测跨度十几毫秒,取整显示成 1 秒)。

所以插件把分叉点改到上一个 turn 的 turn/end(一个平衡边界,openTurnClosers 扫完发现没有开着的 turn,就不补任何东西),前提是这条消息开了它自己的 turn,并且满足下面几条:

  • 消息是该 turn 的第一条 → 切到上一个 turn/end。它同时也在这条提示词的 inbox splice 之前,所以子会话不会重发原文(跟第一条消息改走「新建会话」是同一个理由)。
  • 上一个 turn/end 是宿主补出来的合成回合(reason: {kind:"forked"})→ 跳过它,取再上一个真跑完的回合。合成回合是上一次重试的残留,宿主用它来闭合切点落在半截的那个 turn;而转录会把合成回合渲染成一行空的「已完成,用时 1 秒」。切在它后面就会继承它 —— 于是每次重试都继承上一次重试留下的那行,每重试一次多一行(自己延续自己)。切点必须落在真正跑过的回合上。
  • 上一个 turn 结束时 inbox 里还压着没被取走的提示词 → 保持 seq - 1。被用户中断(aborted)的 turn 会把已 splice 进 inbox 的提示词留在队列里,等下一个 turn 才取走;切到那个 turn/end 会把这条源会话从没回答过的文本一起继承给子会话,让它先重发一遍。所以分叉前先数一遍 inbox 的净存量(Σinserted − ΣremovedCount),非空就不切。
  • 消息是 turn 中途插进去的(steering)→ 保持 seq - 1。它前面没有任何已闭合边界,往前切会连带丢掉同一 turn 里更早的消息,那比多一行严重。
  • 事件窗口还没加载完(hasMore)→ 同样保持 seq - 1:看不到开头就既不能断言「这是第一条」,也不能信一个自己看不见起点的边界。

重试跑在哪个模型上

跑在你现在选中的那个模型上 —— composer 里显示的是哪个,重试就用哪个。

这件事需要显式做,因为 DSH 的两条规则叠在一起会得出一个意外结果:分叉只继承到重试点为止的事件前缀,而宿主是按会话自己的日志决定模型的(该会话最后一条 request/header 的路由,或一条尚未被请求消费掉的 model/selection)。于是「先切了模型,再往上重试一条旧消息」——也就是最自然的用法——子会话会跑在那条旧消息当时用的模型上,跟你眼前选中的那个无关。

所以插件在分叉之前读源会话的 modelSelection 投影(next:有待生效的选择就是它,否则是最后一次请求的路由,正是 composer 模型控件渲染的那个值),并在这个新会话的第一条提示词之前调用宿主那条 session/selectModel —— composer 模型控件用的也是同一条 RPC。宿主把它记成一条 model/selection 事件,于是子会话的记录、模型控件、以及真正跑起来的模型三者一致;历史是别的模型生成的时,DSH 自己还会在记录里插一条「model changed」提示,把这件事讲清楚。

  • 目标会话本来就解析成同一个路由时不调用:单模型会话里的重试不会平白多出一条事件(也不会多写一次默认值)。
  • 取不到选择(会话从没选过、投影还没加载)或路由已经不可用时,重试照常发出,只在控制台留一条警告 —— 带模型过来是附加的,不能反过来把重试弄失败。
  • ⚠️ 宿主对这条 RPC 的处理和手动选模型完全一样:它会同时把这个选择存为部署默认值。所以如果源会话当前的模型是从历史继承来的(不是刚手动选的),一次重试也会顺带把它变成新会话的默认模型。这是 DSH 自身的语义,插件没有另立一套。

重试跑在哪个权限下

跑在你现在选中的那个权限预设上 —— composer 里权限控件显示的是哪个,重试就用哪个。

原因和模型那条一样,但后果更实在:沙箱模式和审批策略都是会话事件(sandbox/mode、approval/policy,再加上把两者打包成一个身份的 permission/preset),所以分叉只继承到重试点为止的那些事件;而第一条消息走的是「新建会话」,新会话拿到的是部署默认预设。于是「先切到完全权限,再往上重试一条旧消息」会让子会话跑在旧的(或默认的)权限下 —— 而且屏幕上不会说,直到你打开这个子会话才发现。

所以插件在分叉之前读源会话的 permissions 投影(currentValue,正是 composer 权限控件渲染的那个值),并在新会话的第一条提示词之前切过去。切的动作用的是 /permission <预设> —— composer 权限控件提交的就是同一行,于是走的还是产品自己的路径:宿主把预设记成 permission/preset,再把变化的那些旋钮(沙箱模式、审批策略)分别交给各自的 setter,子会话的记录、控件和真正生效的权限三者一致。

  • 目标会话本来就解析成同一个预设时不切:把最新那条消息重试一遍(最常见的情况)不会平白多出一条命令记录。
  • 取不到预设、或源会话显示的是 custom(表示「当前旋钮组合不对应任何预设」)时不切:custom 不是一个可切换的目标,硬发过去只会换来一条失败记录。
  • 预设名里有空白字符时同样不切:/permission 是按空白切分的命令行,那样的名字只会被拼成一行乱码。预设键由部署方决定,非 ASCII 的名字(比如 完全权限)不受影响,可以正常带过去。
  • 切换失败(预设已不可用、宿主拒绝执行)时重试照常发出,只在控制台留一条警告 —— 带权限过来同样是附加的,不能反过来把重试弄失败。
  • 切到完全权限时 UI 会让你确认一次;重试这条路径不再问第二遍 —— 你已经在源会话里确认过并选中了它,每次重试都弹一次确认没有意义。
  • 和模型那条一样,这次切换会在子会话记录里留下一个命令节点 —— 跟你在 composer 里自己切一次留下的东西是同一个。

删除原会话

删除是可选、永久的,所以它默认关闭、状态记在 localStorage 里(勾一次就一直是勾上的),而且只在重试真的发出去之后才执行 —— 重试失败会保留源会话,因为那时它是这段对话唯一的副本。

删除成功后,新会话会改名成被删掉的那个会话的名字:源会话没了,它的名字就空出来了,与其让新会话顶着分叉自带的「原名 (1)」,不如直接继承。名字在删除之前读取(删除会把标题所在的投影一并清掉,之后再读就是空的),改名走 session.rename 契约 —— 也就是分叉路径自己 increaseTitle 内部用的同一个调用,所以侧边栏和标题投影会同步更新。源会话在目录里查不到标题时跳过改名,改名失败只在气泡下方报「改名失败:原因」,不会影响已经完成的删除。

DSH 没有给客户端插件任何删除会话的能力:workspace 面只提供 archiveSession(可逆,日志还在盘上),agent 协议的 session_delete 是给外部 ACP agent 用的,而会话存储根本没有公开的删除接口。所以删除做在宿主半边(src/index.js),顺序是有讲究的:

  1. 停:ctx.agents.get(id).cancel() 再等它静默(最多 15 秒);
  2. 刷盘:ctx.sessions.flush(),否则 disposes 会在日志删掉之后又把它写回来;
  3. 摘内存:sessions.detachEntered();
  4. 删日志:~/.dsh/sessions/<slug>/<id>/,裸 uuid 和 session- 前缀两种拼写都删;
  5. 确认删干净(清扫三次,因为一个在途的 dispose 可能重建目录);
  6. 最后才清掉 workspace 记账和投影缓存。

第 5 步的顺序不是随便排的:半删除的会话比没删的更糟 —— 日志还在但记账没了,会话会掉进 Ungrouped;记账还在但日志没了,侧边栏会留一条永远打不开的记录。

⚠️ 这段逻辑走的是 DSH 内部结构(sessions.store / detachEntered / storageDomain 的表形状 / 磁盘布局),其中 detachEntered 连 DSH 自己都注明「the store has no public remove API」。每一处都做了能力探测、探测不到就跳过,所以 DSH 升级后最坏是拒绝删除或部分删除,而不是崩溃。但它确实可能在升级后失效,届时看气泡下方的错误信息。

删除走一条宿主注册的 HTTP 路由(POST /dsh-edit-retry/session/delete),要求一个自定义请求头 —— 这不是装饰:自定义头会强制 CORS 预检,而本服务不应答预检,所以外部页面无法调用这条破坏性路由。会话 id 在进入任何文件路径之前会先按白名单字符校验,并且每个候选路径都会再验证一次真的落在 ~/.dsh/sessions 之内。

已知小瑕疵:删除后侧边栏那条旧记录可能要等下一次刷新才消失(DSH 没有给客户端提供刷新会话列表的接口,而整页 reload 会打断刚发出的重试流)。点它不会有反应,重新加载即可。

为什么是「分叉」而不是原地改写

DSH 的持久化日志是只能追加的(seq = log.length),而且会话记录只渲染追加来源的 surface 事件。所以原地替换 surface 只会让模型看到的内容变了、而屏幕上还是旧文本 —— 一种静默的不一致。分叉没有这个问题,因为它是产品自己就在用的做法。

安装

插件是装进某个 profile 的普通依赖,并在该 profile 的 dsh.profile.bundles 里登记一层:

dsh plugin --profile <你的 profile> add github:huangmiuXyz/dsh-edit-retry

dsh plugin 就是把参数转发给该 profile 目录里的 pnpm,装完再自动把新依赖登记进 dsh.profile.bundles —— 本包声明了 dsh.bundle,所以这一步不用手改 package.json。重启 DSH(或刷新 Web 界面)即可生效。

为什么是 github: 而不是裸包名? 本包还没发布到 npm,add dsh-edit-retry 会 404;GitHub 源是当前唯一可用的写法。包内没有 prepare/构建脚本,所以也不会被 pnpm 的构建脚本审批(allowBuilds)挡住。

桌面端(DSH Desktop):--profile desktop 由 Electron 应用独占,dsh plugin 会直接拒绝(profile "desktop" is managed exclusively by the Electron application),所以桌面端这份要在应用内的插件管理入口里装同一个 GitHub 源。

如果该 profile 的 dsh.profile.bundles 里有解析不到的条目(典型是指向已删除目录的 link: 包),dsh plugin 会在包装完之后的登记阶段报错退出、不写 bundles —— 依赖其实已经装好了。清掉那条悬空条目再跑一次,或者直接按下面的手动做法补 bundle。

手动等价做法(不用 CLI)
cd ~/.dsh/profiles/<你的 profile>
pnpm add github:huangmiuXyz/dsh-edit-retry

然后编辑该 profile 的 package.json,把包名加进 dsh.profile.bundles(顺序即层级顺序,插件放在内置 bundle 之后):

{
  "dsh": {
    "profile": {
      "bundles": [
        "@deepseek-ai/dsh-base",
        "@deepseek-ai/dsh-web-app",
        "dsh-edit-retry"
      ]
    }
  }
}

从本地克隆安装

开发时用 link: 更顺手,改完代码刷新页面就生效,不用重新装包:

dsh plugin --profile <你的 profile> add link:/绝对路径/dsh-edit-retry

bundle 登记同上 —— dsh plugin 会自动做,手动做法见上面的折叠块。

行为与边界

  • 右键是唯一入口。 没有重试按钮,也没有双击 —— 按钮得挤进 DSH 自带的操作栏(复制 + 时间),而那一行渲染在自带组件内部,从外面接不进去;双击则会和浏览器「选中一个词」的手势打架。
  • 「编辑并重试」用的是底部那个自带输入框。 文字进输入框、图片进输入框的附件栏,你在那里改完按发送,按发送才分叉。没按发送之前不会分叉出任何会话,改主意了清空输入框即可。
  • 编辑时对话是模糊的。 进入编辑模式后,对话区蒙一层半透明 + 背景模糊,只有底部输入框和正在编辑的那条消息是清楚的。做法照抄参考产品(遮罩类 + 伪元素模糊,输入框层级在遮罩之上)。Esc 退出编辑模式:模糊撤掉、输入框清空、挂起的重试作废 —— 否则那段放弃的文字会在下次发送时被当成普通消息发出去。
  • 中途插话的消息一样能重试。 一个 turn 正在跑的时候发进去的提示词,转录把它渲染成 steering 节点;它和开启 turn 的那条一样是人写的消息,右键菜单完全相同。区别只在分叉点:插话前面没有已闭合的边界,所以它保持 seq - 1(见下文),代价是子会话里会多一行空的「用时 N 秒」。
  • 文本和图片都能重试;文件附件不行。 图片在持久消息里是引用({type:'image', attachment:{attachmentId,…}}),而 session/prompt 只接受内联字节,所以重试会通过源会话自己的 readAttachment 把每张图读回来(授权按源会话的日志校验,因此必须在删除原会话之前),转成 base64 内联重发,由宿主再落成持久引用。走输入框那条路径里,图片是先进输入框自己的附件栏、再由会话服务按产品自己的方式序列化的 —— 所以附件栏里看到什么,发出去的就是什么。某张图读失败只损失那一张:打一条警告后跳过,重试照常发出 —— 用户要的是重发这条消息,不是校验它的附件。文件搬不过来:宿主只接受由某次 agent 作用域上传签发的 receiptId,而客户端无法读取已存文件的字节去换一张新回执 —— session.attachment 是图片专用(授权走 imageBlockIn,只匹配 type === 'image'),其余远程方法都不读附件字节。因此带文件的消息保留原生右键菜单。
  • 消息内的交互元素(链接、按钮、输入框)保留它们自己的右键菜单,插件不会抢占。
  • 消息为空白时「重试」置灰 —— 重发一个空提示词没有意义;但带图片的消息本身就算一条消息,仍然可以重试。「编辑并重试」始终可用,它也是给纯文本消息补上文本的唯一途径。
  • 在别的会话里按发送,不会触发分叉。 「编辑并重试」是记在它来源那个会话上的:选完切到别的会话再按发送,那是一次普通发送,不会分叉 —— 分叉属于消息来自的那个会话。
  • 同一次「编辑并重试」只分叉一次。 分叉记录在按发送的那一刻就被取走了,所以连按两次发送,第二次是普通发送。
  • 第一条消息走「新建会话」而不是「分叉」。理由见下面的注释;简单说,在 Turn 1 内部切分会留下一个空的「用时 N 秒」行,而在 Turn 1 之前切分会让子会话重发原文。第一条之外的消息才做分叉,并且切到上一个已闭合的 turn 末尾(中途插话的消息、以及上一个 turn 结束时 inbox 里还压着未取走提示词的情况除外,见上文)。
  • 两条菜单项共用同一套分叉规则 —— 是消息的位置决定走新建还是分叉,跟选了哪一项无关。
  • 重试跑在源会话当前选中的模型上,而不是被重试那条消息当时用的模型;路由不可用时退回前缀继承的那个,重试本身不受影响。
  • 重试也跑在源会话当前选中的权限预设下,而不是被重试那条消息当时用的那个;取不到预设、预设是 custom、或切换被拒时退回前缀继承(新建会话则是部署默认)的那个,重试本身不受影响。
  • 重试会话会落在和源会话相同的 Workspace 分组里,不会掉进 Ungrouped。
  • 删除开关对两条路径都生效,并且只在重试已经交给宿主之后才执行;重试本身失败不会删源会话。
  • 中英文跟随 DSH 的语言设置,读的是 locale 服务的实时快照。

它是怎么实现的

  • 用一个下层优先级的槽位遮蔽自带渲染器。 DSH 自带的 conversation.chat.node 里 user 和 steering 这两格都注册在默认优先级 0(同一个组件 UserMessageNodeView)。本插件用同样的两个 key、优先级 -1 各注册一次:渲染器按优先级取每一格的第一个未被让位的条目,所以更小的数字会遮蔽自带的那条。随后插件通过 ctx.slots.entries() 取出自带组件本身来渲染,因此气泡样式、图片渲染、复制和时间戳仍然归产品所有。
  • 输入框那一半不遮蔽槽位,改的是会话的输入 shell。 conversation 服务把输入 hub 挂在 input 字段上,input.for(会话作用域) 解析出常驻的输入 shell;那个 shell 就是自带输入框的 keyboard,而 shell.submit 是所有发送的唯一起点。插件在 shell 实例上改写 submit,所以自带输入框(编辑器、模型控件、权限控件、附件栏、dock、回车、发送按钮)原样渲染。遮蔽 conversation.composer.bar 走不通:遮蔽的条目既不继承自带的 inject 也不继承它的 children,而重复声明 children 会被注册表直接拒绝 —— 失败的样子是一声不吭地什么都没发生。
  • 两格都必须遮蔽,哪怕自带的是一个组件。 座位是按节点的 kind 分发的:正在跑的 turn 里插进去的提示词被转录判成 steering(见 message.ts 从 next-step inbox 认领的规则),只遮蔽 user 会让这类消息悄悄留在自带条目上 —— 菜单不存在,也没有任何提示。这正是「插话右键没反应」那个 bug 的成因。
  • 必须转发 locale 座位。 entry.locale 是渲染器注入 t 翻译函数的依据,自带气泡的操作栏会调用 t;只转发 props 而不给这个座位会让自带节点崩溃。两个格子各转发各自那一条的座位。
  • 客户端半边只 import React。 默认的 Client 半边不允许引入任何 Harness Client 包(也就没有 JSX、没有 react-dom),所以菜单全部用朴素元素加 --dsw-* 主题 token 画出来,因而自动跟随明暗主题。
  • 宿主半边是给删除用的。 sessions.fork、uiWorkspace.openSession、session.prompt 都在客户端服务上,所以重试本身不需要宿主;宿主这边除了让客户端模块扫描发现这个包(并发出 exports["./client"]),还注册了那条删除路由。客户端和宿主是两个不共享 import 的模块图,路由和请求头在两处各写了一遍,自检里有一条专门断言这两种写法一致 —— 否则这个功能会永远静默 404。
  • 模型的搬运在客户端完成,但落点在宿主。 选择从会话的 modelSelection 投影读(客户端上就有一份,和 UI 显示的是同一帧),写则要走宿主那条 session/selectModel,因为模型的归属在宿主:只有它能给会话追加 model/selection 事件。这一路是能力探测式的 —— 探测不到 Remote 命名空间就只少这一项功能,菜单和重试照旧。
  • 权限的搬运走产品自己的那条命令。 DSH 给客户端的只有权限预设的读(permissions 投影的 currentValue)和一条写(/permission <预设>,也就是 composer 权限控件提交的那条命令);沙箱模式和审批策略各自的 setter 是宿主内部的,客户端插件够不着,所以插件不去碰它们,而是把源会话当前的预设名用同一条命令交给子会话 —— 子会话里留下的命令节点,和你手动切一次留下的是同一个。同样是能力探测:客户端 Session 没有 command 这个方法就只少这一项功能。

开发

仓库自带一个零依赖的离线自检,覆盖清单字段、槽位遮蔽与优先级、菜单交互、两种重试形态(新建 / 分叉)、输入框那一半的语义(不挂起时不接管发送、别的会话不触发、只分叉一次、发出去的是发送那一刻输入框里的文字)、分页窗口判断、删除路由的守卫与真实文件系统行为,以及浏览器真正收到的「多模块拼接成单个 classic script」的投递形态:

node test/check.mjs

退出码非零即失败。测试里的假对象刻意照着真实契约写 —— 一个凭空发明数据形状的假对象什么也验证不了。删除那部分用真的临时目录跑:DSH_HOME 指向一个 mkdtemp 出来的目录,里面放好真实的会话目录,所以被验证的是真正的文件系统行为,不是假的对象。

兼容性

针对 DSH 的 client-modules 协议构建:一个 classic script,通过 window.__ModuleLoader__.load({ id, factory }) 注册工厂,只从 react 取依赖。它依赖 conversation.chat.node 槽位、客户端 sessions / uiWorkspace / locale 服务、conversation 服务的 input 字段(输入 hub,用来拿到会话的输入 shell),以及 node.data 形态的 Chat 视图节点。删除功能另外依赖宿主的 webServer(可选,没有就不注册路由)以及若干内部结构 —— 见上文的风险说明。

License

MIT


English

Resend any user message. Right-click a message in the transcript to either retry it as-is or edit it first — either way DSH forks a new session from just before that message and sends the text as its first prompt. The edit happens in the shipped composer at the bottom of the window, and the fork happens when you press send. A separated toggle below the two entries can also delete the source session once the retry is in flight, and the retry then inherits its name.

A DSH plugin with both halves.

  • Why fork instead of rewriting in place? The durable log is append-only (seq = log.length) and the transcript renders only append-origin surface events, so an in-place surface replace would change what the model sees while the screen kept the old text. Forking is exactly the path the shipped "branch" button uses.
  • Which model the retry runs on: the one you have selected now. Two DSH rules combine into a surprise here — a fork inherits only the event prefix up to the retry point, and a Host resolves a session's model from that session's own log (its last request/header route, or an unconsumed model/selection). So the most natural flow, switching models and then retrying an older message, used to run the child on the model that older turn happened to use. The retry therefore reads the source's modelSelection projection — exactly what the composer shows — and installs it on the child through the same session/selectModel RPC the composer's model control submits through, before the child's first prompt. When the child already resolves to the same route nothing is written; when the route is gone (or the session is held by another writer) the retry is still sent and only a console warning is left behind. One consequence worth knowing: the Host treats that RPC like a manual pick and also saves the selection as the deployment default.
  • Which permissions the retry runs under: the preset you have selected now. Sandbox mode and approval policy are session events as well (sandbox/mode, approval/policy, bundled into an identity by permission/preset), so a fork inherits only the ones in force at the retry point and a fresh session is stamped with the deployment default. Switching to full access and then retrying an older message therefore used to run the child under the older (or default) permissions, with nothing on screen saying so. The retry reads the source's permissions projection — exactly what the composer's permission control shows — and switches the child with /permission <preset>, the very line that control submits, so the Host records the preset and hands each changed knob to its own setter. A target that already resolves to the same preset is left untouched; a preset that is custom, unreadable, or refused is reported and swallowed, because this carry-over is an add-on too. The UI asks you to confirm enabling full access once; a retry does not ask again — you already confirmed and selected that preset in the source session. Like a manual switch, it leaves one command node in the child's transcript.
  • Install: dsh plugin --profile <profile> add github:huangmiuXyz/dsh-edit-retry, then restart DSH. The CLI forwards to pnpm in the profile directory and registers the bundle entry for you. The bare name does not resolve yet — the package is not on npm — and the Desktop app's own profile (--profile desktop) refuses the CLI, so install it there through the in-app plugin manager instead.
  • Two entries: Retry resends the text untouched; Edit and retry puts the text into the shipped composer at the bottom of the window, where you edit it and press send — and the fork happens at that send, not at the menu pick. Nothing is forked until you press send, so changing your mind and clearing the composer leaves no session behind. Retry keeps its old behaviour: it forks and sends at once, without the composer. Both share one fork rule and one resend path. Because the menu closes on click, the operation reports progress and failure in a status line under the bubble, or under the composer for the composer path.
  • Edit mode blurs the conversation. Choosing Edit and retry puts a translucent veil with a backdrop blur over the conversation, so the composer the user is typing in stands out — the same arrangement the reference product uses (a mask class on the content container, a pseudo-element with a backdrop blur at z-index 11, and the input box at z-index 12 above it). The message being edited is exempt from the blur: it is the one thing the user is still looking at. Escape leaves edit mode — the blur lifts, the composer's draft is cleared and the staged retry is dropped, so the text the user abandoned does not go out as an ordinary message on the next send.
  • How the composer path works: the plugin reaches the session's input shell — the conversation service carries the input hub as its input field, and input.for(sessionScope) resolves the resident shell. That shell IS the keyboard the shipped composer receives, and shell.submit is where every send starts (the Enter keymap and the send button both call it). So the plugin patches submit on the shell instance and the shipped composer keeps rendering untouched — editor, model control, permission control, attachment rail, dock, Enter handling, send button. The bubble writes the text in through inputActions.setDraft (the chat-node slot is session-scoped, so it already receives the composer's action face). Shadowing conversation.composer.bar cannot work: a shadowing entry inherits neither the shipped entry's inject (where the keyboard comes from) nor its children (which the shipped InputBar needs), and re-declaring those children is rejected by the registry — which looks like a no-op, because the shadowing entry never enters the slot.
  • Images in the composer path land in the composer's own attachment rail and are serialized by the conversation service out of that rail — what you see in the rail is what goes out.
  • Deleting the source is opt-in and permanent. DSH gives client plugins no way to delete a session — the workspace surface only archives, and the session store has no public remove API — so the delete lives in the host half and is reached over an HTTP route the host registers. It force-stops the agent, flushes, detaches the live entry, removes the on-disk log in both id spellings, confirms it is gone, and only then clears the workspace and projection accounting, because a half-deleted session is worse than an undeleted one. It runs only after the retry actually got in flight: a failed retry leaves the source alone. The route requires a custom header, which is what forces a CORS preflight and keeps other pages out, and session ids are charset-validated and path-containment-checked before touching the filesystem. Once it succeeds, the retry takes over the deleted session's name: the title is read before the delete (which strips the projection it lives in) and written through the session.rename contract — the very call the fork path's own increaseTitle makes — so the sidebar row and the title projection stay in step. A source the catalog has no title for skips the rename, and a refused rename reports "Could not rename the retry" without disturbing the completed delete.
  • Boundaries: text and image messages are retryable (a message carrying a file attachment keeps the native menu); a prompt injected into a running turn renders as a steering node and gets the same menu, its only difference being the fork point — it keeps its predecessor, because it has no closed boundary in front of it; right-click is the only entry point; interactive descendants keep their own context menu; blank text disables Retry while Edit and retry stays available — unless the message carries an image, which is a message on its own; the first human prompt opens a fresh session while later prompts fork at the previous turn's closed boundary; the retry adopts the source's currently selected model and permission preset, falling back to the inherited (or deployment-default) one whenever either cannot be carried.
  • Caveat: the delete walks into DSH internals (sessions.store / detachEntered / storageDomain shapes / the on-disk layout). Every step is feature-probed, so an upgrade degrades this to a refused or partial delete rather than a crash — but it can break. Also, the sidebar row for a deleted session may linger until the next reload; DSH exposes no client-side refresh, and reloading the page would interrupt the retry that was just sent.
  • Images are carried across the fork; file attachments are not. A durable message holds an image as a reference ({type:'image', attachment:{attachmentId,…}}), and session/prompt accepts an image only as inline bytes — so the retry reads each one back through the source session's own readAttachment (authorized against that session's log, hence before the optional delete), re-encodes it as base64, and re-uploads it inline; the Host turns it back into a durable reference on the way in. Part order is preserved by an untouched retry, and an edited one collapses the text parts into the editor's single field. A read that fails costs only that image — it is warned about and dropped, and the retry still goes out, because the user asked to resend the message, not to validate its attachments. Files cannot be carried: the Host accepts a file only as a receiptId minted for one agent-scoped upload, and nothing on the client can read a stored file's bytes to mint a fresh one — session.attachment is image-only (it authorizes through imageBlockIn, which matches type === 'image' alone), and no other remote method reads attachment bytes. A message carrying a file therefore keeps the native menu.
  • How: the plugin shadows the shipped user and steering chat-node cells at priority -1 and renders the shipped component through ctx.slots.entries(), forwarding each cell's locale seat so the shipped bubble keeps its t. Both cells are required even though the shipped registry serves them from one component: the seat d