dsh-restart-task
DSH Web 插件:同一步内的可见重试(放大产品自带重试预算并渲染成实时倒计时)、输出超限同轮续写、可选的新开一轮继续;输入框为空时把「继续」交给发送键。 | DSH web plugin: a visible in-turn retry with a live countdown, an in-turn keep-alive for truncated output, and an opt-in continuation turn.
- Stars
- 0
- Language
- JavaScript
- Created
- Sep 25, 2026
- Updated
- Oct 3, 2026
Introduction
description: "DSH Web 插件:把被打断的回合救回来 —— 把失败请求的可见重试预算调宽(由产品自带的重试执行器渲染成实时倒计时)、输出被截断时同轮续写、可选的新一轮继续;输入框为空时发送键本身会变成「继续」。" kind: "package-reference"
dsh-restart-task
中文 | English
DeepSeek Harness Web profile 插件:输入框里的一个回合恢复控件、它背后的三段式恢复策略,以及一整套设置卡片。
所有设计只服务于一条原则:恢复过的请求不该留下痕迹。
| 层级 | 何时生效 | 对话记录里多出什么 |
|---|---|---|
| 1. 调宽可见重试预算 | 模型请求失败 | 一条产品自带的重试行(实时倒计时,可停;重试结束后这条记录被收走,见 §2.1) |
| 2. 同轮续写(keep-alive) | 回复撞上输出上限 | 不新开回合;续写那一行被隐藏 |
| 3. 新开一轮继续 | 回合已经以失败收场 | 一个回合 + 一条折叠记录(默认隐藏) |
0. 适用版本
针对 DSH 0.1.7-rc.1 及其后的 0.1.x,以及 0.2.x 编写,并且在 loader 会真正校验的地方写明了这一点:peerDependencies["@deepseek-ai/dsh"] 为 >=0.1.7-rc.1 <0.1.8-0 || >=0.1.8-rc.1 <0.2.0-0 || >=0.2.0-rc.1 <0.3.0-0(强制字段),声明性的 engines.dsh 与之保持一致,插件自带的 @deepseek-ai/schemastery 为 ^3.18.4(第一个支持 .volatile() schema 的版本,下面的配置模型依赖它)。
范围为什么要分段:node-semver 只有当范围里某个比较符与该版本的
major.minor.patch元组完全一致、且自身带预发布标签时,才放行预发布版本。写成看着更宽的>=0.1.7-rc.1时,0.1.8-rc.1、0.2.0-rc.1这类下一条线的 rc 会被静默排除,用户只会撞上 ERESOLVE(或 loader 直接跳过这个 bundle)。所以每个受支持的元组各占一段:>=0.2.0-rc.1才能让0.2.0-rc.1(npm 上的next标签)这个预发布自身通过,<0.3.0-0把下一个次要版本挡在外面。DSH 自 0.1.7 起就在安装与启动时按这个字段校验(evaluatePluginCompatibility,semver.satisfies(..., { includePrerelease: true }),只看peerDependencies,不看engines),装错版本会给出明确提示而不是运行时崩溃。
版本之所以关键,是因为本插件赖以生存的两条接缝都变了:
- 设置不再是插件自己注册的命名空间。 0.1.7 里
settings.register(namespace, schema, { applies: 'live' })与settings.get(namespace)已被移除且没有继任者:插件的Config本身就是它的设置表单,而表单用 profile 条目 id 来寻址。见 §8。 - 插件消息不再是
{ kind: 'plugin', plugin }包装。 会话格式 v4 要求「生产者自有」的source.kind;本插件用自己的包名。见 §4。
在更旧的运行时上,bundle 会在启动时被 loader 按兼容性规则直接跳过(并给出 dsh plugin allow-version 这条明确的豁免途径)—— 这是诚实的结局:上面两条接缝在那里并不存在。0.2.0-rc.1 仍沿用同样的接缝(agent/request-error 瀑布、retryPolicy、agent/turn-stopping、会话投影、设置表单与 composer slot 都未变),所以放开兼容范围只需要改清单。
1.5.0 针对 0.2.0-rc.2 做的核对与补齐。 上面每一条接缝都按运行中的 0.2.0-rc.2 实例重新核对过:agent/request-error 的 retryPolicy 仍是 ResolvedRetryPolicy(mode / maxRetries / retryableCodes / 扁平退避块),agent/turn-stopping 仍是 awaited 的 serial 事件、agent.steer() 仍把消息投进 next-step,agent.followup() 仍开新回合,settings.configure(presentation, fiber) 与「表单按 entry.options.id 寻址」的模型未变,conversation.input.right 与 plugins.bundle.config 两个 slot 也仍在原位。顺带补上了两处 0.2 才暴露出来的问题:max-tokens 这个回合级结束原因(见 §7),以及同轮续写可能把指令塞进一个人已经停掉的回合。
1.5.1。 可见重试开始自己收尾:重试等待期间实时倒计时照常保留,重试结束(成功重发或被取消)后它留下的那条「已重试模型请求(N/M)」/「模型请求重试已取消(N/M)」记录会被收走(见 §2.1,hideSettledRetry)。
1. 手动控制
当上一轮已经坏掉时,输入框动作区会出现一个圆形控件,位置就在产品自带发送键的左侧。
| 会话状态 | 控件行为 |
|---|---|
上一轮以 error / aborted / interrupted 结束 | 继续 —— 请宿主从中断处接着做 |
上一轮回复撞到输出上限、收尾那步确实被截断(max-tokens) | 继续 —— 接着把没写完的部分写完 |
上报了 agent 级错误(lastAgentError) | 继续 |
只有最后一次发送失败(promptError) | 重发上一条输入 —— 历史里没有可接续的内容 |
| 正在运行、空白会话、无事可做 | 不渲染 |
| 输入框里已经有内容 | 不渲染 —— 那条消息马上要发出去,不在它旁边提供「继续」 |
结果反馈就在按钮内部(转圈 / 绿勾 / 红色 !,说明文字在 title 里)。本插件渲染的任何东西都不插入布局:早先的版本在输入框下方加一行提示,结果每次状态变化都会让输入框抖动。
1.1 发送键本身(sendBecomesContinue,默认开)
「有继续可做」恰恰是产品发送键最没用的时刻:输入框为空时产品会把它置灰(empty || blocked || uploadsPending)并降到 40% 不透明度 —— 没东西可发,也没东西可点。所以圆形控件不是唯一的门:当有继续可做且输入框没有别的内容可发时,本插件把同一个动作交给发送键。
| 输入框 | 发送键 |
|---|---|
| 空,且上一轮中断 | 变为可用、警告色、圆形箭头、aria-label="继续上次任务" —— 点一下即从中断处继续 |
| 你正在输入 | 不动:它照旧是发送键,送你写的内容 |
| 智能体正在运行 | 不动:它照旧是产品的 停止 / 排队发送 / 插话发送 |
机制上,这是本插件唯一写入产品元素的地方,而且写得很克制:
- 输入框的主按钮是 inline JSX、没有席位(composer 只开放
conversation.input.{left,right,model,attachments,activity,dock,overlay,permission,plan},没有 submit 席位),所以遍历只给那个元素打上本插件自己的属性data-dyn-continue="1",外观全部由本插件样式表负责;从不触碰 React 拥有的 class。 - 只接管产品用不了的那个控件 —— 判据是它自己的
disabled。这正是「你正在输入时它仍是发送键」的原因。接管即清掉disabled;React 若写回,观察器会把接管补上。但正因为接管清掉了它自己据以判断的信号,遍历每轮都会重新读草稿本身,点击的那一刻还会再读一次:输入框一旦有内容,接管立即放手、产品原本的标签恢复、你的消息正常发出去。一个在输入框非空后仍然赖着不走的接管,会把「你写的消息」变成「继续旧任务」—— 那比不接管更糟。 - 点击在
document的捕获阶段拦下并就地终止:产品的处理器挂在 React 根容器(更靠下),所以发送路径不会同时触发;窗口里其它点击一律放行。 - 动作不重写:接管后的按钮点击的是本插件自己的圆形控件(接管期间被 CSS 隐藏但仍在 DOM 里),两者共用同一个 busy 闸门、同一个双击围栏、同一份结果反馈 —— 按钮会跟着变绿 / 变红 / 转圈。
- 交还时移除属性、恢复产品原本的
aria-label,并恢复产品对空输入框的判定(disabled):不恢复的话,按钮会以「可用但无事可做」的状态留在那儿,之后再也无法通过它提供继续。输入框里有内容时则保留产品渲染的状态,因为那条消息正是必须能发出去的东西。 hideContinueRow与这个开关互相独立,卡片里都能关掉。关掉接管后,圆形控件是唯一的入口,发送键则完全保持产品原样。
已知边界:它读的是产品渲染而不是声明式 API,若某版本改了主按钮的 class 或去掉 data-composer-card,接管会静默停止(不会报错,圆形控件照常工作);在产品因自身原因禁用发送键的少数状态(有上传在跑、等待授权)下,接管同样会生效;接管期间产品自带 tooltip 仍显示「发送消息」(可访问名与 title 是本插件的);Enter 键刻意没动 —— 产品把它注册为只读固定动作,劫持它会让「空草稿 + 回车」变成不请自来的开工。
2. 第 1 层 —— 调宽可见重试预算(默认开)
agent/request-error 是瀑布(waterfall):本插件用 prepend 把自己挂在产品自带的 dsh-llm-retry 之前,在失败的一步上就地把 payload.retryPolicy 换成一份更宽的策略,然后 next() 委派 —— 由重试执行器读到这份更宽的策略并自己完成重试。因此每次重试都是产品渲染的那条实时倒计时行(用户看得见、能取消),而持久化的 llm/retry 事件始终由持有其不变量的代码来写,本插件从不自己伪造。
普通模式下换上的策略是 { mode: 'normal', maxRetries: maxRequestRetries, retryableCodes, …退避 }:maxRetries 取自本插件的预算(默认 30),退避基数取自 retryBaseDelayMs(上限 60s、20% 抖动),而 retryableCodes 沿用该 provider 自己解析出的那份(provider 没有则回落到产品默认集)—— 也就是「重试预算与节奏由本插件定,什么错误值得重试仍由 provider 定」。确定性客户端错误(如 401、配额耗尽)不在该集合里,因此仍然快速失败,调宽预算不会把错误的 API Key 变成死循环。
打开 retryForever 换上的是 { mode: 'always', …退避 }:执行器会对任何失败无限重试,没有终端错误的守卫 —— 所以它默认关闭,需要无人值守长任务时才开。
只有确实路由到了某份 retry 策略的一步才会被调宽:一个请求若连 adapter 都没到达(payload.retryPolicy 为空),执行器本就会拒绝,硬塞一份策略也救不了它,于是原样委派,保留执行器自己的拒绝路径。因为重试完全交给产品的执行器来做与计数,本插件不再自己等待、不再维护每步计数器。
2.1 已结束的重试记录不再留在对话里
重试进行中的那条实时倒计时必须留着 —— 那是「可见重试」的全部意义。但重试一旦结束,产品仍会把那条记录留在对话里:重新发出请求后它变成「已重试模型请求(N/M)」,回合在等待中被关闭时它变成「模型请求重试已取消(N/M)」。请求已经成功、或者已经放弃,这条记录对用户就只剩噪音。
所以 hideSettledRetry(默认开)补一条条件样式,与 §6 的续写行规则并列、同样只在有设置域时受开关控制:
[data-chat-flow-kind="model-retry"]:not(:has(details[data-active])) { display: none !important; }
判据全部取自产品自己的渲染,没有一处是猜测:model-retry 是产品给重试节点起的 flow kind,而 data-active 是产品在「正在等待下一次重试」时给那个 <details> 加的属性 —— 重新发出请求(started)或取消(cancelled)之后 React 就不再输出它。于是这条规则只收走已经结束的那一条,倒计时照常跳动、也照常可点开看延迟与失败原因。
它也不去分辨「这次重试是谁发起的」:只要本插件在接管重试预算(retryFailedRequests 为开),对话里的重试就都是它调宽后的结果;把「启用可见重试」关掉时这条规则自动失效,产品自己预算内产生的重试记录仍按产品原样显示 —— 隐藏别人 UI 的事,本插件不做。与 §6 的续写行一样,这读的是产品渲染而不是声明式 API:某版本若改了 flow kind 或去掉 data-active,规则会静默失效(记录只是重新显示出来,不会误伤别的行)。
3. 第 2 层 —— 同轮续写(不新开回合)
当一步因为模型撞上输出上限而结束时,回合即将关闭。agent/turn-stopping 就是可以提出异议的边界:调用 agent.steer(...) 会在边界提交前把新输入放进收件箱,循环随即在同一个回合内再跑一步。
「回合即将关闭……提出异议的监听器会 steer(
agent.steer(...)),机器重新读取收件箱:新的 steering 会再跑一步,谁都不会关闭回合。」 ——dsh-agent/lib/types/runtime-types.d.ts,agent/turn-stopping
于是轮次数、轨道和历史都不移动。受 maxTurnContinues 约束(每回合),且仅当该步的结束原因真的是 max-tokens 时才触发 —— 这个原因取自实时 agent/assistant-stream 的 finish 帧,比边界早一帧,因此无需翻会话日志。
两条诚实限制:
- 该边界只在正常停止时到达。
aborted与error是经throw离开循环的,所以第 2 层对它们永不适用。 - 被 steer 的指令仍是一条落盘的
user/message—— 它渲染为一条折叠记录(见 §6),永远不会变成你的发言气泡。
4. 第 3 层 —— 新开一轮继续(默认关,可见)
当回合真的以失败收场时,继续动作会通过 agent.followup 发出一条插件来源的消息:
{ role: 'user',
content: [{ type: 'text', text: '<continue instruction>' }],
source: { kind: 'dsh-restart-task', form: 'notice',
summary: '继续上次中断的任务' } }
source.kind !== 'user' 是它不会变成用户气泡的原因:它渲染为一条折叠记录 —— 而且因为这条消息开启了这一轮,产品会把它渲染成「非人类触发」通知而不是上下文行(§6)。这个 kind 必须是本生产者自己的名字:会话格式 v4 要求生产者自有的 source kind,并拒绝已退休的插件命名空间包装(kind: 'plugin' + plugin 字段),后者只会在 v3→v4 转换既有历史时被抬升。写成旧形状会让整个回合失败并报 format v4 message requires a producer-owned source kind,因为继续消息是在该步能运行之前就被追加的。但它仍然是一个新回合,所以对话记录会多出那一行、轮次轨道会多出一格 —— 这正是 autoContinue 默认关闭、并提供手动控件按需使用的原因。
resend(手动、仅发送失败时)是唯一会发出用户文本的路径 —— 一条从未到达宿主的输入不在历史里,重发它是唯一可能有效的做法。它不注册乐观回显,因此不会出现两行。
5. 为什么已结束的回合不能就地继续
这是 agent 循环的结构性约束,不是缺 API。事实如下,备查:
- 新回合号只能是
previous + 1,并且总是以turn/start宣告(dsh-agent-loop/lib/index.js,turn())。 turn/end在finally块里被追加,任何路径都会执行,所以坏掉的回合在任何插件听说它之前就已持久关闭。- 每一条进入 step 的消息都以
user/message+surfaceOp: 'append'追加;插件无法为「唤醒循环的那条消息」选择非追加的 surface op。 - 会话事件是只追加的:
Session接缝与持久化层都没有 remove / truncate / rewrite API,因此继续动作无法在事后被剪掉。(dsh-rewind-plugin也不删除事件 —— 它追加替换 surface,而替换副本只对模型可见。) interrupted标记由崩溃修复路径写入,它在事后关闭一个孤儿回合;循环从不实时发出它,恢复会话也不会唤醒驱动。
所以「能否不新开回合就继续」的完整答案就是第 1、2 层;对已经结束的回合,第 3 层是唯一可能的做法。
6. 隐藏续写行
第 2、3 层的记录存在,是为了让模型读到指令;它们是管道,而 Chat 给它们只有两种形态 —— 两种都无法用样式表单独选中:
| 形态 | 何时 | 靠什么识别 |
|---|---|---|
| 折叠的上下文行 | 消息没有开启该轮(第 2 层:被 steer 的步骤) | 一个无值的 data-context-source 属性,生产者名字是文本 |
非人类触发通知(data-chat-flow-kind="turn-trigger") | 消息开启了该轮(第 3 层、手动继续) | 什么都没有 —— 未知 source kind 连「请求触发」都区分不出来 |
所以 hideContinueRow(默认开)是一个小型的对话记录遍历,而不是一个选择器:凡是能被归到本插件名下的行都会被打上 data-dyn-restart-row="1",再由一条条件样式隐藏本插件自己的标记:
[data-chat-flow-kind="context"][data-dyn-restart-row="1"],
[data-chat-flow-kind="turn-trigger"][data-dyn-restart-row="1"] { display: none !important; }
归属有两条来源,且从不猜测:
- 来源标签恰好等于本插件
source.kind的上下文行。 - 本插件所开回合里的触发通知行 —— 轮次来自实时会话事件窗口(
ownWakingTurns),窗口由当前显示会话的输入框控件发布。回合的输入是在它的turn/start之后追加的,绝不会在之前(持久顺序是turn/start(7)→user/message(kind: dsh-restart-task)→ …),所以一条消息恰好是它落入回合的第一条输入时,就说明这一轮由它开启。第 1、3 层经 next-turn 收件箱投递,这正是它们的行会成为触发通知的原因;第 2 层的 steer 由已开启回合的某个 step 认领,因此永远不是第一条输入,也永远不占轮次。窗口尚未就绪时该规则被跳过,规则 1 仍然覆盖第 2 层。
第二条来源是累积的,而第三条让归属覆盖整会话。客户端手里永远只是一段分页窗口,折叠一个已完成的回合或加载另一片历史都会把它换成更小的窗口;只按当前窗口重算的集合会忘掉它已经认出的回合,轨道标记就会回来,直到下一片历史到达。所以:
- 两条来源说过的任何内容都会记住,直到会话切换才清空;
- 宿主半边注册了一个 session projection(
restartTaskRounds:同一套规则,但折叠的是整份日志而不是一页),projection 接缝把它的值整份下发给客户端,浏览器半边再并入集合。这正是「事件根本没被加载」的那种回合能被归属的原因 —— 分页转录(加载更早)下屏幕上看不出那一轮是谁开的。
其它生产者的行 —— 时间上下文、AGENTS.md、技能、cron、子代理结算、目标回合 —— 永不打标、永不隐藏。遍历是幂等的(React 重渲染不会碰别人的属性),开关关闭或插件卸载时会清掉自己的标记,读不懂的视图就什么都不做,而不是去隐藏一个归属不明的行。因此「丢失归属」只是观感上的损失(某行继续显示),绝不会变成对别人行的错误猜测。
诚实说明:第一条规则读的是产品渲染而非声明式 API,所以某个版本改了 flow 属性或改了来源标签文案,遍历就会停止匹配(不会坏掉 —— 那行只是重新显示出来);第二条与第三条只在宿主注册了 projection 时生效,没有该接缝的组装回退到纯窗口归属(那时未能加载的回合仍要等历史被加载才能归属);另外这些规则不会碰第 3 层回合在轨道上的那一格(见 §7)。
7. 轮次轨道
第 3 层的继续是一个真实回合,所以轨道会为它多出一格 —— 而因为这一轮没有人类提问,那一格的悬浮卡只能退化成匿名的「第 N 轮 / Turn N」。这个退化不是插件能改的数据:轨道是 inline JSX、没有扩展点,而整会话的 turnOutline 投影只接受 source.kind === 'user' 的提问预览(dsh-session-turn-outline/lib/types/projection.js)。
因此 continuedRailMarks 把它当作呈现问题,提供三种模式:
| 模式 | 效果 |
|---|---|
hide(默认) | 该轮的轨道标记(连同悬浮卡)不显示 |
preview | 标记保留(仍可点击跳转);悬停时不再显示「第 N 轮」,只显示该轮答案预览 |
keep | 产品自身行为,完全不干预 |
机制上这是一次很小的 DOM 遍历,不是样式表技巧。轮次来自 ownWakingTurns 归属给本插件的轮次集合(§6)—— 是轮次本身,而不是被打标的行:第 2 层的 steer 会隐藏「人类自己那一轮」里的一行,但那一轮的轨道标记绝不是本插件该碰的。每个轨道标记通过它的可访问标签(它唯一的逐轮属性)匹配到轮次,然后按配置模式打标:
[data-dyn-continued='hide'] { display: none !important; }
nav[data-dyn-continued-preview='1'] [class*='_previewPrompt'] { display: none !important; }
本插件自己那一轮的标记被打上 hide/preview,其它标记完全保持产品原样。因为 hide 是给标记本身 display: none,悬浮卡就失去了挂载对象:那一轮的「第 N 轮」提示根本不会出现。
只隐藏标记只做了一半。轨道的虚拟化会给这一轮预留位置 —— 它按自己的测量结果决定标记容器的高度,且从不因隐藏而重排;产品本身也不写任何逐标记几何 —— 所以被隐藏的标记会在轨道中间留下一个空洞。因此遍历会压实屏幕上的内容:量出当前渲染出的标记之间的间距,把被隐藏标记之后的每一个标记上移一个间距,并让容器高度减去隐藏轮数。这两处都只是本插件自己的内联覆盖,每次遍历重算,一旦没有需要隐藏的标记或插件卸载就移除,轨道因此会回到产品原本画出的样子。
诚实限制:只能标记并压实视图真正渲染过的轮次(远离已加载窗口的轮次会在滚动到之后被处理);标记是虚拟化的,所以任意时刻只处理轨道自身滚动窗口里的标记;它通过可访问标签匹配,因为标记本身没有轮次属性;轨道的 DOM 在 0.1.7 变了(标记容器里的 button[data-index],没有逐标记位置包装),本遍历读的就是这个;压实是对产品自己会重算的几何做补偿 —— 一次重渲染会把标记放回虚拟化想要的位置,而下一次遍历(隔着一次 DOM 变动)会再次压实。
8. 设置卡片
插件页 → dsh-restart-task(侧边栏里由插件管理器拥有的那一页)。卡片注册进该页的 plugins.bundle.config 席位,键是 bundle 的包名(dsh-restart-task)—— 这是该页自己的契约(「自带配置的 bundle」),也是 0.1.7 重构后唯一保留的席位(旧的 settings.plugin.item 标签页已消失)。只有当宿主提供本插件的设置条目时才会注册,所以从未挂载该行的部署看不到本卡片的任何痕迹。
卡片拥有自己的全部外观:可折叠标题(带实时策略摘要)、四个分组、逐字段的「已自定义 / 恢复默认」,以及带写入状态和「全部恢复默认」的页脚。


| 字段 | 默认 | 含义 |
|---|---|---|
retryFailedRequests | true | 请求失败时放大产品的可见重试预算(可看到倒计时) |
maxRequestRetries | 30 | 可见重试次数上限(0 = 不重试) |
retryForever | false | 忽略预算,重试到成功或中止(切到产品的 always 模式,无终端错误守卫) |
retryBaseDelayMs | 2000 | 退避基数:2s、4s、8s… 上限 60s |
keepAliveOnMaxTokens | true | 让被截断的回合保持开启,在回合内续写 |
maxTurnContinues | 5 | 单个回合内允许续写几次(0 = 不限) |
autoContinue | false | 坏掉的回合之后自动开新一轮继续(可见) |
delayMs | 1500 | 中断之后等待多久再继续 |
maxConsecutive | 3 | 连续继续的轮数上限(0 = 不限) |
onAborted | true | 系统取消(父 agent / hook)之后继续;用户主动停止永不继续 |
onError | true | 模型请求失败之后继续 |
onInterrupted | true | 崩溃遗留孤儿回合之后继续 |
onMaxTokens | true | 回复撞到输出上限、没写完之后继续(只在本轮最后一步确实被截断时触发) |
sendBecomesContinue | true | 输入框为空时,把继续交给产品发送键(见 §1.1) |
hideContinueRow | true | 在 Chat 里隐藏本插件自己的折叠行 |
hideSettledRetry | true | 重试结束后收走那条「已重试 / 已取消」记录;重试中的倒计时保留(见 §2.1) |
continuedRailMarks | hide | 轨道如何呈现本插件继续出的轮次(hide / preview / keep) |
continueText | (内置) | 发给模型的继续指令 |
autoContinue 关闭时,onAborted / onError / onInterrupted / onMaxTokens 会被禁用,因为它们只描述那一层何时触发。
onMaxTokens为什么需要单独的判定:DSH 只要任意一步撞到输出上限,就把该回合的turn/end原因钉成max-tokens,并且后续步骤无法把它降级。所以「被截断后同轮续写、并且正常写完」的回合,和「真的没写完」的回合,带的是同一个原因。插件用收尾那一步自己的 finish 原因区分两者(宿主侧来自agent/assistant-stream,浏览器侧从assistant/message自带的 stream 记录里读回):只有收尾步骤也停在max-tokens时才算未完成。这条判定同时保证第 2 层和第 3 层不会对同一次中断重复出手。
卡片为什么会说「宿主没有接受这次写入」:
configForms对被拒绝的写入是 resolvefalse,不是 reject(ConfigFormController.mutate在!response.ok时返回false,enqueue在内存持久化模式下直接 resolvefalse)。早先的版本只挂成功回调,于是被拒绝的写入也会显示「已生效」—— 编辑器里留着用户自己输入的值,看上去一切正常。现在每个字段的写入、单字段恢复默认、以及「全部恢复默认」都会读这个布尔值,只有全部落地才报成功。数值输入框同时带上了宿主 schema 的.max()上限,让这类拒绝根本不会发生;回归测试会把卡片里的上限表和宿主 schema 逐项对比。
1.2 只有一个 composer 时发送键才接管
发送键接管这件事,靠的是两个「全局」事实:本插件的待继续状态是插件级的一份(由最后渲染的那个会话的控件发布),而 primaryButtons() 会扫过整个文档的 composer 卡片。页面上只有一个 composer 时,这两个事实指向同一个东西;出现第二个时就不成立了 —— 最后发布的那个会话的状态会去装扮另一个会话的发送键,点击也无法再归因到某个会话。
所以 soleComposer() 是这条路径的前置条件:检测到多于一张 [data-composer-card] 就整体退让,把已经接管的按钮全部交还,不装扮任何一张。同一次点击的解析也从「文档里第一个 .dyn-retry-round」改成「被点击按钮自己那张卡片里的控件」(controlInCard(cardOf(button))),fallback 分支在多卡片时同样拒绝出手。
产品每个会话只渲染一张卡片(renderSlot('conversation.composer.bar', …) —— 空会话的 hero 外观是同一个渲染的 variant,不是第二个实例),布局又通过 keyed 的 main slot 挂载中央面板,所以「一张卡片」是当前常态,这条守卫是为它将来不成立的那天准备的。无论如何,退让只让功能不生效,绝不让它继续错任务;圆形控件按自己的 slot 作用域工作,不受这条守卫影响。
值是怎么进出的(0.1.7 的配置模型)
设置命名空间已经不存在,所以接线是这样的:
- 宿主半边的
Config就是表单。 每个字段都标了.volatile(),这是它能被实时编辑的前提;loader 随后给apply每个字段一个引用,config.<field>.get()就是三层策略每次决策时读取的东西。一次写入在下一次中断时生效,无需重启,也不涉及任何插件事件。 - 表单用 profile 条目 id 寻址,即
restart-task—— 本包cordis.patch.yml插入的那个 id。它不是包名,而浏览器半边无法 import 宿主半边的常量,所以两半和 patch 文件各写一遍;回归测试会断言三者一致。 - 写入经
configForms.get('restart-task')(浏览器半边为该条目共享的表单):快照给出被服务的表单值加原始用户层,逐字段set/unset,底下是带版本号的改动队列。被拒绝的写入返回false,冲突则重新读取宿主而不是覆盖它。 - 宿主不会再生成一个页面:插件在可选的
settings子上下文里注册settings.configure({ auto: false }, ctx.fiber)。这个子上下文是刻意可选的 —— 没有设置域的组装仍然能跑三层恢复和输入框控件,只是卡片和两个对话记录遍历不出现。
当客户端与宿主不一致时
浏览器半边可能比正在运行的宿主新 —— 这是插件更新后、profile 重启前的常态。此时条目可能没有被服务,或缺少较新的字段,卡片会说明而不是让写入失败:宿主未提供的字段以只读显示「宿主还是旧版本:重启 DSH 后这一项才会生效」;宿主根本没有提供该条目时提示「宿主没有提供本插件的设置入口」;非回环页面(设置仅在该会话内存中)也会被标注。
结构
cordis.patch.yml—— profile bundle patch,插入restart-task行;这个 id 同时就是设置条目 id,文件里的注释也这么写。lib/index.js—— 宿主半边:每个字段都.volatile()的Config、同轮重试、同轮续写、可选的继续回合、continue-task命令。lib/client.js—— 浏览器半边:window.__ModuleLoader__.loadbundle,导出apply+inject(['slots', 'timer'])、输入框控件、插件页卡片(挂在可选的configForms子上下文上),以及给本插件自己的行打标、呈现其继续出的轮次、并在输入框为空时把继续交给产品发送键的对话记录遍历。restart-task.test.mjs—— 回归测试(见下)。screenshots.json—— 市场详情页展示的 1–8 张截图(见 §8);docs/awesome-dsh-plugin-entry.yml—— 提交或更新awesome-dsh-plugin收录时复制过去的那一条目。截图刻意放在本仓库: 换图只需往这里推一次,不用去那边提 PR、等维护者。
输入框控件的形状刻意用 !important 钉住:它住在产品的 composer 工具行里,那一行有自己的 button/svg 规则。设置卡片使用产品的 --dsw-alias-* token,并在不依赖任何东西的前提下匹配其卡片外观(.5px 边框、16px 圆角、14–16px 头部内边距)。两个注入的 <style> 标签都带 data-plugin,因为模块系统按该属性归属样式:没有归属的标签会被下一个物化的插件认领,并在那个插件卸载时被删掉。
有三处区域被直接断言 —— lib/index.js 的 auto-logic、lib/client.js 的 core-logic,以及宿主模块自身的导出 —— 所以出厂代码跑的就是被测的决策:
node dsh-restart-task/restart-task.test.mjs # 426 条断言,两半都覆盖
测试分四层,因为移植到 0.1.7 时坏过三种方式,而行/轨道的呈现只能看它对文档的实际效果:
- 规则 —— 重试预算调宽(关时不调宽、普通模式取插件预算并沿用 provider 的
retryableCodes、retryForever换always、退避基数与 60s 上限)、保活门槛(只有max-tokens、每轮上限)、回合级门槛(坏掉的原因、逐原因开关、连击上限)、对话记录读取(只认人类提问、坏掉与干净的结束)、开轮识别、控件模式选择、头部摘要文案。 - 接线 —— 所有身份字符串一致(patch 条目 id ↔ 宿主
ENTRY_ID↔ 客户端ENTRY_ID,包名 ↔ bundle 席位键 ↔ 生产者 kind),每个Config字段都是 volatile 且每个被服务的字段都由卡片渲染,已退休的设置接缝、席位、选择器全部消失,行隐藏规则以本插件自己的标记为键,两个样式标签都写明归属。 - 宿主行为 —— 真正 import
lib/index.js,并用桩 Cordis 上下文驱动:命令只发一条生产者来源的消息;失败的一步就地把payload.retryPolicy换成更宽的策略后委派(没有路由到策略的一步原样委派、被停止的一步原样委派);被截断的一步在回合内 steer,而完成的一步不会;回合级继续只在开启时发生,且永不发生在用户停止之后,也永不越过连击上限。 - 对话记录、轨道与发送键(桩 DOM) —— 加载浏览器半边并应用到一份假 document 上:里面有一个本插件开的轮次、一个人类开的轮次(其中带着本插件的同轮 steer)、别的生产者的上下文行与触发通知,以及一个主按钮初始为禁用的输入框。结果是:本插件自己的行被打标而别人的没有;steer 行只靠来源标签就能认出,而触发通知需要会话窗口;本插件那一轮的轨道标记在
hide模式下被隐藏、preview模式下被标记,而人类那一轮与外部轮次的标记永不被动;发送键被接管、被点击(点击在产品看到之前就被截停,改由圆形控件执行)、在输入框有内容时不被接管、开关关闭后被交还;窗口缩小时(折叠/分页)不会把已经认出的轮次收回。事件夹具抄自真实的session.v4.jsonl,包括决定归属的turn/start→ 消息顺序。
延伸阅读
下面这些页面是本插件依赖的接缝,方便逐条对照它声明的版本核对:
- DeepSeek Harness —— 运行时、插件契约,以及全文引用的各包:
dsh-agent(Agent 外观与agent/request-error/agent/turn-stopping分发)、dsh-agent-loop(回合与 step 边界)、dsh-settings(配置表单)、dsh-session-format(v4 来源规则)、dsh-client-ui-conversation(输入框及其席位)、dsh-client-ui-chat(flow 行与轮次轨道)。 dsh-client-shortcuts—— 本插件刻意不改绑的固定输入动作(fixed.send=Enter、fixed.newline=Shift+Enter、fixed.complementary=Ctrl/Cmd+Enter);「忙时回车」到底是排队还是插话,是ui-conversation设置里的busyEnter。dsh-llm-retry—— 挂在本插件上游、耗尽自身策略后委派给它的产品重试执行器。dsh-client-ui-plugin-manager—— 拥有plugins.bundle.config席位(本卡片注册的地方)的插件页。
模型体验
分层的模型可见性:
- 第 1 层对模型不可见:本插件只放大产品的重试预算,重试由产品的
dsh-llm-retry执行、用同一段持久历史重建同一个 step,模型读到的仍是干净前缀。可见的llm/retry倒计时是给人看的 UI,不进模型请求。 - 第 2 层多出一条
user/message,其source.kind是本插件名:模型读到指令,人类读到一条折叠记录(默认隐藏)。 - 第 3 层是一个新回合,唯一输入就是这条生产者来源的消息。因此模型看到的是一句「接着做」的指令,绝不会是用户原话的第二份拷贝。
KV Cache 影响
被重试与被继续的请求都会重建与它所恢复的那次尝试相同的前缀,因此在该提供方的规则下缓存复用得以保留。插件本身不贡献 token:不加工具 schema、不加系统提示词文本,除了第 2、3 层真正需要的那一条继续指令之外不加任何上下文。
开发说明
- 测试就是规格。
restart-task.test.mjs从出厂源码里切出纯函数区域,并真正驱动宿主模块,所以某条规则不可能在这里通过而出厂代码另做一套。 - 事件夹具取自真实日志。 决定归属的
turn/start→ 消息顺序,是从一份真实session.v4.jsonl(每批追加一个 zstd 帧)里读出来的 —— 在这之前,规则的第一版假设了相反的顺序,于是静默地什么都认不出来。 - DOM 遍历读产品渲染是刻意的,因为产品没有为它们作用的那些东西提供席位(折叠上下文行的来源是文本,触发通知没有任何来源属性,轮次轨道是 inline JSX,输入框主按钮也是 inline JSX)。每一处都被写成「读不懂就什么都不做」而不是猜,各自的边界都写在它旁边。
安装 / 移除
直接从本仓库安装(无需 registry):
dsh plugin --profile web add github:zchuxi/dsh-restart-task
# 或:dsh plugin --profile web add https://github.com/zchuxi/dsh-restart-task
# 或从本地 checkout:dsh plugin --profile web add link:<路径不含空格>
# 然后重启 profile(宿主 + 页面):宿主半边变了
dsh plugin --profile web remove dsh-restart-task
desktop 是 Electron 专属的 profile 名,CLI 会拒绝它;请改在插件管理器里用同一个 git 地址安装。
bundle 通过其 dsh.bundle.patch 清单字段加入 dsh.profile.bundles;移除依赖后,下一次启动就不再插入那一行。
本地 checkout 请放在不含空格的路径下。
dsh plugin会把参数经 shell 转发,含空格的路径会被拆成若干个错误依赖。
环境要求。 DSH >=0.1.7-rc.1 <0.1.8-0 || >=0.1.8-rc.1 <0.2.0-0 || >=0.2.0-rc.1 <0.3.0-0(声明为会被真正校验的 peerDependencies["@deepseek-ai/dsh"] 范围,因此范围之外的运行时会在启动时按 loader 的兼容性提示跳过 bundle,而不是在内部某处出错)、Node >=24,以及一个运行时依赖 —— @deepseek-ai/schemastery ^3.18.4(第一个 schema 能 .volatile() 的版本)。开发用 checkout 请用 npm install --legacy-peer-deps 安装:@deepseek-ai/dsh 这个 peer 是插件运行所在的宿主,不该作为构建依赖被拖下来。
浏览器半边是热重载的:dsh-client-hmr 会轮询每个客户端 bundle 的修改时间,把重建后的版本换进正在运行的页面,所以编辑 lib/client.js 保存即生效。宿主半边需要重启 profile —— agent/turn-stopping 和新的设置键都要等 lib/index.js 重新加载后才存在。