dsh-edit-retry
Manifest validRight-click any user message to edit it and retry from that point — a client-side plugin for DeepSeek Harness (DSH). · Right-click to edit any user message and retry from there.
dsh-edit-retry
重发任意一条用户消息。 右键会话里的一条消息,可以重试,也可以先改再重试 —— DSH 都从这条消息之前的位置分叉出一个新会话,把文本当作第一句话发出去。
一个 DeepSeek Harness(DSH)客户端插件。
它做什么
在会话记录里右键点击任意一条用户消息,会弹出一个菜单:
| 菜单项 | 行为 |
|---|---|
| 重试 | 原样重发这条消息,不做任何编辑 |
| 编辑并重试 | 气泡原地变成编辑器(预填原文),改完再发 |
| 重试后删除原会话 | 一个开关,勾上后上面两条动作完成后会删掉源会话,并让新会话继承它的名字 |
编辑器里回车换行、⌘/Ctrl + Enter 保存、Esc 取消。两条动作的后续完全一样:
- 从这条消息之前的那个已闭合的 turn 分叉出新会话(见下面的「为什么重试前面不会多一行」);
- 打开这个新会话;
- 把源会话当前选中的模型装到新会话上(见下一节),再把文本(原文或改后的)作为提示词发进去;
- 如果开关是勾上的,最后删掉源会话。
整个过程复用的是 DSH 自带「分支」按钮的同一条路径,所以权限和行为跟原生功能一致。
选择后菜单就关闭了,因此进度和失败会显示在气泡下方(「正在重试…」/「正在删除原会话…」/「正在继承原会话名…」/「重试失败:原因」/「删除原会话失败:原因」/「改名失败:原因」)—— 编辑那条路径则复用编辑器自己的提示行。
为什么重试前面不会多一行「用时 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 结束时 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 自身的语义,插件没有另立一套。
删除原会话
删除是可选、永久的,所以它默认关闭、状态记在 localStorage 里(勾一次就一直是勾上的),而且只在重试真的发出去之后才执行 —— 重试失败会保留源会话,因为那时它是这段对话唯一的副本。
删除成功后,新会话会改名成被删掉的那个会话的名字:源会话没了,它的名字就空出来了,与其让新会话顶着分叉自带的「原名 (1)」,不如直接继承。名字在删除之前读取(删除会把标题所在的投影一并清掉,之后再读就是空的),改名走 session.rename 契约 —— 也就是分叉路径自己 increaseTitle 内部用的同一个调用,所以侧边栏和标题投影会同步更新。源会话在目录里查不到标题时跳过改名,改名失败只在气泡下方报「改名失败:原因」,不会影响已经完成的删除。
DSH 没有给客户端插件任何删除会话的能力:workspace 面只提供 archiveSession(可逆,日志还在盘上),agent 协议的 session_delete 是给外部 ACP agent 用的,而会话存储根本没有公开的删除接口。所以删除做在宿主半边(src/index.js),顺序是有讲究的:
- 停:
ctx.agents.get(id).cancel()再等它静默(最多 15 秒); - 刷盘:
ctx.sessions.flush(),否则 disposes 会在日志删掉之后又把它写回来; - 摘内存:
sessions.detachEntered(); - 删日志:
~/.dsh/sessions/<slug>/<id>/,裸 uuid 和session-前缀两种拼写都删; - 确认删干净(清扫三次,因为一个在途的 dispose 可能重建目录);
- 最后才清掉 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 自带的操作栏(复制 + 时间),而那一行渲染在自带组件内部,从外面接不进去;双击则会和浏览器「选中一个词」的手势打架。
- 中途插话的消息一样能重试。 一个 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 里还压着未取走提示词的情况除外,见上文)。
- 两条菜单项共用同一套分叉规则 —— 是消息的位置决定走新建还是分叉,跟选了哪一项无关。
- 重试跑在源会话当前选中的模型上,而不是被重试那条消息当时用的模型;路由不可用时退回前缀继承的那个,重试本身不受影响。
- 重试会话会落在和源会话相同的 Workspace 分组里,不会掉进 Ungrouped。
- 删除开关对两条路径都生效,并且只在重试已经交给宿主之后才执行;重试本身失败不会删源会话。
- 中英文跟随 DSH 的语言设置,读的是
locale服务的实时快照。
它是怎么实现的
- 用一个下层优先级的槽位遮蔽自带渲染器。 DSH 自带的
conversation.chat.node里user和steering这两格都注册在默认优先级0(同一个组件UserMessageNodeView)。本插件用同样的两个 key、优先级-1各注册一次:渲染器按优先级取每一格的第一个未被让位的条目,所以更小的数字会遮蔽自带的那条。随后插件通过ctx.slots.entries()取出自带组件本身来渲染,因此气泡样式、图片渲染、复制和时间戳仍然归产品所有。 - 两格都必须遮蔽,哪怕自带的是一个组件。 座位是按节点的 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 命名空间就只少这一项功能,菜单和重试照旧。
开发
仓库自带一个零依赖的离线自检,覆盖清单字段、槽位遮蔽与优先级、菜单交互、两种重试形态(新建 / 分叉)、分页窗口判断、删除路由的守卫与真实文件系统行为,以及浏览器真正收到的「多模块拼接成单个 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 服务,以及 node.data 形态的 Chat 视图节点。删除功能另外依赖宿主的 webServer(可选,没有就不注册路由)以及若干内部结构 —— 见上文的风险说明。
License
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. 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/headerroute, or an unconsumedmodel/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'smodelSelectionprojection — exactly what the composer shows — and installs it on the child through the samesession/selectModelRPC 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. - 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 opens an editor first. Both share one fork rule and one resend path, so only the text differs. Because the menu closes on click, the operation reports progress and failure in a status line under the bubble.
- 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.renamecontract — the very call the fork path's ownincreaseTitlemakes — 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
steeringnode 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 falls back to the inherited one if that route is unavailable. - Caveat: the delete walks into DSH internals (
sessions.store/detachEntered/storageDomainshapes / 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,…}}), andsession/promptaccepts an image only as inline bytes — so the retry reads each one back through the source session's ownreadAttachment(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 areceiptIdminted for one agent-scoped upload, and nothing on the client can read a stored file's bytes to mint a fresh one —session.attachmentis image-only (it authorizes throughimageBlockIn, which matchestype === '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
userandsteeringchat-node cells at priority-1and renders the shipped component throughctx.slots.entries(), forwarding each cell'slocaleseat so the shipped bubble keeps itst. Both cells are required even though the shipped registry serves them from one component: the seat dispatches on the node's kind, so a shim registered onuseralone never sees a message the transcript classified as steering. The client half imports nothing but React. - Test:
node test/check.mjs— dependency-free offline self-check. The delete half runs against a real temporaryDSH_HOME, so the filesystem behaviour under test is the real one.
Comments
Loading…
Similar plugins
by mbj733
DeepSeek Harness (DSH) plugin: edit sent messages and resend (stop an in-flight reply, edit, resend), plus branch-based edit / reroll / retry and a version timeline.
★ 4
↓ 47/wk
MIT
TypeScript
Sep 9, 2026
dsh plugin --profile web add dsh-edit-resendby suzuran520yyz
DSH Web GUI 插件,用于对会话消息进行编辑、重试、高级重试和删除等功能的实现
★ 0
MIT
JavaScript
Sep 3, 2026
dsh plugin --profile web add dsh-more-message-actionsby lxl8182
DSH Web 用户消息编辑重发插件:改掉某一轮提问重发,该轮之后的内容丢弃,原会话归档(新会话继承逐字节相同前缀以保住 prompt 缓存)。
★ 0
MIT
JavaScript
Sep 30, 2026
dsh plugin --profile web add dsh-message-editby Moeblack
DSH 插件:分支式消息编辑、重掷、重试与版本时间线 | DSH plugin: branch-based message editing, reroll, retry, version timeline
★ 49
TypeScript
Aug 16, 2026
dsh plugin --profile web add dsh-message-editby perdakovich
click the pencil, edit your text, enjoy
★ 0
MIT
JavaScript
Sep 21, 2026
dsh plugin --profile web add dsh-plugin-prompt-editby Yaya716
Persistent user-message tools for DeepSeek Harness Web: hover actions on user bubbles in the chat flow (copy, and edit-and-resend as a new message).
★ 0
JavaScript
Aug 15, 2026
dsh plugin --profile web add @local/dsh-msg-tools