dsh-edit-turn 0.2.11 → 0.2.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # dsh-edit-turn
2
2
 
3
- **DeepSeek Harness 的「编辑某一轮」插件 —— 点用户消息旁的编辑按钮,就地改写那一句。保存即生效:改后的内容成为模型后续读到的版本,这一条消息下面的对话原样不动。** 改写走官方 surface-replace 契约(一条替换事件同时完成"删旧 + 立新",被替换的内容从模型上下文里消失),**默认不重跑模型**。会话日志是 append-only 的,**原始字节一个都不改写**。
3
+ **DeepSeek Harness 的「编辑某一轮」插件 —— 点用户消息旁的编辑按钮,就地改写那一句。保存即生效:改后的内容成为模型后续读到的版本,这一条消息下面的对话原样不动。** 改写走官方 surface-replace 契约(一条替换事件同时完成"删旧 + 立新",被替换的内容从模型上下文里消失),**保存本身永不调用模型**。会话日志是 append-only 的,**原始字节一个都不改写**。
4
4
 
5
5
  [中文](#中文) · [English](#english)
6
6
 
@@ -18,14 +18,14 @@ DSH 的会话日志是 append-only 的事件流:说错的提示词、问偏的
18
18
  - 点击后在该消息下方就地展开编辑器,预填原文;
19
19
  - 保存后:**只有这一条消息被替换**——旧措辞从模型上下文里消失,改后的内容占住它的位置(因此后续每一轮读到的都是改后的版本);
20
20
  - **它下面的助手回复、以及之后的全部对话,原样保留**,不重跑、不清空;
21
- - 想让它立刻按改后的内容重新回答,把 `rerun` 打开即可。
21
+ - 装了姊妹插件 **dsh-rerun-turn** 时,编辑器里会多一个「**重跑**」:先保存这次修改,再让它用改后的提示词重新生成这一轮(后续轮次逐事件重放回来,不丢)。
22
22
 
23
23
  ### 特性
24
24
 
25
25
  - **官方 seam,不改日志** —— 回退 = 追加一条带 `surfaceOp: { op: 'replace', startSeq, endSeq }` 的替换事件。原始事件全部留在会话文件里,只是不再进入 `deriveMessages()`。与官方 `/compact` 用的是同一套契约。
26
26
  - **改写内容就是载体本身** —— 提示词编辑落下的是一条 `user/message` 替换事件,内容就是你改后的那句话。它既让模型读到你改后的版本,也在转录里显示成你自己的气泡;平台不会把它当成新输入去回答(这一点踩过坑:**追加**一条 user 消息会触发平台自动回答,越改越多)。
27
27
  - **窗口最小化** —— 提示词编辑只遮蔽目标那一格(`shadowed = [target.seq]`);回复编辑才需要"到末尾"的窗口(回答变了,建立在它之上的一切都不再成立),因此助手消息(内含 tool_use)与它产生的 tool/result 永远一起走,**不可能留下悬空的调用/结果对**。
28
- - **默认不重跑模型** —— 保存只写上下文,不产生任何模型调用。想"改完立刻重答",配置 `rerun: true`:那会走官方准入路径 `ctx.sessionController.prompt()`(它会自己 resume 冷会话,并恰好开一个新轮次)。
28
+ - **保存永不调用模型** —— 保存只写上下文,不产生任何模型调用。"改完立刻重答"是另一件事,交给姊妹插件 **dsh-rerun-turn**(它遮蔽该轮、用表面上的提示词重新生成、再把后续轮次逐事件重放回来);本插件只在它装着时提供一个「重跑」按钮转发过去。
29
29
  - **模型回答也能编辑** —— 回答无法被"替换":官方格式禁止 `assistant/message` 携带 `sourceEventSeqs`(已在真实校验器上验证)。做法是回退该回答及其后的内容,再**追加**一条带改写文本的助手消息——模型会把改写后的内容当成自己说过的话,对话可以继续。`source.editedBy` 会如实记录这段文字由插件写入。
30
30
  - **改过的那条还能再改** —— 它仍然显示在原来的位置(由插件渲染成气泡),悬停就有编辑按钮。
31
31
  - **一次点击即执行** —— 保存后不再有二次确认(`confirm: true` 可恢复两步确认,确认步里会写明这次保存会丢弃什么)。编辑器本身已经是用户主动打开的动作,保存的后果在 README 与确认步里说明,输入框本身保持干净。
@@ -50,7 +50,7 @@ dsh plugin --profile web add /path/to/dsh-edit-turn
50
50
  ### 使用
51
51
 
52
52
  1. 把鼠标移到你想改的那条**用户消息**上,点右侧的编辑图标(铅笔)。
53
- 2. 消息下方展开编辑器,原文已预填。改完点「**保存**」——**一次点击即执行**:旧措辞从模型上下文里移除,改后的内容占住它的位置,**不叫模型**。(配置 `rerun: true` 时按钮为「保存并重跑」。)
53
+ 2. 消息下方展开编辑器,原文已预填。改完点「**保存**」——**一次点击即执行**:旧措辞从模型上下文里移除,改后的内容占住它的位置,**不叫模型**。装了 `dsh-rerun-turn` 时旁边还有一个「**重跑**」:先保存,再让那一轮用改后的内容重新生成(后续轮次不丢)。
54
54
  3. 编辑器收起,那条消息就地变成你改后的内容;它下面的回复与之后的对话**原样不动**。
55
55
 
56
56
  编辑器是**平台输入框那样的一个圆角盒子**:输入区在上、取消与主按钮在右下,聚焦时整圈描边高亮;**随文字长高**(空的时候不留空白),没有标题栏、没有常驻说明、没有拖拽手柄。键盘也和输入框一致:**Enter 保存、Shift+Enter 换行、Esc 取消**(确认步里 Esc 是退回上一步)。**输入法优先**:组合中的 Enter 与"候选选词的 Enter"(`isComposing` 或 `keyCode 229`,平台自己的输入框也是这两条一起挡)都不会被当成保存,组合期间也不重排输入框——此前少挡了 229 那半,拼音选词按回车会直接保存/关闭,看起来就是"打不了中文"。
@@ -69,13 +69,11 @@ dsh plugin --profile web add /path/to/dsh-edit-turn
69
69
  - id: dsh-edit-turn
70
70
  config:
71
71
  confirm: false # 默认 false:一次点击即执行;置 true 才要求二次确认
72
- rerun: false # 默认 false:保存只写上下文;置 true 则同时重跑该轮
73
72
  ```
74
73
 
75
74
  | 字段 | 默认 | 含义 |
76
75
  |---|---|---|
77
76
  | `confirm` | `false` | 客户端保存后是否先要求一次确认。默认**关闭**(一次点击即执行:编辑器本身已是用户主动打开的动作;设为 `true` 时确认步里会写明这次保存会丢弃什么); |
78
- | `rerun` | `false` | 保存后是否立刻重跑该轮。默认**关闭**:改后的内容进上下文即可,等你下次发消息时模型才读到它。设为 `true` 则保存即重跑(走官方 `sessionController.prompt()`)。 |
79
77
 
80
78
  ### 工作原理
81
79
 
@@ -96,11 +94,7 @@ session.append('developer/message', { turn, step, message: { role: 'developer',
96
94
 
97
95
  落盘后等一次持久化检查点(`sessions.flush`),保证重启也能看到。
98
96
 
99
- **第二步(可选):重跑。** 默认不做。`rerun: true` 时:
100
-
101
- ```js
102
- await ctx.sessionController.prompt({ requestId, sessionId, mode: 'queue', content: [{ type: 'text', text: 新内容 }] })
103
- ```
97
+ **重跑不由本插件做。** "改完立刻重答"是姊妹插件 `dsh-rerun-turn` 的操作:它遮蔽该轮、用表面现在显示的提示词(也就是你改后的文本)重新生成,再把后续轮次逐事件重放回来。本插件只负责把编辑落地;编辑器里的「重跑」按钮仅在检测到它挂载时出现,点击后先保存、再调用它的 `POST /dsh-rerun-turn/apply`。它不在(例如桌面版没装)时,没有这个按钮。
104
98
 
105
99
  **为什么不做文件截断?** 因为那不是官方能力。`dsh-session-persistence-jsonl` 只暴露残帧崩溃修复用的 `truncateTornTail`,正常路径下「Committed events are never rewritten」;运行中的宿主把会话放在内存 append-only 日志 + 投影缓存里,改磁盘不会让它重新读取。截断还会破坏 `sourceEventSeqs` 的 `[start,end]` 压缩表示与多帧 zstd 结构。surface-replace 是官方为这件事准备的机制。
106
100
 
@@ -109,7 +103,7 @@ await ctx.sessionController.prompt({ requestId, sessionId, mode: 'queue', conten
109
103
  | 类型 | 名称 | 说明 |
110
104
  |---|---|---|
111
105
  | 路由 | `GET /dsh-edit-turn/state?sessionId=` | 可编辑轮次、已遮蔽行账本(`hidden` + `revisions`)、surface、忙碌状态 |
112
- | 路由 | `POST /dsh-edit-turn/apply` | `{ sessionId, seq \| messageId \| turn, text }` → 写入改后内容(`rerun: true` 时另加重跑);响应含 `applied` / `reran` / `shadowed` / `replacementSeq` |
106
+ | 路由 | `POST /dsh-edit-turn/apply` | `{ sessionId, seq \| messageId \| turn, text }` → 写入改后内容;响应含 `applied` / `shadowed` / `replacementSeq` |
113
107
  | 工具 | `edit_turn_targets` | 只读:列出该会话当前可编辑的轮次与原文(供 agent 自查) |
114
108
  | 前端 | `conversation.input.overlay` | 每会话控制器:编辑入口、就地编辑器、被遮蔽行的隐藏账本 |
115
109
 
@@ -170,7 +164,7 @@ ln -s /path/to/dsh-install/node_modules ./node_modules
170
164
 
171
165
  - 可以编辑**你自己输入的消息**与**模型的回答**;注入的上下文行与系统提示词没有编辑入口。
172
166
  - 编辑一条回答会把它替换为**纯文本**:该回答里的工具调用与思考过程会被移除(编辑器会提示),因为它们的结果已不再成立。
173
- - 编辑一条用户消息**只替换那一条**,**默认不重跑模型**(`rerun: true` 才会保存后立刻重跑);编辑一条模型回答只替换内容,同样不问模型。
167
+ - 编辑一条用户消息**只替换那一条**,**保存永不调用模型**;编辑一条模型回答只替换内容,同样不问模型。想立刻重答,用 `dsh-rerun-turn` 的「重跑」。
174
168
  - **提示词编辑只动那一条**:它下面的回复与之后的对话**原样保留**。**回复编辑**才会移除该回答及其后的内容——回答变了,建立在它之上的一切都不再成立。想保留原文形成分支,需要走 `sessionController.fork({ sessionId, atSeq })`,尚未实现。
175
169
  - **只改写文本**。消息里含图片/文件附件时,改写后只保留文字(编辑器会提示)。
176
170
  - **会话必须当前在 DSH 中打开**,否则返回 `409 session-not-active`。
@@ -219,6 +213,26 @@ ln -s /path/to/dsh-install/node_modules ./node_modules
219
213
 
220
214
  ### 更新日志
221
215
 
216
+ **0.2.14** —— 修「重新 apply 时上一次注入的节点不清理 → 同一条消息行上堆出多个宿主」(互操作契约 I3 / §5)。只修 Bug,交互语义不变。
217
+
218
+ - **修复:重新 apply(HMR / 现场重载 / 插件开关 / bundle 组重载)会在同一行里再种一个编辑宿主,而不是复用上一次那个**。旧宿主的身份记在模块实例的 WeakMap 里,实例一换就认不出;于是每 apply 一次,那条操作条里就多一个「编辑这条消息」的笔。真机 CDP 探针实测同一 strip 里 `dshet-action-host` 多达 **7 个**、children 一度到 17(页面重载后回到 1、stripKids = 5),用户看到的是「一行上出现 3 个重跑按钮」。现在**每个注入节点都带命名空间属性**(宿主 `data-dshet-action-host="1"`、浮层根 `data-dshet-layer="1"`、编辑框 `data-dshet-editor="1"`、替代气泡 `data-dshet-revision="1"`),注入前先按属性在行内查一次、查到就**复用**——并且把它重新指到**当前**控制器(激活函数挂在按钮元素上、按下时才读,不再是上次那份捕获了旧闭包的监听;否则复用的笔点开的是已退休的控制器,表现就是「点了没反应」)。每趟 DOM pass 结束还会清掉「本次实例没有认领的」同命名空间宿主,正是那 7 个的直接来源;扫描只认自己的命名空间,兄弟插件的按钮永远不在候选里(I3)。
219
+ - **修复:卸载时不再把自己的注入节点留在页面上**。fiber dispose(`ctx.effect` 的清理)现在按属性选择器**全局**扫掉本插件的注入节点(宿主 / 替代气泡 / 编辑框 / 浮层根),并把**只属于自己**的隐藏交还给宿主行(`data-dshet-hidden` / `data-dshet-collapsed` / 折叠中途的内联样式),恢复可见前照旧先问归属(I4);React 自己渲染的节点(提示条、回答动作条里那支笔)**不扫**——从 React 手里抽走节点会让它在卸载时抛错。
220
+ - **English**: re-applying the bundle (HMR, a live reload, a plugin toggle) over the same page no longer plants a second edit-action host on the row - every injected node now carries a namespace attribute, an existing host is looked up in the row and reused (with its activation re-pointed at the controller that is mounted now, read off the button at press time), any host this apply instance did not claim is swept at the end of the pass, and disposing the fiber removes every node this plugin injected (hosts, revision bubbles, the editor and its layer root) while handing back only its own row/child hiding - React-owned nodes are deliberately left to the reconciler.
221
+ - 本版:`npm test` 126/126、`npm run verify:contract` 80/80、`npm run verify:client` 157/157 全绿(exit 0,`npm run check` 连跑两次同结果);新增 5 条 DOM 用例与 12 条静态检查;5 条新用例已用「把三处修复改回旧写法」验证必红——旧代码下那条 strip 里的宿主数正好是 **7**(与探针一致),卸载后宿主数仍是 1(等于不清理)。
222
+
223
+ **0.2.13** —— 隐藏归因(互操作契约 I4)+ 兄弟探测硬化(I5)。只修 Bug,交互语义不变。
224
+
225
+ - **修复:别人隐藏的行,本插件不再替它显示出来**。回退的「恢复可见」分支无条件把 `row.style.display` 清成 `''`——那一行若正被 **dsh-delete-turn**(`data-dshdt-hidden`)或 **dsh-rerun-turn**(`data-dsrr-hidden`)按归属属性隐藏着,本插件一恢复就把别人的隐藏一并抹掉(行"复活")。现在按契约 §4 在本地拷入 `foreignHideOn(row,'dshet')`:恢复前先确认没有别的归属属性,有则**保持 `display:none`**,只交还本插件自己那份隐藏。同理,折叠子节点时只有**本插件亲手写下**的 `display:none` 才打 `data-dshet-collapsed` 标记,恢复时也不会把别的插件留下的 `none` 重新显示出来。
226
+ - **修复:兄弟探测的过期答案不再覆盖新答案**(探测令牌,编辑器连开两次时不再闪一下又消失);`fetch` 抛错仍归入「缺席」且不打印任何日志(I5),探测结果为 `unknown` 时依旧不渲染重跑按钮。
227
+ - **English**: rows another plugin is keeping hidden are no longer un-hidden by this plugin's restore pass (`foreignHideOn(row, 'dshet')`, contract §4), a `display:none` this plugin did not write is no longer claimed by the collapse marker, and a sibling-probe answer older than the newest probe can no longer take the re-run button back out; a failed probe stays silent and absent.
228
+ - 本版:`npm test` 121/121、`npm run verify:contract` 80/80、`npm run verify:client` 145/145 全绿(exit 0);新增 7 条 DOM 用例与 5 条静态检查,三条新用例已用「改回旧代码」验证必红。
229
+
230
+ **0.2.12** —— 编辑器里的「重跑」(转发给 dsh-rerun-turn),并删除旧的 `rerun` 配置。
231
+
232
+ - **新增:「重跑」按钮(提示词编辑专用)**。装了姊妹插件 **dsh-rerun-turn** 时,取消/保存 旁多一个「重跑」:先走本插件的保存(就地替换、不花模型调用),紧接着调用它的 `POST /dsh-rerun-turn/apply { sessionId, seq: 该轮作答 }`——它按表面现在显示的提示词(你改后的文本)重新生成,并把后续轮次逐事件重放回来。探测方式与它的加载器探针一致(不带 sessionId 请求它的 `/state`:400=装着、404=没装),**没装就没有这个按钮**(例如桌面版没装时)。回答编辑不加这个按钮——回答动作条里本来就有它自己的 ↻。**它的 apply 不接受改写文本,所以顺序永远是先保存、后重跑**;该轮没有可重跑的作答、或它拒绝(`not-rerunnable`/`already-retired`/`busy`/`rerunning`…)时,按错误码提示,不影响已经落地的保存。
233
+ - **删除:旧的 `rerun: true` 配置及其实现**。旧路径在保存后用 `sessionController.prompt()` 在末尾追加一条同文提问——那不是"重跑这一轮",语义不干净(会在上下文里多出一条重复提问)。现在 `POST /apply` 的响应不再有 `reran` / `rerunError`,`/state` 的 `config` 不再有 `rerun`,i18n 的「保存并重跑」与文档一并移除;**保存永不调用模型**,重跑一律交给 dsh-rerun-turn。
234
+ - **诚实修正(流程问题)**:静态检查里「编辑器锚定到替代行」那条断言自 0.2.7 起写的还是被替换前的旧表达式(`view.revisions.get(seq)`,代码已改为 `headRevision(...)`),**一直红着**;我在那几轮里只 grep「全部通过」而漏看 `✗`,所以 0.2.7–0.2.11 的提交说明里"静态全绿"的说法**有一处不实**(单测、契约、真机冒烟这些当时确实都过,功能未受影响)。本条断言已按现表达式修正,并且从此用**退出码**判定 `npm run check`。本版:114 单测 / 80 契约 / 静态(exit 0)/ verify:live / verify:ui 全绿。
235
+
222
236
  **0.2.11** —— 修「字怎么变大了」。
223
237
 
224
238
  - **修复:改写后的气泡与编辑器不跟随平台的"内容字号"设置**。平台的气泡用 `font-size: var(--dsh-content-font-size,14px)` + `line-height: calc(22px + var(--dsh-content-font-delta,0px))`;我们的替代气泡写成 `font:inherit`(继承了行的 16px,比气泡的 15px 大),编辑器又写死 15px——读者一改内容字号,两处都对不上,看起来就是"字变大了"。现在两者都取平台同一个变量/公式(该变量定义在 body 上,编辑器所在的浮层是其子节点,可直接继承)。
@@ -336,7 +350,7 @@ This plugin adds it:
336
350
  - saving **replaces that one message in place**: only the old wording leaves the model context, the revised text takes its place, and every later turn is answered against the new version;
337
351
  - **the reply under it and the whole conversation after it stay exactly as they were** - nothing is re-run and nothing is cleared;
338
352
  - model replies are editable too: replacing one rolls back that answer and everything built on it (a changed answer invalidates what followed) and appends your text as a fresh reply;
339
- - turn `rerun` on if you do want the edited turn answered again right away.
353
+ - with the sister plugin **dsh-rerun-turn** installed, the editor also offers a "**Re-run**" button: it saves your change first, then has that plugin regenerate the turn from the revised prompt (replaying what followed, so nothing after it is lost).
340
354
 
341
355
  ### Features
342
356
 
@@ -344,7 +358,7 @@ This plugin adds it:
344
358
  - **The revision is the carrier.** A prompt edit lands a `user/message` replacement whose content **is** your revised text: it updates the model context and shows in the transcript in place of the old wording. The platform does not answer it as a new input (that was tried: *appending* a user message makes the platform reply, and the next save added another copy). Reply edits stay silent too: their carrier is an empty `developer/message` - the format admits only system-prompt sources on `system/message`, and an empty plugin-owned system node is exactly what used to make sessions unreadable (see below).
345
359
  - **The smallest window that is correct.** A prompt edit shadows exactly one node (`shadowed = [target.seq]`); a reply edit shadows the reply through the end of the surface - a changed answer invalidates everything built on it - which also keeps an assistant message (carrying its own tool_use blocks) and the tool/result it produced together. A dangling call/result pair is impossible.
346
360
  - **A rewritten message stays editable.** It still shows where the original stood, and hovering it offers the pencil again.
347
- - **No model call by default.** Saving only writes the context. `rerun: true` restores the old behaviour and goes through the one official prompt admission path, `ctx.sessionController.prompt()`.
361
+ - **Saving never calls the model.** It only writes the context. Regenerating a turn after the edit is a different operation, owned by the sister plugin **dsh-rerun-turn** (it shadows the turn, regenerates from the prompt the surface now shows, and replays the turns that followed); this plugin only forwards to it, through the "Re-run" button, when it is mounted.
348
362
  - **The model's replies are editable too.** A reply cannot be swapped in place: the format refuses `sourceEventSeqs` on an `assistant/message` (verified against the real validator). The answer is rolled back together with everything after it, and the corrected text is **appended** as a fresh reply - the model goes on treating it as its own. `source.editedBy` records honestly that the plugin wrote those words.
349
363
  - **One click applies** - no second confirmation by default. The editor is already an explicit action the user opened; with `confirm: true` the confirmation step states what this save will discard, while the input box itself stays clean.
350
364
  - **Bilingual UI** that follows the current DSH locale.
@@ -368,7 +382,7 @@ Restart the DSH process that serves that profile to pick it up.
368
382
  ### Usage
369
383
 
370
384
  1. Hover the **user message** you want to change and click the pencil action.
371
- 2. An editor opens below it with the original text pre-filled. Click "**Save**" - **one click applies**: the old wording leaves the model context and the revised text takes its place. **No model call.** (`rerun: true` shows "Save and re-run" instead.)
385
+ 2. An editor opens below it with the original text pre-filled. Click "**Save**" - **one click applies**: the old wording leaves the model context and the revised text takes its place. **No model call.** With `dsh-rerun-turn` installed there is also a "**Re-run**" button: save first, then let that turn regenerate from your revised wording (nothing after it is lost).
372
386
  3. The editor closes and the message shows your text in place; the reply under it and everything after it stay exactly where they were.
373
387
 
374
388
  The editor is **shaped like the platform's own input box**: one rounded
@@ -397,13 +411,11 @@ longer valid once you have changed what the model said.
397
411
  - id: dsh-edit-turn
398
412
  config:
399
413
  confirm: false # default: apply in one click; true restores the two-step flow
400
- rerun: false # default: saving only writes the context; true re-runs the turn
401
414
  ```
402
415
 
403
416
  | Field | Default | Meaning |
404
417
  |---|---|---|
405
418
  | `confirm` | `false` | Whether the client asks for a second confirmation before applying. Off by default - one click applies, because the editor is already an explicit action the user opened; with `true` the confirmation step states what this save will discard. |
406
- | `rerun` | `false` | Whether the edited turn is re-run right after saving. Off by default: the revision enters the context and the model reads it on your next message. `true` re-runs through the official `sessionController.prompt()`. |
407
419
 
408
420
  ### How it works
409
421
 
@@ -434,11 +446,7 @@ session.append('developer/message', { turn, step, message: { role: 'developer',
434
446
  The append then waits for the official durability checkpoint (`sessions.flush`)
435
447
  so a reload or a DSH restart still sees the rollback.
436
448
 
437
- **Step two (optional): the re-run.** Off by default; `rerun: true` turns it on.
438
-
439
- ```js
440
- await ctx.sessionController.prompt({ requestId, sessionId, mode: 'queue', content: [{ type: 'text', text }] })
441
- ```
449
+ **The re-run is not this plugin's operation.** Regenerating the turn is the sister plugin `dsh-rerun-turn`'s job: it shadows the turn, regenerates from the prompt the surface now shows (your revised text), and replays the turns that followed. This plugin only lands the edit; its editor offers a "Re-run" button when that plugin is detected, and the button chains save-then-`POST /dsh-rerun-turn/apply`. Without it (the desktop profile, say) there is no such button.
442
450
 
443
451
  **Why not truncate the log?** Because that is not an official capability.
444
452
  `dsh-session-persistence-jsonl` exposes only `truncateTornTail`, for crash
@@ -454,7 +462,7 @@ for exactly this.
454
462
  | Kind | Name | Purpose |
455
463
  |---|---|---|
456
464
  | Route | `GET /dsh-edit-turn/state?sessionId=` | Editable turns, the hidden-row ledger (`hidden` + `revisions`), the surface, busy state |
457
- | Route | `POST /dsh-edit-turn/apply` | `{ sessionId, seq \| messageId \| turn, text }` → write the revised text (`rerun: true` re-runs too); the response carries `applied` / `reran` / `shadowed` / `replacementSeq` |
465
+ | Route | `POST /dsh-edit-turn/apply` | `{ sessionId, seq \| messageId \| turn, text }` → write the revised text; the response carries `applied` / `shadowed` / `replacementSeq` |
458
466
  | Tool | `edit_turn_targets` | Read-only: list the session's editable turns and their text |
459
467
  | Client | `conversation.input.overlay` | Per-session controller: edit entry, in-place editor, hidden-row ledger |
460
468
 
@@ -703,8 +711,8 @@ ln -s /path/to/dsh-install/node_modules ./node_modules
703
711
 
704
712
  - **Human prompts and model replies** are both editable; injected context rows and the system prompt offer no edit entry.
705
713
  - **Editing a reply replaces it with plain text**: the tool calls and reasoning inside it are removed (the editor warns first), because their results are no longer valid.
706
- - **Editing a user message replaces that one message and leaves the rest of the conversation untouched - no re-run unless `rerun: true`**; editing a model reply only replaces the text and never asks the model again.
707
- - **A prompt edit touches only that message.** The reply under it and the whole conversation after it are kept - no re-run unless `rerun: true`. A **reply edit** removes that answer and everything built on it, because a changed answer invalidates what followed. Keeping the original as a branch needs `sessionController.fork({ sessionId, atSeq })`, which is not implemented.
714
+ - **Editing a user message replaces that one message and leaves the rest of the conversation untouched; saving never calls the model.** For an immediate answer, use `dsh-rerun-turn`'s "Re-run"; editing a model reply only replaces the text, too.
715
+ - **A prompt edit touches only that message.** The reply under it and the whole conversation after it are kept; saving never calls the model. A **reply edit** removes that answer and everything built on it, because a changed answer invalidates what followed. Keeping the original as a branch needs `sessionController.fork({ sessionId, atSeq })`, which is not implemented.
708
716
  - **Text only.** A message carrying image or file attachments keeps its text and drops them (the editor warns first).
709
717
  - **The session must be open in DSH**, otherwise the route answers `409 session-not-active`.
710
718
  - **Running work is refused**: an unclosed turn or an in-flight compaction answers `409 busy`.
package/lib/client.js CHANGED
@@ -28,9 +28,13 @@ window.__ModuleLoader__.load({
28
28
 
29
29
  const NS = 'dsh-edit-turn'
30
30
  const ROUTE_PREFIX = '/dsh-edit-turn'
31
+ // The sibling that owns re-running: it shadows a turn and regenerates it
32
+ // from the prompt the surface now shows, then replays what followed. The
33
+ // editor's re-run button is only offered when it is mounted.
34
+ const RERUN_PREFIX = '/dsh-rerun-turn'
31
35
 
32
36
  /** Keep in sync with package.json and lib/index.js. */
33
- const PLUGIN_VERSION = '0.2.11'
37
+ const PLUGIN_VERSION = '0.2.14'
34
38
 
35
39
  // --- copy -----------------------------------------------------------------
36
40
 
@@ -42,7 +46,7 @@ window.__ModuleLoader__.load({
42
46
  'editor.placeholder': '改成你希望自己当时说的内容。保存后这条消息之后的轮次会从模型上下文中移除,改后的内容成为新的起点。',
43
47
  'editor.placeholder-reply': '改成你希望模型说过的内容,保存后它就成为这条回答。',
44
48
  'editor.save': '保存',
45
- 'editor.save-rerun': '保存并重跑',
49
+ 'editor.rerun': '重跑',
46
50
  'editor.save-reply': '保存',
47
51
  'editor.review': '确认执行',
48
52
  'editor.cancel': '取消',
@@ -63,6 +67,11 @@ window.__ModuleLoader__.load({
63
67
  'error.prompt': '已回退,但改后的内容没能写进上下文。请重试。',
64
68
  'error.reply': '已回退,但替换没有落地。请重试。',
65
69
  'error.empty': '改写后的内容不能为空。',
70
+ 'error.rerun-nothing': '已保存,但这一轮没有可重跑的作答。',
71
+ 'error.rerun-unavailable': '已保存,但重跑没有开始,请稍后重试。',
72
+ 'error.not-rerunnable': '这一轮没有可重跑的作答。',
73
+ 'error.already-retired': '这一轮的内容已经不在当前上下文里了。',
74
+ 'error.rerunning': '已经有一次重跑在进行中,请等它结束。',
66
75
  'error.internal': '服务器内部错误,请稍后重试。',
67
76
  }
68
77
 
@@ -74,7 +83,7 @@ window.__ModuleLoader__.load({
74
83
  'editor.placeholder': 'Saving rolls the conversation back to just before this message and this turn runs again.',
75
84
  'editor.placeholder-reply': 'Write what you want the model to have said; saving makes it this reply.',
76
85
  'editor.save': 'Save',
77
- 'editor.save-rerun': 'Save and re-run',
86
+ 'editor.rerun': 'Re-run',
78
87
  'editor.save-reply': 'Save',
79
88
  'editor.review': 'Confirm',
80
89
  'editor.cancel': 'Cancel',
@@ -95,6 +104,11 @@ window.__ModuleLoader__.load({
95
104
  'error.prompt': 'Rolled back, but the re-run did not start. Send the revised text manually.',
96
105
  'error.reply': 'Rolled back, but the replacement did not land. Try again.',
97
106
  'error.empty': 'The revised message cannot be empty.',
107
+ 'error.rerun-nothing': 'Saved, but this turn has no reply that can be re-run.',
108
+ 'error.rerun-unavailable': 'Saved, but the re-run did not start; try again.',
109
+ 'error.not-rerunnable': 'This turn has no reply that can be re-run.',
110
+ 'error.already-retired': 'This turn is no longer part of the current context.',
111
+ 'error.rerunning': 'A re-run is already in flight; wait for it to finish.',
98
112
  'error.internal': 'The host hit an internal error; try again shortly.',
99
113
  }
100
114
 
@@ -270,6 +284,11 @@ window.__ModuleLoader__.load({
270
284
  this.editorFocus = false
271
285
  this.editorDraft = ''
272
286
  this.clearTimer = null
287
+ this.pendingRerun = false
288
+ this.siblingRerun = 'unknown'
289
+ // Bumped on every probe: an answer older than the newest probe is stale
290
+ // and must not move the button.
291
+ this.probeToken = 0
273
292
  this.confirming = false
274
293
  this.noticeTimer = null
275
294
  this.view = Object.freeze({
@@ -290,7 +309,9 @@ window.__ModuleLoader__.load({
290
309
  // Matches the host's default: one click applies. Only an explicit
291
310
  // `confirm: true` in the profile turns the second step back on.
292
311
  confirmStep: false,
293
- rerun: false,
312
+ // Whether the sibling that owns re-running (dsh-rerun-turn) is
313
+ // mounted; probed when an editor opens, updated only on change.
314
+ siblingRerun: 'unknown',
294
315
  revision: 0,
295
316
  })
296
317
  }
@@ -369,9 +390,6 @@ window.__ModuleLoader__.load({
369
390
  loaded: true,
370
391
  loadError: false,
371
392
  confirmStep: !(data.config && data.config.confirm === false),
372
- // Off by default: saving writes the revision into the context and waits
373
- // for the next message rather than spending a model call.
374
- rerun: Boolean(data.config && data.config.rerun === true),
375
393
  })
376
394
  })
377
395
  .catch(() => {
@@ -409,10 +427,36 @@ window.__ModuleLoader__.load({
409
427
  }
410
428
  }
411
429
  this.publish({ editing: target, draft: this.editorDraft, confirming: false, failure: null })
430
+ this.probeSiblingRerun()
431
+ }
432
+
433
+ // The sibling is probed the way our own loader is probed: its state route
434
+ // answers 400 without a sessionId when it is mounted, 404 when the route
435
+ // does not exist. A change is published - the editor's marker carries it,
436
+ // so the button appears (or never does).
437
+ async probeSiblingRerun() {
438
+ const token = (this.probeToken += 1)
439
+ let status = 0
440
+ try {
441
+ const response = await fetch(`${RERUN_PREFIX}/state`, { headers: { accept: 'application/json' } })
442
+ status = response.status
443
+ } catch {
444
+ // A sibling that is not mounted is not an error (I5): a rejected
445
+ // fetch, a timeout and a 404 all land in 'absent', and none of them
446
+ // logs anything.
447
+ status = 0
448
+ }
449
+ // Two opens before the first answer arrived: the slower probe used to
450
+ // land second and take the button back out. Only the newest speaks.
451
+ if (token !== this.probeToken) return
452
+ const next = status === 400 || status === 405 ? 'present' : 'absent'
453
+ this.siblingRerun = next
454
+ if (this.view.siblingRerun !== next) this.publish({ siblingRerun: next })
412
455
  }
413
456
 
414
457
  close() {
415
458
  if (this.view.pending) return
459
+ this.pendingRerun = false
416
460
  this.editorElement = null
417
461
  this.editorBox = null
418
462
  this.editorRow = null
@@ -429,6 +473,17 @@ window.__ModuleLoader__.load({
429
473
  }
430
474
 
431
475
  review() {
476
+ this.pendingRerun = false
477
+ this.runReview()
478
+ }
479
+
480
+ /** The same save, plus the intent to re-run the turn afterwards. */
481
+ reviewRerun() {
482
+ this.pendingRerun = true
483
+ this.runReview()
484
+ }
485
+
486
+ runReview() {
432
487
  if (this.editorDraft.trim() === '') {
433
488
  this.publish({ failure: 'empty' })
434
489
  return
@@ -450,6 +505,8 @@ window.__ModuleLoader__.load({
450
505
  async confirm() {
451
506
  const target = this.editor
452
507
  if (target === null || this.view.pending) return
508
+ const rerunIntent = this.pendingRerun
509
+ this.pendingRerun = false
453
510
  const text = this.editorDraft
454
511
  if (text.trim() === '') {
455
512
  this.publish({ failure: 'empty' })
@@ -526,6 +583,11 @@ window.__ModuleLoader__.load({
526
583
  // failed second half has to be reported somewhere that survives that.
527
584
  if (data.kind === 'reply' && data.applied === false) this.notify('reply')
528
585
  if (data.kind !== 'reply' && data.applied === false) this.notify('prompt')
586
+ // Save first, re-run second - always: the sibling re-runs the turn
587
+ // from the prompt the surface NOW shows, so the revised wording has
588
+ // to be on the surface before it is asked to regenerate. Not
589
+ // awaited: its own client takes the screen from here.
590
+ if (rerunIntent) this.startSiblingRerun(target.turn)
529
591
  // The view above is complete enough to render, but the host is the
530
592
  // authority on what the session now looks like: only its answer knows
531
593
  // how many rows the platform decided to draw, and what the appended
@@ -538,6 +600,37 @@ window.__ModuleLoader__.load({
538
600
  }
539
601
  }
540
602
 
603
+ // Hand the re-run to the sibling: find the turn's reply through its own
604
+ // state (the freshest view of what it can rerun) and ask it to start.
605
+ async startSiblingRerun(turn) {
606
+ try {
607
+ const response = await fetch(
608
+ `${RERUN_PREFIX}/state?sessionId=${encodeURIComponent(this.sessionId)}`,
609
+ { headers: { accept: 'application/json' } },
610
+ )
611
+ const state = response.ok ? await response.json().catch(() => ({})) : {}
612
+ const replies = Array.isArray(state.replies)
613
+ ? state.replies.filter((entry) => entry && entry.turn === turn && typeof entry.seq === 'number')
614
+ : []
615
+ if (replies.length === 0) {
616
+ this.notify('rerun-nothing')
617
+ return
618
+ }
619
+ const head = replies.reduce((best, entry) => (entry.seq > best.seq ? entry : best))
620
+ const started = await fetch(`${RERUN_PREFIX}/apply`, {
621
+ method: 'POST',
622
+ headers: { 'content-type': 'application/json' },
623
+ body: JSON.stringify({ sessionId: this.sessionId, seq: head.seq }),
624
+ })
625
+ const result = await started.json().catch(() => ({}))
626
+ if (!started.ok || !result.ok) {
627
+ this.notify(result && result.code ? String(result.code) : 'rerun-unavailable')
628
+ }
629
+ } catch {
630
+ this.notify('rerun-unavailable')
631
+ }
632
+ }
633
+
541
634
  notify(code) {
542
635
  if (this.noticeTimer !== null) window.clearTimeout(this.noticeTimer)
543
636
  this.publish({ notice: code })
@@ -653,6 +746,132 @@ window.__ModuleLoader__.load({
653
746
 
654
747
  const rowActions = new WeakMap()
655
748
 
749
+ // --- injected-node namespace ----------------------------------------------
750
+ //
751
+ // The host reloads this bundle in place: a new module instance with fresh
752
+ // closures, a fresh WeakMap and a fresh editor layer, over the DOM the
753
+ // previous instance left behind. Nothing in an instance can recognise its
754
+ // predecessor's nodes by identity, so every node this half injects carries
755
+ // its namespace in an attribute as well as in its class (contract I3): the
756
+ // attribute is what a reload looks for (reuse it, never plant a second) and
757
+ // what a real unload sweeps away. The class forms stay as fallbacks - a
758
+ // bundle older than this one injected the same nodes with the class alone.
759
+ const HOST_ATTR = 'dshetActionHost'
760
+ const LAYER_ATTR = 'dshetLayer'
761
+ const EDITOR_ATTR = 'dshetEditor'
762
+ const HOST_SELECTOR = '[data-dshet-action-host="1"],.dshet-action-host'
763
+ const LAYER_SELECTOR = '[data-dshet-layer="1"],.dshet-layer'
764
+ // The live activation of an injected action, kept on the button element
765
+ // itself: see `wireAction`.
766
+ const ACTIVATE = 'dshetActivate'
767
+ // Everything this half creates itself. The React-owned nodes are NOT in here
768
+ // on purpose (the notice banner, the reply entry in the assistant-actions
769
+ // strip): the host unmounts those through its own reconciler, and pulling a
770
+ // node out from under React makes it throw on the way out.
771
+ const OWN_NODE_SELECTOR = [
772
+ HOST_SELECTOR,
773
+ LAYER_SELECTOR,
774
+ '[data-dshet-editor="1"],.dshet-editor',
775
+ '[data-dshet-revision="1"],.dshet-revision',
776
+ ].join(',')
777
+
778
+ // The action hosts this module instance stands behind. A host carrying the
779
+ // namespace that no pass of this instance claimed cannot be positioned or
780
+ // updated by anyone: it is a leftover of an apply that is gone, and
781
+ // `dropUnclaimedHosts` takes it out.
782
+ const liveHosts = new WeakSet()
783
+
784
+ /** Does this node still hang in a tree? A parentless host is one nobody can see. */
785
+ function hasParent(node) {
786
+ return node !== null && node !== undefined && node.parentElement !== null && node.parentElement !== undefined
787
+ }
788
+
789
+ /** Is this one of our action hosts - by namespace attribute, or by class? */
790
+ function isActionHost(node) {
791
+ if (node === null || node === undefined) return false
792
+ const className = typeof node.className === 'string' ? node.className : ''
793
+ if (/(^|\s)dshet-action-host(\s|$)/.test(className)) return true
794
+ if (typeof node.matches === 'function') {
795
+ try {
796
+ return node.matches(HOST_SELECTOR) === true
797
+ } catch {
798
+ return false
799
+ }
800
+ }
801
+ return false
802
+ }
803
+
804
+ /** Every action host of ours inside `scope`, the scope itself included. */
805
+ function actionHostsIn(scope) {
806
+ const out = []
807
+ if (scope === null || scope === undefined) return out
808
+ if (isActionHost(scope)) out.push(scope)
809
+ if (typeof scope.querySelectorAll === 'function') {
810
+ for (const node of scope.querySelectorAll(HOST_SELECTOR)) {
811
+ if (!out.includes(node)) out.push(node)
812
+ }
813
+ }
814
+ return out
815
+ }
816
+
817
+ /** The button inside an injected host, wherever a previous apply left it. */
818
+ function actionButtonIn(host) {
819
+ for (const child of host.children) {
820
+ const className = typeof child.className === 'string' ? child.className : ''
821
+ if (child.tagName === 'BUTTON' || /(^|\s)dshet-action(\s|$)/.test(className)) return child
822
+ }
823
+ return null
824
+ }
825
+
826
+ function createActionHost() {
827
+ const host = document.createElement('span')
828
+ host.className = 'dshet-action-host'
829
+ host.dataset[HOST_ATTR] = '1'
830
+ return host
831
+ }
832
+
833
+ function createActionButton() {
834
+ const button = document.createElement('button')
835
+ button.type = 'button'
836
+ button.className = 'dshet-action dshet-row-action'
837
+ button.innerHTML = ICON_MARKUP
838
+ return button
839
+ }
840
+
841
+ /**
842
+ * Wire the press/click pair onto an injected action.
843
+ *
844
+ * The action itself is NOT captured here: it is read off the button at press
845
+ * time (`ACTIVATE`). A reloaded bundle reuses the very node the retired one
846
+ * injected - that is what the namespace is for - and it wires that node
847
+ * again, so the handler has to reach the controller that is mounted NOW.
848
+ * A captured closure would open the retired controller, which does nothing
849
+ * at all and reads exactly like a dead pencil.
850
+ */
851
+ function wireAction(button) {
852
+ pressable(button, () => {
853
+ const activate = button[ACTIVATE]
854
+ if (typeof activate === 'function') activate()
855
+ })
856
+ }
857
+
858
+ /** The pencil host a previous apply injected into this row, if there is one (I3). */
859
+ function adoptActionHost(row) {
860
+ const hosts = actionHostsIn(row)
861
+ if (hosts.length === 0) return null
862
+ // The first one wins: a second host in the same row is a ghost of an
863
+ // earlier apply, and the sweep at the end of this pass removes it.
864
+ const host = hosts[0]
865
+ if (host.dataset !== undefined && host.dataset[HOST_ATTR] !== '1') host.dataset[HOST_ATTR] = '1'
866
+ let button = actionButtonIn(host)
867
+ if (button === null) {
868
+ button = createActionButton()
869
+ host.appendChild(button)
870
+ }
871
+ wireAction(button)
872
+ return { host, button, target: null }
873
+ }
874
+
656
875
  // Show a rewritten prompt where its original row is.
657
876
  //
658
877
  // The bubble is cloned from a real user bubble's classes at runtime - the
@@ -696,7 +915,6 @@ window.__ModuleLoader__.load({
696
915
  return node.kind !== 'user'
697
916
  }
698
917
  const bubble = existing === null ? cloneUserBubble(row) : existing
699
- if (existing === null) bubble.dataset.dshetRevision = '1'
700
918
  {
701
919
  const key = row.getAttribute('data-chat-flow-key')
702
920
  if (typeof key === 'string' && key !== '' && bubble.dataset[REVISION_FOR] !== key) {
@@ -740,6 +958,10 @@ window.__ModuleLoader__.load({
740
958
  function cloneUserBubble(row) {
741
959
  const bubble = document.createElement('div')
742
960
  bubble.className = 'dshet-revision'
961
+ // Namespace attribute as well as the class: a reloaded bundle finds this
962
+ // bubble by attribute (it has no memory of the node), and an unload sweeps
963
+ // it by the same selector.
964
+ bubble.dataset.dshetRevision = '1'
743
965
  if (row !== null && row !== undefined) {
744
966
  const key = row.getAttribute('data-chat-flow-key')
745
967
  if (typeof key === 'string' && key !== '') bubble.dataset[REVISION_FOR] = key
@@ -855,8 +1077,13 @@ window.__ModuleLoader__.load({
855
1077
  if (hide) {
856
1078
  // Written only when the value changes: this runs on every DOM pass,
857
1079
  // and an attribute write the observer can see would never settle.
858
- if (child.style.display !== 'none') child.style.display = 'none'
859
- if (child.dataset.dshetCollapsed !== '1') child.dataset.dshetCollapsed = '1'
1080
+ // The marker is written only for a `none` this plugin wrote (I4):
1081
+ // a child another plugin had already displayed away is left
1082
+ // untouched, so the restore pass cannot take that hiding back out.
1083
+ if (child.style.display !== 'none') {
1084
+ child.style.display = 'none'
1085
+ if (child.dataset.dshetCollapsed !== '1') child.dataset.dshetCollapsed = '1'
1086
+ }
860
1087
  } else if (child.dataset.dshetCollapsed === '1') {
861
1088
  child.style.display = ''
862
1089
  delete child.dataset.dshetCollapsed
@@ -867,6 +1094,25 @@ window.__ModuleLoader__.load({
867
1094
  return true
868
1095
  }
869
1096
 
1097
+ // Attribution for the hiding below (contract §4). Another plugin writes its
1098
+ // own marker on a row it displayed away (delete-turn 'dshdt', rerun-turn
1099
+ // 'dsrr'); this half resets `display` for its OWN hide only, and a row
1100
+ // another plugin is keeping away stays away.
1101
+ const HIDE_OWNERS = [
1102
+ ['dshdt', 'data-dshdt-hidden', 'dshdtHidden'],
1103
+ ['dshet', 'data-dshet-hidden', 'dshetHidden'],
1104
+ ['dsrr', 'data-dsrr-hidden', 'dsrrHidden'],
1105
+ ]
1106
+ /** Is anyone but `own` claiming a hide on this row? */
1107
+ function foreignHideOn(row, own) {
1108
+ for (const [key, attr, prop] of HIDE_OWNERS) {
1109
+ if (key === own) continue
1110
+ if (row.dataset?.[prop] === '1') return true
1111
+ if (typeof row.hasAttribute === 'function' && row.hasAttribute(attr)) return true
1112
+ }
1113
+ return false
1114
+ }
1115
+
870
1116
  function setRowHidden(row, hide, animate) {
871
1117
  // A turn tail is not a message row. It is the strip a turn's end renders
872
1118
  // (duration, usage, actions); when the turn behind it is rolled back the
@@ -917,7 +1163,10 @@ window.__ModuleLoader__.load({
917
1163
  delete row.dataset.dshetHidden
918
1164
  if (row.dataset.dshetKeepActions === '1') delete row.dataset.dshetKeepActions
919
1165
  row.classList.remove('dshet-collapsing')
920
- row.style.display = ''
1166
+ // I4: dropping our marker ends OUR hide. `display` is only ours to clear
1167
+ // while no other plugin's hide marker stands - a row a sibling displayed
1168
+ // away stays away, whatever this half would prefer.
1169
+ row.style.display = foreignHideOn(row, 'dshet') ? 'none' : ''
921
1170
  row.style.height = ''
922
1171
  row.style.opacity = ''
923
1172
  row.style.marginTop = ''
@@ -929,6 +1178,7 @@ window.__ModuleLoader__.load({
929
1178
  function removeRowAction(row) {
930
1179
  const entry = rowActions.get(row)
931
1180
  if (!entry) return
1181
+ liveHosts.delete(entry.host)
932
1182
  entry.host.remove()
933
1183
  rowActions.delete(row)
934
1184
  }
@@ -936,28 +1186,47 @@ window.__ModuleLoader__.load({
936
1186
  function injectRowAction(row, target, controller, t, options = {}) {
937
1187
  const label = t(target.mode === 'reply' ? 'action.edit-reply' : 'action.edit')
938
1188
  let entry = rowActions.get(row)
939
- if (!entry) {
940
- const host = document.createElement('span')
941
- host.className = 'dshet-action-host'
942
- const button = document.createElement('button')
943
- button.type = 'button'
944
- button.className = 'dshet-action dshet-row-action'
945
- button.innerHTML = ICON_MARKUP
1189
+ // An entry whose host lost its parent is not an entry any more: another
1190
+ // apply swept the node away (or the host took it out with the container it
1191
+ // was in), and a pass that only repositions it would leave this row with
1192
+ // no pencil at all.
1193
+ if (entry !== undefined && !hasParent(entry.host)) {
1194
+ rowActions.delete(row)
1195
+ entry = undefined
1196
+ }
1197
+ // I3: a host a previous apply injected into this row is REUSED, never
1198
+ // doubled. The WeakMap cannot find it - a reloaded bundle starts with an
1199
+ // empty one - so the namespace attribute is what does.
1200
+ if (entry === undefined) {
1201
+ const adopted = adoptActionHost(row)
1202
+ if (adopted !== null) entry = adopted
1203
+ }
1204
+ if (entry === undefined) {
1205
+ const host = createActionHost()
1206
+ const button = createActionButton()
946
1207
  // The marker goes on the BUTTON, not only on its wrapper: the pass that
947
1208
  // looks for the official strip's entry queries buttons, so a marker one
948
1209
  // level up made this pencil look like the strip's own and it deleted
949
1210
  // itself - then the next pass put it back, which reads as flicker.
950
- if (options.fallback === true) button.dataset.dshetFallback = '1'
951
1211
  host.appendChild(button)
952
1212
  entry = { host, button, target }
953
- // Wired once, and the action reads the CURRENT target off the entry: the
954
- // pass runs again on every snapshot, and re-wiring would reset the
955
- // press guard between a pointerdown and the click it swallows.
956
- pressable(button, () => controller.open(entry.target))
1213
+ // The action reads the CURRENT target off the entry and the CURRENT
1214
+ // controller off the button, so re-wiring on a later apply is harmless
1215
+ // and a reloaded bundle can take this very node over.
1216
+ wireAction(button)
957
1217
  rowActions.set(row, entry)
958
1218
  }
959
1219
  entry.target = target
960
1220
  const { host, button } = entry
1221
+ // The fallback marker mirrors the placement chosen below: a host adopted
1222
+ // from a gutter placement must not keep claiming one it no longer has.
1223
+ if (options.fallback === true) {
1224
+ if (button.dataset.dshetFallback !== '1') button.dataset.dshetFallback = '1'
1225
+ } else if (button.dataset !== undefined && button.dataset.dshetFallback !== undefined) {
1226
+ delete button.dataset.dshetFallback
1227
+ }
1228
+ button[ACTIVATE] = () => controller.open(entry.target)
1229
+ liveHosts.add(host)
961
1230
  if (button.getAttribute('aria-label') !== label) {
962
1231
  button.setAttribute('aria-label', label)
963
1232
  button.setAttribute('title', label)
@@ -995,6 +1264,37 @@ window.__ModuleLoader__.load({
995
1264
  }
996
1265
  }
997
1266
 
1267
+ /**
1268
+ * One pencil per row, however often the bundle is applied over the same DOM.
1269
+ *
1270
+ * Every pass registers the host it stands behind in `liveHosts`. A host that
1271
+ * carries this plugin's namespace and was claimed by NO pass of this
1272
+ * instance cannot be positioned or updated by anyone: it is the leftover of
1273
+ * an apply that is gone (a reload, a plugin toggle, a bundle-group swap),
1274
+ * and left alone the bar collects one more of them per apply - the observed
1275
+ * report was seven action hosts and seventeen children in a single strip,
1276
+ * which the user reads as three re-run buttons on one row. Only nodes
1277
+ * carrying this plugin's own namespace are ever considered (I3).
1278
+ *
1279
+ * The DOM pass runs on every snapshot, so a ghost is taken out on the first
1280
+ * pass after the reload that produced it.
1281
+ */
1282
+ function dropUnclaimedHosts() {
1283
+ if (typeof document === 'undefined' || typeof document.querySelectorAll !== 'function') return
1284
+ let hosts = []
1285
+ try {
1286
+ hosts = document.querySelectorAll(HOST_SELECTOR)
1287
+ } catch {
1288
+ return
1289
+ }
1290
+ for (const host of hosts) {
1291
+ if (liveHosts.has(host)) continue
1292
+ try {
1293
+ host.remove()
1294
+ } catch {}
1295
+ }
1296
+ }
1297
+
998
1298
  // The platform's own action icons all carry a hashed class ending in
999
1299
  // `_action`; the clock, the usage pill and anything this plugin adds do not
1000
1300
  // (ours are hyphenated). Matching that suffix avoids CSS-module hashes
@@ -1026,12 +1326,32 @@ window.__ModuleLoader__.load({
1026
1326
  let editorLayer = null
1027
1327
  function ensureEditorLayer() {
1028
1328
  if (editorLayer !== null && editorLayer.isConnected === true) return editorLayer
1329
+ // A reloaded bundle starts with no layer of its own; the one the previous
1330
+ // instance left is the same node doing the same job, so it is adopted
1331
+ // rather than doubled (I3). The instance that left it takes it out with
1332
+ // its own sweep when it is disposed.
1333
+ const existing = ownLayer()
1334
+ if (existing !== null && existing.isConnected !== false) {
1335
+ editorLayer = existing
1336
+ return editorLayer
1337
+ }
1029
1338
  editorLayer = document.createElement('div')
1030
1339
  editorLayer.className = 'dshet-layer'
1340
+ editorLayer.dataset[LAYER_ATTR] = '1'
1031
1341
  document.body.appendChild(editorLayer)
1032
1342
  return editorLayer
1033
1343
  }
1034
1344
 
1345
+ /** The editor layer a previous apply left on the page, if there is one. */
1346
+ function ownLayer() {
1347
+ if (typeof document === 'undefined' || typeof document.querySelector !== 'function') return null
1348
+ try {
1349
+ return document.querySelector(LAYER_SELECTOR)
1350
+ } catch {
1351
+ return null
1352
+ }
1353
+ }
1354
+
1035
1355
  // Put the box under the row it belongs to: same width, just below its lower
1036
1356
  // edge. Called on render and whenever the page scrolls or resizes, since
1037
1357
  // the layer is fixed to the viewport.
@@ -1101,13 +1421,16 @@ window.__ModuleLoader__.load({
1101
1421
  view.confirming ? 1 : 0,
1102
1422
  view.pending ? 1 : 0,
1103
1423
  view.failure === null ? '' : view.failure,
1424
+ // The re-run button appears when the sibling probe lands; without it in
1425
+ // the marker the editor would never be rebuilt to show the button.
1426
+ view.siblingRerun === 'present' ? 'r' : 'n',
1104
1427
  ].join('|')
1105
1428
  }
1106
1429
 
1107
1430
  // Every string the editor shows depends on what is being edited: a prompt is
1108
1431
  // re-run, a reply is replaced. Keeping them in one place keeps the two modes
1109
1432
  // from drifting apart in wording.
1110
- function editorTexts(t, target, rerun) {
1433
+ function editorTexts(t, target) {
1111
1434
  const reply = target.mode === 'reply'
1112
1435
  return {
1113
1436
  title: t(reply ? 'editor.title-reply' : 'editor.title'),
@@ -1115,13 +1438,13 @@ window.__ModuleLoader__.load({
1115
1438
  warn: t(reply ? 'editor.warn-reply' : 'editor.warn.attachments'),
1116
1439
  // Saving writes the revision into the context; only the opt-in config asks
1117
1440
  // the model again, and the button says which one will happen.
1118
- save: t(reply ? 'editor.save-reply' : rerun === true ? 'editor.save-rerun' : 'editor.save'),
1441
+ save: t(reply ? 'editor.save-reply' : 'editor.save'),
1119
1442
  }
1120
1443
  }
1121
1444
 
1122
1445
  function renderEditor(row, controller, view, t) {
1123
1446
  const target = view.editing
1124
- const texts = editorTexts(t, target, view.rerun)
1447
+ const texts = editorTexts(t, target)
1125
1448
  const marker = editorMarker(target, view)
1126
1449
  const existing = controller.editorBox !== null && controller.editorBox.isConnected === true
1127
1450
  ? controller.editorBox
@@ -1147,6 +1470,7 @@ window.__ModuleLoader__.load({
1147
1470
  clearEditor()
1148
1471
  const box = document.createElement('div')
1149
1472
  box.className = 'dshet-editor'
1473
+ box.dataset[EDITOR_ATTR] = '1'
1150
1474
  box.dataset.dshetFor = marker
1151
1475
 
1152
1476
  const area = document.createElement('textarea')
@@ -1243,6 +1567,20 @@ window.__ModuleLoader__.load({
1243
1567
  })
1244
1568
 
1245
1569
  footer.appendChild(cancel)
1570
+ // The re-run button belongs to the sibling (dsh-rerun-turn): it appears
1571
+ // only when that plugin is mounted, only for a prompt edit, and only
1572
+ // outside the confirmation step - the intent is carried through it. Save
1573
+ // first, re-run second, always. A reply edit has no re-run here: the
1574
+ // action strip of the reply carries the sibling's own button.
1575
+ if (target.mode === 'prompt' && view.siblingRerun === 'present' && view.confirming !== true) {
1576
+ const rerun = document.createElement('button')
1577
+ rerun.type = 'button'
1578
+ rerun.className = 'dshet-btn dshet-rerun'
1579
+ rerun.textContent = t('editor.rerun')
1580
+ rerun.disabled = view.pending
1581
+ pressable(rerun, () => controller.reviewRerun())
1582
+ footer.appendChild(rerun)
1583
+ }
1246
1584
  footer.appendChild(submit)
1247
1585
  box.appendChild(footer)
1248
1586
 
@@ -1342,6 +1680,10 @@ window.__ModuleLoader__.load({
1342
1680
  controller.editorBox = null
1343
1681
  controller.editorRow = null
1344
1682
  }
1683
+ // Whatever of ours no row of this pass claimed is a ghost of an earlier
1684
+ // apply: the host rebuilt the row under it, or the bundle was reloaded
1685
+ // over it. Contract §5 - a button nobody can update must not pile up.
1686
+ dropUnclaimedHosts()
1345
1687
  }
1346
1688
 
1347
1689
  // A snapshot this entry can read, or null when the controller cannot be
@@ -1517,6 +1859,65 @@ window.__ModuleLoader__.load({
1517
1859
  })
1518
1860
  }
1519
1861
 
1862
+ // --- unload -----------------------------------------------------------------
1863
+
1864
+ /**
1865
+ * Take back everything this half put on the page.
1866
+ *
1867
+ * Runs when the plugin is disposed - a reload, a toggle, a bundle-group swap
1868
+ * - and it works by NAMESPACE, never by identity: the WeakMap and the layer
1869
+ * reference die with the module instance, the attributes on the nodes do
1870
+ * not, and the instance that replaces this one starts with no memory of them
1871
+ * at all. That is exactly why a reload used to leave a second pencil (and a
1872
+ * third, and a fourth) in the same bar.
1873
+ *
1874
+ * Only nodes this plugin created are removed, and only state this plugin
1875
+ * wrote is taken back (I3, I4): a row another plugin is keeping away stays
1876
+ * away, and the host's own buttons never move.
1877
+ */
1878
+ function releaseInjectedNodes() {
1879
+ if (typeof document === 'undefined' || typeof document.querySelectorAll !== 'function') return
1880
+ const each = (selector, act) => {
1881
+ let nodes = []
1882
+ try {
1883
+ nodes = document.querySelectorAll(selector)
1884
+ } catch {
1885
+ return
1886
+ }
1887
+ for (const node of nodes) {
1888
+ try {
1889
+ act(node)
1890
+ } catch {}
1891
+ }
1892
+ }
1893
+ // 1. The platform's own rows go back to how this half found them. These
1894
+ // markers are this plugin's own writes, so this is its own state - and
1895
+ // only its own: the restore path asks the attribution guard first (I4).
1896
+ each('[data-dshet-hidden="1"]', (row) => setRowHidden(row, false))
1897
+ each('[data-dshet-collapsed="1"]', (child) => {
1898
+ child.style.display = ''
1899
+ delete child.dataset.dshetCollapsed
1900
+ })
1901
+ // A row caught mid-collapse keeps the inline height and opacity this half
1902
+ // wrote, and nothing after this would ever clear them.
1903
+ each('.dshet-collapsing', (row) => {
1904
+ row.classList.remove('dshet-collapsing')
1905
+ row.style.height = ''
1906
+ row.style.opacity = ''
1907
+ row.style.marginTop = ''
1908
+ row.style.marginBottom = ''
1909
+ row.style.paddingTop = ''
1910
+ row.style.paddingBottom = ''
1911
+ })
1912
+ // 2. Every node this half created goes with it: the injected pencils, the
1913
+ // bubbles standing in for rewritten prompts, the editor and the layer
1914
+ // it is hosted on. React's own nodes are deliberately not in the
1915
+ // selector - the host unmounts those, and pulling one out from under
1916
+ // React makes it throw on the way out.
1917
+ each(OWN_NODE_SELECTOR, (node) => node.remove())
1918
+ editorLayer = null
1919
+ }
1920
+
1520
1921
  // --- plugin ---------------------------------------------------------------
1521
1922
 
1522
1923
  function apply(ctx) {
@@ -1535,6 +1936,11 @@ window.__ModuleLoader__.load({
1535
1936
  () => () => {
1536
1937
  for (const controller of controllers.values()) controller.dispose()
1537
1938
  controllers.clear()
1939
+ // The DOM this half injected is not owned by the controllers: it
1940
+ // outlives them, and the next apply has no way to find it except by
1941
+ // namespace. An unload that does not take it out is what left one more
1942
+ // pencil in the bar on every reload (I3, contract §5).
1943
+ releaseInjectedNodes()
1538
1944
  },
1539
1945
  'dsh-edit-turn: per-session controllers',
1540
1946
  )
package/lib/index.js CHANGED
@@ -18,11 +18,8 @@
18
18
  // tool/result it produced are always shadowed together: no call/result
19
19
  // pair can be left dangling.
20
20
  //
21
- // 2. RE-RUN - `ctx.sessionController.prompt(...)`, the one official prompt
22
- // admission path. It resumes the Session when cold and starts exactly one
23
- // new turn from the revised text.
24
21
  //
25
- // The silent replacement carrier is an EMPTY `developer/message` (0.2.11;
22
+ // The silent replacement carrier is an EMPTY `developer/message` (0.2.12;
26
23
  // before that an empty `system/message`, which no reader accepts - see the
27
24
  // README). Empty content projects to no model message, so the rollback adds
28
25
  // nothing the model can see and the derived context after an edit is exactly
@@ -60,7 +57,7 @@ export const PLUGIN_ID = 'dsh-edit-turn'
60
57
  export const name = PLUGIN_ID
61
58
 
62
59
  /** Keep in sync with package.json and lib/client.js. */
63
- export const PLUGIN_VERSION = '0.2.11'
60
+ export const PLUGIN_VERSION = '0.2.14'
64
61
 
65
62
  // A ring of the last few requests, answering "did the browser half even ask, and
66
63
  // what did it get?" without needing its console. Read-only diagnostics: the
@@ -82,7 +79,6 @@ export function recentRequests() {
82
79
  const ROUTE_PREFIX = '/dsh-edit-turn'
83
80
  const SESSION_ID_RE = /^(session-)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
84
81
  const MAX_TEXT = 200_000
85
- const PROMPT_DEADLINE_MS = 30_000
86
82
  const FLUSH_DEADLINE_MS = 5_000
87
83
 
88
84
  /**
@@ -113,12 +109,6 @@ export const Config =
113
109
  * step.
114
110
  */
115
111
  confirm: z.boolean().default(false),
116
- /**
117
- * Whether saving a revised prompt also runs the turn again. Off by default:
118
- * the revision becomes the standing wording and waits for the next message,
119
- * so saving costs no model call. Set it to `true` to re-run on save.
120
- */
121
- rerun: z.boolean().default(false),
122
112
  })
123
113
 
124
114
  // ---------------------------------------------------------------------------
@@ -700,7 +690,7 @@ async function stateOf(ctx, sessionId, config) {
700
690
  live: Boolean(live),
701
691
  busy: isBusy(events),
702
692
  lastSeq: events.length > 0 ? events[events.length - 1].seq : -1,
703
- config: { confirm: config.confirm, rerun: config.rerun === true },
693
+ config: { confirm: config.confirm },
704
694
  version: PLUGIN_VERSION,
705
695
  }
706
696
  }
@@ -907,60 +897,14 @@ async function applyEdit(ctx, sessionId, body, config) {
907
897
 
908
898
  // The rollback above already did the whole job: the carrier that replaced the
909
899
  // window *is* the revised prompt, so the model reads it as the user's wording
910
- // from here on and nothing has to be prompted. Saving therefore costs no model
911
- // call - which is the point. `rerun: true` restores the older behaviour of
912
- // asking the model right away (through the one official prompt path, which also
913
- // resumes a cold Session and starts exactly one new turn).
914
- if (config.rerun !== true) {
915
- return {
916
- kind: 'prompt',
917
- replacementSeq: replacement.seq,
918
- applied: true,
919
- reran: false,
920
- shadowed: plan.shadowed,
921
- ...flush,
922
- }
923
- }
924
-
925
- const controller = ctx.get('sessionController')
926
- if (!controller || typeof controller.prompt !== 'function') {
927
- return {
928
- kind: 'prompt',
929
- replacementSeq: replacement.seq,
930
- applied: true,
931
- reran: false,
932
- rerunError: 'sessionController.prompt unavailable',
933
- shadowed: plan.shadowed,
934
- ...flush,
935
- }
936
- }
937
- try {
938
- await controller.prompt(
939
- {
940
- requestId: randomUUID(),
941
- sessionId,
942
- mode: 'queue',
943
- content: [{ type: 'text', text }],
944
- },
945
- AbortSignal.timeout(PROMPT_DEADLINE_MS),
946
- )
947
- } catch (error) {
948
- return {
949
- kind: 'prompt',
950
- replacementSeq: replacement.seq,
951
- applied: true,
952
- reran: false,
953
- rerunError: String((error && error.message) || error),
954
- shadowed: plan.shadowed,
955
- ...flush,
956
- }
957
- }
958
-
900
+ // from here on. Saving therefore costs no model call, always: regenerating
901
+ // the turn is a different operation with a different owner - the sibling
902
+ // plugin dsh-rerun-turn shadows the turn and regenerates it from the prompt
903
+ // the surface now shows, then replays what followed. Nothing here prompts.
959
904
  return {
960
905
  kind: 'prompt',
961
906
  replacementSeq: replacement.seq,
962
907
  applied: true,
963
- reran: true,
964
908
  shadowed: plan.shadowed,
965
909
  ...flush,
966
910
  }
@@ -1027,7 +971,7 @@ function registerTool(ctx) {
1027
971
  lines.push(`- reply (seq ${reply.seq}, ${reply.attachments} non-text part(s)): ${preview}`)
1028
972
  }
1029
973
  if (replies.length > shownReplies.length) lines.push(`(${replies.length - shownReplies.length} earlier reply/replies omitted)`)
1030
- lines.push('editing a turn re-runs it; editing a reply replaces its text and discards what follows.')
974
+ lines.push('editing a turn replaces its wording in place (no model call); editing a reply replaces its text and discards what follows. Re-running a turn is dsh-rerun-turn\'s button.')
1031
975
  return lines.join('\n')
1032
976
  },
1033
977
  }),
@@ -1140,7 +1084,6 @@ export function apply(ctx, config) {
1140
1084
  : DEFAULT_MARKER,
1141
1085
  carrier: config && config.carrier === 'user/message' ? 'user/message' : 'system/message',
1142
1086
  confirm: config && typeof config.confirm === 'boolean' ? config.confirm : false,
1143
- rerun: config && config.rerun === true,
1144
1087
  }
1145
1088
 
1146
1089
  const registerRoutes = (webServer, fiber) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-edit-turn",
3
- "version": "0.2.11",
3
+ "version": "0.2.14",
4
4
  "description": "Edit any user turn in DeepSeek Harness and re-run it: an inline editor on the message row rolls the conversation back to just before that turn through the official surface-replace contract, then re-prompts the agent with the revised text. The append-only session log is never rewritten.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -21,7 +21,6 @@
21
21
  "edit",
22
22
  "message",
23
23
  "turn",
24
- "rerun",
25
24
  "surface-replace",
26
25
  "rollback"
27
26
  ],