dsh-rewind-plugin 0.3.2 → 0.4.0

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
@@ -66,6 +66,7 @@ The plugin appends an **empty-content marker** `assistant/message` into the sess
66
66
  - The marker carries `sourceEventSeqs` covering every shadowed node, and `Session.append`'s surface rules validate the cut (a contiguous range on the current surface).
67
67
  - Because the marker is **empty**, the harness derives it to `null` — it never enters the model context and never renders as conversation content. Agent and user both see the conversation exactly as it was at the target.
68
68
  - The marker's **turn number reuses the last started turn** (`markerTurnOf`), never `lastTurn + 1`: the harness numbers its next real turn exactly `last turn/start + 1`, so a `maxTurn + 1` marker would sit *before* that `turn/start` — the client conversation builder rejects the ordering (`…turn-tail… received an update before its start Match`) and history load fails. Reusing an already-consumed turn makes the marker a harmless trailing update on the previous completed turn's tail — it can never collide with a future turn.
69
+ - The marker rides a **ghost step frame** — its own `step/start` … `step/end` with a fresh step number (`markerStepOf`) — because the harness token-meter requires every `assistant/message` to sit inside an open step of the same turn/step; a bare idle-time marker would fail its replay and break `/compact` for the session.
69
70
  - The append-only log is **untouched** — every withdrawn event stays in the audit trail; only the model-visible surface is cut, so the next request derives its context from the target onward.
70
71
 
71
72
  A running turn (LLM thinking / streaming) is force-stopped first (`cancel({ kind: 'user' })`) and the rewind waits for quiescence; if it can't stop, the rewind is aborted with an error.
@@ -109,11 +110,19 @@ The essential difference: dsh-turn-rewind keeps the log immutable and therefore
109
110
  > This project and DeepSeek Harness are both in developer preview. Pin exact
110
111
  > versions in reproducible environments and review the behavior notes above.
111
112
 
113
+ ## Client contract
114
+
115
+ Third-party DOM plugins that need to know which transcript rows a rewind
116
+ withdrew should consume the stable, locale-independent helpers exported from
117
+ `dsh-rewind-plugin/client` (`hiddenSeqsOf`, `targetSeqOfArgs`) — never parse
118
+ `outcome.text`. The `data-dsh-rewind-hidden` attribute marks withdrawn rows
119
+ (observational only). Details: [docs/client-contract.md](docs/client-contract.md).
120
+
112
121
  ## Known issues
113
122
 
114
- Rewinds created with versions `≤ v0.2.4` could corrupt client replay when followed by more conversation (a marker turn collides with the next `turn/start`). The offline repair tool ships **inside the npm package** (`dsh-rewind-repair`). This only affects sessions you already had before upgrading — a fresh install never hits it.
123
+ Rewinds created with versions `≤ v0.2.4` could corrupt client replay when followed by more conversation (a marker turn collides with the next `turn/start`). Only pre-upgrade sessions are affected. The offline repair tool (`dsh-rewind-repair`) was shipped before v0.4.0 and is no longer provided from v0.4.0 oninstall a pre-v0.4.0 release if you need it ([docs/troubleshooting.md](docs/troubleshooting.md)).
115
124
 
116
- Full instructions: [docs/troubleshooting.md](docs/troubleshooting.md)
125
+ Rewinds from `≤ v0.3.3` appended a bare marker (no step frame); the harness token-meter rejects such a log on replay, so `/compact` fails for that session. Newer versions are compatible; affected old sessions have no online repair yet — start a new session.
117
126
 
118
127
  ## Security
119
128
 
@@ -125,7 +134,7 @@ This plugin only appends rewind-marker events to the session log; it never delet
125
134
 
126
135
  ```sh
127
136
  npm install # devDeps from the npm registry
128
- npm run typecheck # tsc on both compilation surfaces (host + client)
137
+ npm run typecheck # tsc on all three surfaces (host + client + client-test)
129
138
  npm test # vitest: rewind / snapshot / hidden / session-cwd / integration
130
139
  npm run build # esbuild: lib/index.js (host ESM) + lib/client.js (loader closure) + .d.ts
131
140
  node scripts/verify-host.mjs # boot the BUILT host artifact end-to-end
package/README.zh.md CHANGED
@@ -66,6 +66,7 @@ dsh plugin --profile web add dsh-rewind-plugin
66
66
  - 标记携带 `sourceEventSeqs` 覆盖所有被遮蔽节点,`Session.append` 的 surface 规则校验切割合法性(仅限当前 surface 上的连续区间)。
67
67
  - 因为标记**内容为空**,harness 会将其派生为 `null`——永不进入模型上下文、也永不渲染成对话内容。agent 与用户看到的对话都回到目标消息当时的样子。
68
68
  - 标记的 **turn 号复用最后一个已开始的回合**(`markerTurnOf`),而不是「最后回合 + 1」:harness 恰好用 `最后 turn/start + 1` 编号下一条真实回合。若标记也取这个数,日志里就会出现同一 turn 的 `assistant/message` 先于 `turn/start` 的乱序,客户端 conversation 构建器会以 `conversation Context …:turn-tail… received an update before its start Match` 拒绝重放——历史加载失败、整个对话从界面消失。复用已消费的 turn 号则标记只是上一个已完成回合尾部的一次无害追加,永不与新回合冲突。
69
+ - 标记自带**幽灵步骤框架**——自己的 `step/start` … `step/end`,step 号取该回合未用过的新号(`markerStepOf`):harness 的 token-meter 重放要求每条 `assistant/message` 位于打开的 step 内,裸标记则会让该会话的 `/compact` 失效。
69
70
  - append-only 日志**不被改写**——审计轨迹完整保留每条被撤回的事件,只有模型可见的 surface 被剪掉,下一条请求从目标消息起派生上下文。
70
71
 
71
72
  若 agent 正在运行(LLM 思考/流式输出),会先强制停止(`cancel({ kind: 'user' })`)并等待 quiescence 再回退;停不下来则中止并报错。
@@ -98,7 +99,7 @@ dsh plugin --profile web add dsh-rewind-plugin
98
99
  | 跟踪范围 | 仅写类工具编辑(同 Claude Code) | 任意 Git 管理文件(要求 Git worktree) |
99
100
  | 公共服务 API | 无——聚焦单用途插件 | 有——`ctx.changeLedger` 服务 + `/turn-rewind` HTTP 端点 |
100
101
 
101
- 本质区别:dsh-turn-rewind 因保持日志不可变而必须派生新会话;本插件用空标记**就地剪掉**模型可见 surface,于是原对话在同一个窗口继续——这段并不平凡的实现(见[已知问题](#已知问题))正是 dsh-turn-rewind 绕开的部分。
102
+ 本质区别:dsh-turn-rewind 因保持日志不可变而必须派生新会话;本插件用空标记**就地剪掉**模型可见 surface,于是原对话在同一个窗口继续——这段并不平凡的实现正是 dsh-turn-rewind 绕开的部分。
102
103
 
103
104
  ## 兼容性
104
105
 
@@ -109,11 +110,19 @@ dsh plugin --profile web add dsh-rewind-plugin
109
110
  > 本项目与 DeepSeek Harness 均处于开发者预览阶段。可复现环境请 pin 精确版本,
110
111
  > 并阅读上述行为说明。
111
112
 
113
+ ## 客户端契约
114
+
115
+ 需要获知哪些转录行被回退撤回的第三方 DOM 插件,应使用
116
+ `dsh-rewind-plugin/client` 导出的稳定、与本地化无关的纯函数
117
+ (`hiddenSeqsOf`、`targetSeqOfArgs`),切勿解析 `outcome.text`。
118
+ `data-dsh-rewind-hidden` 属性标记被撤回的行(仅观测性)。
119
+ 详见:[docs/client-contract.zh.md](docs/client-contract.zh.md)。
120
+
112
121
  ## 已知问题
113
122
 
114
- `v0.2.4` 及之前版本创建的回退在随后继续对话时可能损坏客户端重放(标记 turn 与下一个 `turn/start` 撞号)。离线修复工具**已随 npm 包内置**(`dsh-rewind-repair`)。只影响升级前就已存在的旧会话——全新安装永不触发。
123
+ `v0.2.4` 及之前版本创建的回退在随后继续对话时可能损坏客户端重放(标记 turn 与下一个 `turn/start` 撞号)。只影响升级前就已存在的旧会话。离线修复工具(`dsh-rewind-repair`)在 v0.4.0 之前随包提供,此后不再提供——需要修复的用户可安装 v0.4.0 之前的版本([完整步骤](docs/troubleshooting.zh.md))。
115
124
 
116
- 完整步骤见:[docs/troubleshooting.zh.md](docs/troubleshooting.zh.md)
125
+ `v0.3.3` 及之前版本的回退以裸标记追加(无步骤框架),token-meter 重放会拒绝该日志,`/compact` 对该会话失效。新版本均已兼容;已受影响的旧会话暂不提供在线修复——请新建会话。
117
126
 
118
127
  ## 安全
119
128
 
@@ -125,7 +134,7 @@ dsh plugin --profile web add dsh-rewind-plugin
125
134
 
126
135
  ```sh
127
136
  npm install # devDeps 来自 npm registry
128
- npm run typecheck # tsc 双面编译(host + client)
137
+ npm run typecheck # tsc 三面编译(host + client + client-test
129
138
  npm test # vitest:rewind / snapshot / hidden / session-cwd / 集成
130
139
  npm run build # esbuild:lib/index.js(host ESM)+ lib/client.js(loader 闭包)+ .d.ts
131
140
  node scripts/verify-host.mjs # 端到端验证构建产物
@@ -0,0 +1,56 @@
1
+ # Client contract — rewind visibility
2
+
3
+ How third-party DOM plugins learn **which transcript rows a rewind withdrew**.
4
+ This is the only sanctioned answer to that question; anything not listed here
5
+ is internal and may change without notice.
6
+
7
+ ## Stability tiers
8
+
9
+ | Channel | Stability | Consumers |
10
+ | --- | --- | --- |
11
+ | `/rewind` command `args` `@<seq>` | ✅ stable, semver-protected | machine |
12
+ | `outcome.sourceEventSeq` (marker log seq) | ✅ stable, semver-protected | machine |
13
+ | `data-dsh-rewind-hidden` attribute | ✅ stable name; observational only | DOM plugins |
14
+ | `outcome.text` | ❌ **not** stable — human copy, never parse | — |
15
+
16
+ ## The sanctioned machine channel
17
+
18
+ `dsh-rewind-plugin/client` exports the pure, locale-independent computation
19
+ the plugin itself uses (it never reads the DOM or host copy):
20
+
21
+ ```ts
22
+ import { hiddenSeqsOf, type HiddenChat } from 'dsh-rewind-plugin/client'
23
+
24
+ const chat = session.getSnapshot().chat
25
+ const hidden = hiddenSeqsOf(chat as HiddenChat) // Set<number> of anchor seqs
26
+ ```
27
+
28
+ `hiddenSeqsOf` hides every internal probe row (`preview` / `__candidates`),
29
+ every successful executed `/rewind` row, and every message inside its
30
+ `[target, marker]` span (each rewind cuts one span; spans stay separate).
31
+ `targetSeqOfArgs` is exported for consumers that only need the target from a
32
+ command's `args`. Reuse these instead of re-deriving the logic (cf.
33
+ dsh-chat-timeline#6).
34
+
35
+ ## DOM attribute
36
+
37
+ Each withdrawn row carries `data-dsh-rewind-hidden="true"` while hidden,
38
+ removed on un-hide. Contract:
39
+
40
+ - The **attribute name** is stable; treat the value as opaque.
41
+ - It is **observational only** — rewind hides via `style.display`; the
42
+ attribute records the cause, not the mechanism. Do not write it, and do
43
+ not expect it on rows outside rewind's control (third-party own elements).
44
+
45
+ ## Explicit non-contract
46
+
47
+ `outcome.text` is human-facing copy. Its wording changes freely with
48
+ localization; parsing it is a bug. The `impact=<n>` trailer on preview text
49
+ is machine-readable and stable but outside this contract's scope.
50
+
51
+ ## Maintenance rules
52
+
53
+ - Any change to the meaning of `@<seq>` / `sourceEventSeq` /
54
+ `data-dsh-rewind-hidden` updates this document in the same PR.
55
+ - Breaking a listed stability tier is a minor/major version bump.
56
+ - `scripts/build.mjs` asserts the export surface on every build.
@@ -0,0 +1,54 @@
1
+ # 客户端契约 — 回退可见性
2
+
3
+ 第三方 DOM 插件如何获知**哪些转录行被回退撤回**。这是该问题的唯一权威
4
+ 答案;未在此列出的内容均属内部实现,可随时变更,恕不另行通知。
5
+
6
+ ## 稳定性分级
7
+
8
+ | 通道 | 稳定性 | 适用方 |
9
+ | --- | --- | --- |
10
+ | `/rewind` 命令 `args` 中的 `@<seq>` | ✅ 稳定,受 semver 保护 | 机器 |
11
+ | `outcome.sourceEventSeq`(marker 日志 seq) | ✅ 稳定,受 semver 保护 | 机器 |
12
+ | `data-dsh-rewind-hidden` 属性 | ✅ 属性名稳定;仅观测性 | DOM 插件 |
13
+ | `outcome.text` | ❌ **不**稳定 — 人类可读文案,禁止解析 | — |
14
+
15
+ ## 唯一机器通道
16
+
17
+ `dsh-rewind-plugin/client` 导出插件自身所用的纯函数计算,与本地化无关
18
+ (不读取 DOM,也不解析 host 文案):
19
+
20
+ ```ts
21
+ import { hiddenSeqsOf, type HiddenChat } from 'dsh-rewind-plugin/client'
22
+
23
+ const chat = session.getSnapshot().chat
24
+ const hidden = hiddenSeqsOf(chat as HiddenChat) // 被隐藏的 anchor seq 集合
25
+ ```
26
+
27
+ `hiddenSeqsOf` 隐藏所有内部探针行(`preview` / `__candidates`)、所有成功
28
+ 执行的 `/rewind` 行,以及每条回退 `[target, marker]` 区间内的消息(每次
29
+ 回退切一条区间,区间互不合并)。只需从命令 `args` 取 target 时,可用同样
30
+ 导出的 `targetSeqOfArgs`。请复用这些实现,勿自行重写(参见
31
+ dsh-chat-timeline#6)。
32
+
33
+ ## DOM 属性
34
+
35
+ 每条被撤回的行在隐藏期间带有 `data-dsh-rewind-hidden="true"`,取消隐藏时
36
+ 移除。契约如下:
37
+
38
+ - **属性名**稳定;值视为不透明。
39
+ - 仅**观测性**标记 — rewind 通过 `style.display` 隐藏,该属性记录的是
40
+ 原因而非机制。不要写它;也不要在 rewind 控制范围之外(第三方自建元素)
41
+ 期望它存在。
42
+
43
+ ## 明确非契约
44
+
45
+ `outcome.text` 是面向人类用户的文案,措辞随本地化自由变更,解析它即视为
46
+ bug。preview 文案中的 `impact=<n>` 尾注是机器可读且稳定的,但不属于本
47
+ 契约范围。
48
+
49
+ ## 维护规则
50
+
51
+ - 任何改变 `@<seq>` / `sourceEventSeq` / `data-dsh-rewind-hidden` 语义的
52
+ 改动,必须在同一 PR 中更新本文档。
53
+ - 破坏已承诺的稳定性分级属于 minor/major 版本变更。
54
+ - `scripts/build.mjs` 在每次构建时断言导出面。
@@ -47,7 +47,6 @@ src/client/locales.ts zh / en copy (LocaleNamespaceMap)
47
47
  src/client/styles.ts injected styles (dsh design tokens)
48
48
  scripts/build.mjs esbuild: lib/index.js (host ESM) + lib/client.js (loader closure) + .d.ts
49
49
  scripts/verify-host.mjs end-to-end host verification (18 checks)
50
- scripts/repair-markers.mjs offline marker-turn repair tool (ships as `dsh-rewind-repair`)
51
50
  tests/ vitest suites (rewind / snapshot / hidden / session-cwd / integration)
52
51
  docs/ maintainer docs: harness reference, troubleshooting, release steps
53
52
  assets/screenshots/ UI screenshots
@@ -4,36 +4,17 @@
4
4
 
5
5
  ## History load failure: `…turn-tail… received an update before its start Match`
6
6
 
7
- Versions `≤ 0.2.4` corrupted client replay when a rewind was **followed by further
8
- conversation**: the marker's turn number collided with the next real turn's
9
- `turn/start`, so reopening the session showed
7
+ Rewinds from `≤ 0.2.4` collided with the next real turn's `turn/start`, so reopening the session showed
10
8
 
11
9
  ```
12
10
  Failed to load history: conversation Context …:turn-tail… received an update before its start Match (internal)
13
11
  ```
14
12
 
15
- and the history vanished. Rewinds created from `0.2.5` on no longer produce the
16
- collision, but **already-corrupted sessions need an offline repair** (the log is
17
- append-only — it cannot be rewritten in memory).
18
-
19
- The repair tool ships **inside the npm package** (`dsh-rewind-repair`) — no
20
- source checkout needed:
13
+ and the history vanished. Rewinds from `0.2.5` on no longer collide; already-corrupted sessions need an offline repair (the log is append-only). The repair tool (`dsh-rewind-repair`) is no longer shipped from v0.4.0 — install a pre-v0.4.0 release to get it (fully quit dsh web / host first, then):
21
14
 
22
15
  ```sh
23
- # 1. Fully quit dsh web / host first (while a session is resident in memory,
24
- # a disk repair is overwritten by the next checkpoint)
25
- # 2. Run the offline repair (scans every session under ~/.dsh/sessions,
26
- # rewriting each marker's turn back to the last started turn)
27
- npm exec --yes --package=dsh-rewind-plugin -- dsh-rewind-repair
28
- npm exec --yes --package=dsh-rewind-plugin -- dsh-rewind-repair -- --dry-run # preview only
29
- # 3. Restart dsh web — the repaired sessions load their history again
16
+ npm exec --yes --package=dsh-rewind-plugin@0.3.3 -- dsh-rewind-repair
17
+ npm exec --yes --package=dsh-rewind-plugin@0.3.3 -- dsh-rewind-repair -- --dry-run # preview only
30
18
  ```
31
19
 
32
- Or install it globally once (`npm i -g dsh-rewind-plugin`) and run
33
- `dsh-rewind-repair` directly; from a source checkout the same tool is
34
- `node scripts/repair-markers.mjs` (identical flags).
35
-
36
- The tool only rewrites the `data.turn` of `dsh-rewind` empty-marker events
37
- (keeping seqs, order, and the zstd frame structure intact), backs up the original
38
- file to `session.jsonl.zstd.bak-<timestamp>` before writing, and never touches
39
- any other event — safe to run repeatedly.
20
+ It only rewrites the marker events' `data.turn` (seqs, order, and zstd frame structure intact) and backs up the original file before writing — safe to run repeatedly. From a source checkout of a pre-v0.4.0 tag: `node scripts/repair-markers.mjs` (identical flags).
@@ -4,28 +4,11 @@
4
4
 
5
5
  ## 历史加载失败:`…turn-tail… received an update before its start Match`
6
6
 
7
- 0.2.4 及之前版本在回退**之后继续对话**的场景下会损坏会话的客户端重放:标记的 turn
8
- 与下一条真实回合的 `turn/start` 编号冲突,重新打开会话时界面报
9
-
10
- ```
11
- 历史加载失败:conversation Context …:turn-tail… received an update before its start Match(internal)
12
- ```
13
-
14
- 历史整段消失。0.2.5 起新的回退不再产生该冲突;但**已损坏的会话需要离线修复**(日志是
15
- append-only 的,不能在内存中改写)。
16
-
17
- 修复工具**已随 npm 包发布**(`dsh-rewind-repair`)——无需下载源码:
7
+ 0.2.4 及之前版本的回退与下一条真实回合的 `turn/start` 撞号,重开会话时历史整段消失。0.2.5 起的新回退不再产生该冲突;**已损坏的旧会话需离线修复**(日志是 append-only 的)。修复工具(`dsh-rewind-repair`)v0.4.0 起不再随包提供——安装 v0.4.0 之前的版本即可获取(先完全退出 dsh web / host,然后):
18
8
 
19
9
  ```sh
20
- # 1. 先完全退出 dsh web / host(会话处于驻留内存时,磁盘修复会被下次 checkpoint 覆盖)
21
- # 2. 运行离线修复(扫描 ~/.dsh/sessions 下所有会话,把标记 turn 改回最后一个已开始的回合)
22
- npm exec --yes --package=dsh-rewind-plugin -- dsh-rewind-repair
23
- npm exec --yes --package=dsh-rewind-plugin -- dsh-rewind-repair -- --dry-run # 只预览不写盘
24
- # 3. 重启 dsh web,损坏的会话即可正常加载历史
10
+ npm exec --yes --package=dsh-rewind-plugin@0.3.3 -- dsh-rewind-repair
11
+ npm exec --yes --package=dsh-rewind-plugin@0.3.3 -- dsh-rewind-repair -- --dry-run # 只预览不写盘
25
12
  ```
26
13
 
27
- 也可以全局安装一次(`npm i -g dsh-rewind-plugin`)后直接运行 `dsh-rewind-repair`;
28
- 源码方式为 `node scripts/repair-markers.mjs`(参数相同)。
29
-
30
- 工具只改写 `dsh-rewind` 空标记事件的 `data.turn` 字段(保持 seq / 顺序 / zstd 帧结构不变),
31
- 改前自动备份原文件为 `session.jsonl.zstd.bak-<时间戳>`;不改动任何其它事件,可安全重复运行。
14
+ 工具只改写标记事件的 `data.turn`(保持 seq / 顺序 / zstd 帧结构不变),改前自动备份原文件——可安全重复运行。源码方式:v0.4.0 之前的 tag 下 `node scripts/repair-markers.mjs`(参数相同)。
package/lib/client.js CHANGED
@@ -27,8 +27,10 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
27
27
  var index_exports = {};
28
28
  __export(index_exports, {
29
29
  apply: () => apply,
30
+ hiddenSeqsOf: () => hiddenSeqsOf,
30
31
  inject: () => inject,
31
- name: () => name
32
+ name: () => name,
33
+ targetSeqOfArgs: () => targetSeqOfArgs
32
34
  });
33
35
  module.exports = __toCommonJS(index_exports);
34
36
 
@@ -467,9 +469,96 @@ function renderImpactStep(root, opts, back, cached) {
467
469
  impact.textContent = t("popover.impact.failed", { message: "unexpected error" });
468
470
  });
469
471
  }
472
+ function mountShell(root, anchor, onKeyDown) {
473
+ const position = () => {
474
+ const rect = anchor.getBoundingClientRect();
475
+ const gap = 4;
476
+ const height = root.offsetHeight;
477
+ const top = rect.bottom + gap + height <= window.innerHeight - 8 ? rect.bottom + gap : Math.max(8, rect.top - gap - height);
478
+ root.style.top = `${Math.round(top)}px`;
479
+ root.style.left = `${Math.round(Math.min(rect.right, window.innerWidth - 8 - root.offsetWidth))}px`;
480
+ };
481
+ const onPointerDown = (event) => {
482
+ const target = event.target;
483
+ if (root.contains(target) || anchor.contains(target)) return;
484
+ closePopover();
485
+ };
486
+ const deferred = setTimeout(() => {
487
+ document.addEventListener("pointerdown", onPointerDown);
488
+ document.addEventListener("keydown", onKeyDown, true);
489
+ }, 0);
490
+ const dispose = () => {
491
+ clearTimeout(deferred);
492
+ document.removeEventListener("pointerdown", onPointerDown);
493
+ document.removeEventListener("keydown", onKeyDown, true);
494
+ };
495
+ document.body.append(root);
496
+ position();
497
+ return { position, dispose };
498
+ }
499
+ function openRetractPopover(opts) {
500
+ closePopover();
501
+ const { preview, anchor, t, retract, onRetract } = opts;
502
+ if (retract === void 0 || onRetract === void 0) return;
503
+ const root = el("div", CLASS.popover);
504
+ root.setAttribute("role", "dialog");
505
+ root.setAttribute("aria-label", t("popover.retract.title"));
506
+ const onKeyDown = (event) => {
507
+ if (event.key === "ArrowDown") {
508
+ event.preventDefault();
509
+ event.stopPropagation();
510
+ moveFocus(root, 1);
511
+ return;
512
+ }
513
+ if (event.key === "ArrowUp") {
514
+ event.preventDefault();
515
+ event.stopPropagation();
516
+ moveFocus(root, -1);
517
+ return;
518
+ }
519
+ if (event.key === "Escape") {
520
+ event.preventDefault();
521
+ event.stopPropagation();
522
+ closePopover();
523
+ }
524
+ };
525
+ const previewText = preview.length > 0 ? preview : t("popover.noText");
526
+ const actions = el("div", CLASS.popoverActions);
527
+ const confirm = document.createElement("button");
528
+ confirm.type = "button";
529
+ confirm.className = CLASS.popoverPrimary;
530
+ confirm.textContent = t("popover.retract.confirm");
531
+ confirm.addEventListener("click", () => {
532
+ closePopover();
533
+ onRetract();
534
+ });
535
+ const cancel = document.createElement("button");
536
+ cancel.type = "button";
537
+ cancel.className = CLASS.popoverGhost;
538
+ cancel.textContent = t("popover.cancel");
539
+ cancel.addEventListener("click", closePopover);
540
+ actions.append(confirm, cancel);
541
+ root.replaceChildren(
542
+ el("div", CLASS.popoverTitle, t("popover.retract.title")),
543
+ el("div", CLASS.popoverTarget, t("popover.retract.target", { preview: previewText })),
544
+ el("div", CLASS.popoverImpact, t("popover.retract.hint")),
545
+ actions
546
+ );
547
+ const shell = mountShell(root, anchor, onKeyDown);
548
+ popoverEl = root;
549
+ disposeOutside = shell.dispose;
550
+ focusFirst(root);
551
+ }
470
552
  function openPopover(opts) {
471
553
  closePopover();
554
+ if (opts.retract !== void 0) {
555
+ openRetractPopover(opts);
556
+ return;
557
+ }
472
558
  const { session, seq, time, preview, anchor, t } = opts;
559
+ const onRewind = opts.onRewind;
560
+ if (seq === void 0 || time === void 0 || onRewind === void 0) return;
561
+ const durableOpts = { session, seq, time, preview, anchor, t, onRewind };
473
562
  const root = el("div", CLASS.popover);
474
563
  root.setAttribute("role", "dialog");
475
564
  root.setAttribute("aria-label", t("popover.title"));
@@ -483,11 +572,13 @@ function openPopover(opts) {
483
572
  el("div", CLASS.popoverTarget, formatTarget(t, seq, time, preview)),
484
573
  modeOption(t("popover.chat"), t("popover.chat.hint"), () => {
485
574
  closePopover();
486
- opts.onRewind("chat");
575
+ durableOpts.onRewind("chat");
487
576
  })
488
577
  ];
489
578
  if (bothState.state === "noChanges") {
490
579
  children.push(el("div", CLASS.popoverImpact, t("popover.noChanges")));
580
+ } else if (bothState.state === "error") {
581
+ children.push(el("div", CLASS.popoverImpact, t("popover.impact.failed", { message: bothState.message })));
491
582
  } else {
492
583
  const option = modeOption(
493
584
  t("popover.both"),
@@ -510,38 +601,7 @@ function openPopover(opts) {
510
601
  };
511
602
  const renderImpact = () => {
512
603
  step = "impact";
513
- renderImpactStep(root, opts, renderModes, impactOutcome);
514
- };
515
- const position = () => {
516
- const rect = anchor.getBoundingClientRect();
517
- const gap = 4;
518
- const height = root.offsetHeight;
519
- const top = rect.bottom + gap + height <= window.innerHeight - 8 ? rect.bottom + gap : Math.max(8, rect.top - gap - height);
520
- root.style.top = `${Math.round(top)}px`;
521
- root.style.left = `${Math.round(Math.min(rect.right, window.innerWidth - 8 - root.offsetWidth))}px`;
522
- };
523
- renderModes();
524
- document.body.append(root);
525
- position();
526
- focusFirst(root);
527
- void (async () => {
528
- const outcome = await previewImpact(session, seq);
529
- impactOutcome = outcome;
530
- if (outcome !== null && outcome.kind === "success") {
531
- bothState = { state: hasFileImpact(outcome.text) ? "hasChanges" : "noChanges" };
532
- }
533
- renderModes();
534
- position();
535
- })().catch(() => {
536
- bothState = { state: "hasChanges" };
537
- renderModes();
538
- position();
539
- });
540
- popoverEl = root;
541
- const onPointerDown = (event) => {
542
- const target = event.target;
543
- if (root.contains(target) || anchor.contains(target)) return;
544
- closePopover();
604
+ renderImpactStep(root, durableOpts, renderModes, impactOutcome);
545
605
  };
546
606
  const onKeyDown = (event) => {
547
607
  if (event.key === "ArrowDown") {
@@ -563,20 +623,52 @@ function openPopover(opts) {
563
623
  else closePopover();
564
624
  }
565
625
  };
566
- const deferred = setTimeout(() => {
567
- document.addEventListener("pointerdown", onPointerDown);
568
- document.addEventListener("keydown", onKeyDown, true);
569
- }, 0);
570
- disposeOutside = () => {
571
- clearTimeout(deferred);
572
- document.removeEventListener("pointerdown", onPointerDown);
573
- document.removeEventListener("keydown", onKeyDown, true);
574
- };
626
+ renderModes();
627
+ const shell = mountShell(root, anchor, onKeyDown);
628
+ popoverEl = root;
629
+ disposeOutside = shell.dispose;
630
+ void (async () => {
631
+ const outcome = await previewImpact(session, seq);
632
+ impactOutcome = outcome;
633
+ if (outcome !== null && outcome.kind === "success") {
634
+ bothState = { state: hasFileImpact(outcome.text) ? "hasChanges" : "noChanges" };
635
+ } else if (outcome !== null && outcome.kind === "error") {
636
+ bothState = { state: "error", message: outcome.text ?? "unknown error" };
637
+ }
638
+ renderModes();
639
+ shell.position();
640
+ })().catch(() => {
641
+ bothState = { state: "hasChanges" };
642
+ renderModes();
643
+ shell.position();
644
+ });
575
645
  }
576
646
 
577
647
  // src/client/portals.tsx
578
648
  var import_react = require("react");
579
649
  var import_react_dom = require("react-dom");
650
+
651
+ // src/client/pending.ts
652
+ function matchPendingRows(rows, steering) {
653
+ const matched = [];
654
+ for (let i = 0; i < rows.length; i++) {
655
+ const row = rows[i];
656
+ const item = steering[i];
657
+ if (item !== void 0 && row.text === (item.text ?? "")) {
658
+ matched.push(item.id);
659
+ } else {
660
+ matched.push(null);
661
+ }
662
+ }
663
+ return matched;
664
+ }
665
+ function retractSpan(steering, targetId) {
666
+ const index = steering.findIndex((item) => item.id === targetId);
667
+ if (index === -1) return [];
668
+ return steering.slice(index).map((item) => item.id);
669
+ }
670
+
671
+ // src/client/portals.tsx
580
672
  var import_jsx_runtime = require("react/jsx-runtime");
581
673
  function userTextAt(session, seq) {
582
674
  const snap = session.getSnapshot();
@@ -609,16 +701,36 @@ async function runRewindAndFill(session, seq, mode, currentSessionId) {
609
701
  const result = await session.command(`/rewind @${seq} ${mode}`);
610
702
  if (!result.ok || result.value?.matched !== true) return;
611
703
  const outcome = await waitForCommand(session, (node) => isExecutedRewindCommand(node, seq) && !known.has(node.seq), 2e4);
612
- if (outcome === null || outcome.kind !== "success") return;
704
+ if (outcome === null) return;
705
+ if (outcome.kind !== "success") {
706
+ showHint(outcome.text ?? "rewind failed");
707
+ return;
708
+ }
613
709
  if (currentSessionId() !== session.sessionId) return;
614
710
  const text = userTextAt(session, seq);
615
711
  if (text === void 0 || text === "") return;
616
712
  fillComposer(text);
617
713
  }
714
+ function showHint(text) {
715
+ const textarea = document.querySelector(COMPOSER_SELECTOR);
716
+ const hint = document.createElement("div");
717
+ hint.className = CLASS.guardHint;
718
+ hint.setAttribute("role", "status");
719
+ hint.textContent = text;
720
+ document.body.appendChild(hint);
721
+ if (textarea !== null) {
722
+ const card = textarea.closest("[data-composer-card]");
723
+ const rect = card instanceof HTMLElement ? card.getBoundingClientRect() : textarea.getBoundingClientRect();
724
+ hint.style.left = `${Math.round(rect.left)}px`;
725
+ hint.style.bottom = `${Math.round(window.innerHeight - rect.top + 8)}px`;
726
+ }
727
+ window.setTimeout(() => hint.remove(), 3200);
728
+ }
618
729
  var COMPOSER_SELECTOR = "[data-input-scroll] textarea, textarea[data-phase]";
619
730
  var USER_SEAT_SELECTOR = '[data-chat-flow-kind="user"][data-chat-anchor-key], [data-chat-flow-kind="steering"][data-chat-anchor-key]';
620
731
  var CHAT_SEAT_SELECTOR = "[data-chat-anchor-key]";
621
732
  var ACTIONS_ROOT_SELECTOR = "[data-time-hover-root]";
733
+ var PENDING_SEAT_SELECTOR = "[data-pending-steering][data-time-hover-root]";
622
734
  function collectTargets(chat, hiddenSeqs) {
623
735
  const rows = /* @__PURE__ */ new Map();
624
736
  for (const element of document.querySelectorAll(USER_SEAT_SELECTOR)) {
@@ -635,14 +747,51 @@ function collectTargets(chat, hiddenSeqs) {
635
747
  const messageRoot = row?.querySelector(ACTIONS_ROOT_SELECTOR);
636
748
  const actions = messageRoot?.lastElementChild;
637
749
  if (!(actions instanceof HTMLElement) || actions.querySelector("button") === null) continue;
638
- targets.push({ key, container: actions, seq: user.seq, time: user.time, preview: messagePreviewOf(user) });
750
+ targets.push({ kind: "durable", key, container: actions, seq: user.seq, time: user.time, preview: messagePreviewOf(user) });
751
+ }
752
+ return targets;
753
+ }
754
+ function bubbleTextOf(row) {
755
+ const clone = row.cloneNode(true);
756
+ clone.lastElementChild?.remove();
757
+ return clone.textContent ?? "";
758
+ }
759
+ function collectPendingTargets(snapshot) {
760
+ if (snapshot.subagent !== null) return [];
761
+ const steering = snapshot.queue.filter((item) => item.placement === "steering");
762
+ if (steering.length === 0) return [];
763
+ const rows = Array.from(document.querySelectorAll(PENDING_SEAT_SELECTOR));
764
+ const matched = matchPendingRows(
765
+ rows.map((row) => ({ text: bubbleTextOf(row) })),
766
+ steering.map((item) => ({ id: item.id, text: item.text }))
767
+ );
768
+ const targets = [];
769
+ for (let i = 0; i < matched.length; i++) {
770
+ const itemId = matched[i];
771
+ if (itemId === null) continue;
772
+ const row = rows[i];
773
+ if (row === void 0) continue;
774
+ const messageRoot = row.matches(ACTIONS_ROOT_SELECTOR) ? row : row.querySelector(ACTIONS_ROOT_SELECTOR);
775
+ const actions = messageRoot?.lastElementChild;
776
+ if (!(actions instanceof HTMLElement) || actions.querySelector("button") === null) continue;
777
+ const item = steering[i];
778
+ targets.push({
779
+ kind: "pending",
780
+ key: `pending:${itemId}`,
781
+ container: actions,
782
+ itemId,
783
+ text: item.text,
784
+ preview: item.preview
785
+ });
639
786
  }
640
787
  return targets;
641
788
  }
642
789
  function sameTargets(left, right) {
643
790
  return left.length === right.length && left.every((target, index) => {
644
791
  const other = right[index];
645
- return other !== void 0 && target.key === other.key && target.container === other.container && target.seq === other.seq;
792
+ if (other === void 0 || target.key !== other.key || target.container !== other.container) return false;
793
+ if (target.kind === "durable") return other.kind === "durable" && target.seq === other.seq;
794
+ return other.kind === "pending" && target.itemId === other.itemId;
646
795
  });
647
796
  }
648
797
  function RewindPortals({ sessionId, sessionOf, currentSessionId, t, subscribeLocale }) {
@@ -662,7 +811,8 @@ function RewindPortals({ sessionId, sessionOf, currentSessionId, t, subscribeLoc
662
811
  setTargets([]);
663
812
  return;
664
813
  }
665
- const chat = session.getSnapshot().chat;
814
+ const snapshot = session.getSnapshot();
815
+ const chat = snapshot.chat;
666
816
  const hiddenSeqs = hiddenSeqsOf(chat);
667
817
  let hiddenCount = 0;
668
818
  for (const seat of document.querySelectorAll(CHAT_SEAT_SELECTOR)) {
@@ -684,7 +834,7 @@ function RewindPortals({ sessionId, sessionOf, currentSessionId, t, subscribeLoc
684
834
  `[dsh-rewind] hiding: ${hiddenCount} rows, seqs [${[...hiddenSeqs].slice(0, 20).join(", ")}${hiddenSeqs.size > 20 ? "\u2026" : ""}]`
685
835
  );
686
836
  }
687
- const next = collectTargets(chat, hiddenSeqs);
837
+ const next = [...collectTargets(chat, hiddenSeqs), ...collectPendingTargets(snapshot)];
688
838
  setTargets((current) => sameTargets(current, next) ? current : next);
689
839
  };
690
840
  const queueRefresh = () => {
@@ -704,7 +854,16 @@ function RewindPortals({ sessionId, sessionOf, currentSessionId, t, subscribeLoc
704
854
  };
705
855
  }, [sessionId, sessionOf]);
706
856
  return targets.map((target) => (0, import_react_dom.createPortal)(
707
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
857
+ target.kind === "pending" ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
858
+ RetractButton,
859
+ {
860
+ target,
861
+ sessionId,
862
+ sessionOf,
863
+ t
864
+ },
865
+ target.key
866
+ ) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
708
867
  RewindButton,
709
868
  {
710
869
  target,
@@ -753,6 +912,49 @@ function RewindButton({ target, sessionId, sessionOf, currentSessionId, t }) {
753
912
  }
754
913
  );
755
914
  }
915
+ function composerText() {
916
+ const textarea = document.querySelector(COMPOSER_SELECTOR);
917
+ return textarea === null ? "" : textarea.value;
918
+ }
919
+ async function retractPending(session, itemId, text) {
920
+ await session.cancel();
921
+ const queue = session.getSnapshot().queue;
922
+ const steering = queue.filter((item) => item.placement === "steering");
923
+ for (const id of retractSpan(steering, itemId)) {
924
+ await session.updateQueue(id, { kind: "remove" });
925
+ }
926
+ if (text !== null && text !== "" && composerText().trim() === "") {
927
+ fillComposer(text);
928
+ }
929
+ }
930
+ function RetractButton({ target, sessionId, sessionOf, t }) {
931
+ const onClick = (event) => {
932
+ event.stopPropagation();
933
+ const session = sessionOf(sessionId);
934
+ if (session === void 0) return;
935
+ openPopover({
936
+ session,
937
+ preview: target.preview,
938
+ anchor: event.currentTarget,
939
+ t,
940
+ retract: { itemId: target.itemId, text: target.text },
941
+ onRetract: () => {
942
+ void retractPending(session, target.itemId, target.text);
943
+ }
944
+ });
945
+ };
946
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
947
+ "button",
948
+ {
949
+ type: "button",
950
+ className: CLASS.button,
951
+ "aria-label": t("button.retract.aria"),
952
+ title: t("button.retract.title"),
953
+ onClick,
954
+ dangerouslySetInnerHTML: { __html: REWIND_ICON_SVG }
955
+ }
956
+ );
957
+ }
756
958
  function createRewindBridge(deps) {
757
959
  return function RewindBridge({ sessionId }) {
758
960
  return (0, import_react.createElement)(RewindPortals, { sessionId, ...deps });
@@ -763,8 +965,14 @@ function createRewindBridge(deps) {
763
965
  var zh = {
764
966
  "button.aria": "\u56DE\u9000\u5230\u6B64\u6D88\u606F",
765
967
  "button.title": "\u56DE\u9000",
968
+ "button.retract.aria": "\u56DE\u9000\u5230\u6B64\u63D2\u8BDD\u6D88\u606F",
969
+ "button.retract.title": "\u56DE\u9000",
766
970
  "popover.title": "\u56DE\u9000\u5230\u8FD9\u6761\u6D88\u606F",
767
971
  "popover.noText": "\uFF08\u65E0\u6587\u672C\uFF09",
972
+ "popover.retract.title": "\u56DE\u9000\u5230\u8FD9\u6761\u63D2\u8BDD\u6D88\u606F",
973
+ "popover.retract.target": "\u63D2\u8BDD\u4E2D \xB7 {preview}",
974
+ "popover.retract.hint": "\u5C06\u505C\u6B62\u5F53\u524D\u751F\u6210\uFF0C\u5E76\u56DE\u9000\u5230\u8BE5\u6D88\u606F\u4E4B\u524D",
975
+ "popover.retract.confirm": "\u786E\u8BA4\u56DE\u9000",
768
976
  "popover.chat": "\u4EC5\u56DE\u9000\u5BF9\u8BDD",
769
977
  "popover.chat.hint": "\u53EA\u56DE\u9000\u6A21\u578B\u4E0A\u4E0B\u6587\uFF0C\u4E0D\u52A8\u5DE5\u4F5C\u533A\u6587\u4EF6",
770
978
  "popover.both": "\u56DE\u9000\u5BF9\u8BDD\u548C\u4EE3\u7801",
@@ -784,8 +992,14 @@ var zh = {
784
992
  var en = {
785
993
  "button.aria": "Rewind to this message",
786
994
  "button.title": "Rewind",
995
+ "button.retract.aria": "Rewind to this pending message",
996
+ "button.retract.title": "Rewind",
787
997
  "popover.title": "Rewind to this message",
788
998
  "popover.noText": "(no text)",
999
+ "popover.retract.title": "Rewind to this pending message",
1000
+ "popover.retract.target": "Pending \xB7 {preview}",
1001
+ "popover.retract.hint": "Stops the current run and rewinds to before this message",
1002
+ "popover.retract.confirm": "Confirm rewind",
789
1003
  "popover.chat": "Rewind conversation only",
790
1004
  "popover.chat.hint": "Cut the model context only; workspace files stay untouched",
791
1005
  "popover.both": "Rewind conversation and code",
package/lib/index.js CHANGED
@@ -90,6 +90,15 @@ function markerTurnOf(events) {
90
90
  }
91
91
  return lastStarted;
92
92
  }
93
+ function markerStepOf(events, turn) {
94
+ let lastStarted = 0;
95
+ for (const event of events) {
96
+ if (event.type === "step/start" && event.data.turn === turn && event.data.step > lastStarted) {
97
+ lastStarted = event.data.step;
98
+ }
99
+ }
100
+ return lastStarted + 1;
101
+ }
93
102
  function isUserMessageEvent(event) {
94
103
  return event.type === "user/message";
95
104
  }
@@ -653,6 +662,11 @@ async function waitForAgentIdle(agent, signal, timeoutMs = 15e3) {
653
662
  if (onAbort !== void 0) signal.removeEventListener("abort", onAbort);
654
663
  }
655
664
  }
665
+ function dropPendingSteering(agent) {
666
+ for (const message of [...agent.inbox.nextStep]) {
667
+ agent.inbox.remove(message.id);
668
+ }
669
+ }
656
670
  async function executeRewind(ctx, store, fs, invocation, rawTarget, mode, inflight) {
657
671
  const { agent } = invocation;
658
672
  const sessionId = agent.session.id;
@@ -662,12 +676,13 @@ async function executeRewind(ctx, store, fs, invocation, rawTarget, mode, inflig
662
676
  inflight.add(sessionId);
663
677
  try {
664
678
  if (agent.status !== "idle") {
665
- agent.cancel({ kind: "user" });
679
+ agent.cancel({ kind: "user" }, { keepInbox: true });
666
680
  const stopped = await waitForAgentIdle(agent, invocation.signal);
667
681
  if (!stopped) {
668
682
  return { kind: "error", text: t("stopFailed") };
669
683
  }
670
684
  }
685
+ dropPendingSteering(agent);
671
686
  if (invocation.signal.aborted) {
672
687
  return { kind: "error", text: t("cancelled") };
673
688
  }
@@ -680,10 +695,19 @@ async function executeRewind(ctx, store, fs, invocation, rawTarget, mode, inflig
680
695
  const marker = buildMarker();
681
696
  let event;
682
697
  try {
683
- event = agent.session.append("assistant/message", { turn: markerTurnOf(agent.session.events), step: 0, message: marker }, {
684
- surfaceOp: { op: "replace", start: plan.surfaceStart, end: plan.surfaceEnd },
685
- sourceEventSeqs: [...plan.shadowedSeqs]
686
- });
698
+ const turn = markerTurnOf(agent.session.events);
699
+ const step = markerStepOf(agent.session.events, turn);
700
+ agent.session.append("step/start", { turn, step });
701
+ try {
702
+ event = agent.session.append("assistant/message", { turn, step, message: marker }, {
703
+ surfaceOp: { op: "replace", start: plan.surfaceStart, end: plan.surfaceEnd },
704
+ sourceEventSeqs: [...plan.shadowedSeqs]
705
+ });
706
+ } catch (error) {
707
+ agent.session.append("step/end", { turn, step });
708
+ throw error;
709
+ }
710
+ agent.session.append("step/end", { turn, step });
687
711
  } catch (error) {
688
712
  return {
689
713
  kind: "error",
@@ -35,3 +35,8 @@ export declare const inject: string[];
35
35
  * @param ctx - client root context carrying `slots`, `sessions`, `locale` and `commandUi`.
36
36
  */
37
37
  export declare function apply(ctx: ClientContext): void;
38
+ /**
39
+ * Public contract — rewind visibility. Stable, semver-protected; the rest of
40
+ * this module is internal. See `docs/client-contract.md`.
41
+ */
42
+ export { hiddenSeqsOf, targetSeqOfArgs, type HiddenChat } from './hidden.ts';
@@ -3,8 +3,14 @@
3
3
  export declare const zh: {
4
4
  'button.aria': string;
5
5
  'button.title': string;
6
+ 'button.retract.aria': string;
7
+ 'button.retract.title': string;
6
8
  'popover.title': string;
7
9
  'popover.noText': string;
10
+ 'popover.retract.title': string;
11
+ 'popover.retract.target': string;
12
+ 'popover.retract.hint': string;
13
+ 'popover.retract.confirm': string;
8
14
  'popover.chat': string;
9
15
  'popover.chat.hint': string;
10
16
  'popover.both': string;
@@ -33,8 +39,14 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
33
39
  export declare const en: {
34
40
  'button.aria': string;
35
41
  'button.title': string;
42
+ 'button.retract.aria': string;
43
+ 'button.retract.title': string;
36
44
  'popover.title': string;
37
45
  'popover.noText': string;
46
+ 'popover.retract.title': string;
47
+ 'popover.retract.target': string;
48
+ 'popover.retract.hint': string;
49
+ 'popover.retract.confirm': string;
38
50
  'popover.chat': string;
39
51
  'popover.chat.hint': string;
40
52
  'popover.both': string;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Pure pending-message matching: pairs the rendered pending-steering bubble
3
+ * rows with the session's transient queue mirror rows (`placement === 'steering'`).
4
+ *
5
+ * Both sides derive from the host's next-step inbox order — the ChatView
6
+ * renders `pendingSteering` in array order and the queue mirror keeps the same
7
+ * host order — so index-primary matching is reliable. Text equality is still
8
+ * verified as a per-row cross-check, and a row that fails (or a row with no
9
+ * mirror item, or a mirror item with no row) is skipped INDIVIDUALLY: one bad
10
+ * row never takes down the other rows' buttons. The matching text is the
11
+ * bubble's message text WITHOUT its actions container — the harness copy
12
+ * button's Tooltip mounts a label bubble inside that container on hover, so
13
+ * the full row textContent would flip between "message" and "message+Copy"
14
+ * with the mouse, flickering the button (see `bubbleTextOf` in portals.tsx).
15
+ *
16
+ * The browser half lives in `portals.tsx`; this module stays DOM-free so the
17
+ * matching contract is unit-testable in a plain node environment.
18
+ *
19
+ * @module dsh-rewind/client/pending
20
+ */
21
+ /** One rendered pending-steering bubble row (only the fields matching reads). */
22
+ export interface PendingRow {
23
+ /** The bubble's message text, excluding the actions container (see module doc). */
24
+ readonly text: string;
25
+ }
26
+ /** One steering occurrence from the session queue mirror. */
27
+ export interface PendingSteeringItem {
28
+ readonly id: string;
29
+ /** Complete editable text; null when the message contains non-text blocks. */
30
+ readonly text: string | null;
31
+ }
32
+ /**
33
+ * Pair rows to steering items by index, verifying text equality per row.
34
+ * @param rows - pending bubble rows in DOM order (== render order).
35
+ * @param steering - steering queue items in host order (== render order).
36
+ * @returns the item id for each row, or null for rows that cannot be matched
37
+ * safely (missing counterpart, text mismatch). A bad row never affects the
38
+ * other rows.
39
+ */
40
+ export declare function matchPendingRows(rows: readonly PendingRow[], steering: readonly PendingSteeringItem[]): readonly (string | null)[];
41
+ /**
42
+ * The pending-steering ids a "rewind to this pre-sent message" retracts: the
43
+ * target occurrence and every steering message after it, in inbox (FIFO)
44
+ * order. Queued (next-turn) messages are deliberately NOT included — the
45
+ * harness QueueDock already offers the user per-item edit/remove, so a rewind
46
+ * must not silently drop messages the user may still want to send.
47
+ * @param steering - steering queue items in host order (== render order).
48
+ * @param targetId - the rewind target's inbox occurrence id.
49
+ * @returns the ids to remove, oldest-first; empty when the target is no
50
+ * longer pending (already claimed/consumed).
51
+ */
52
+ export declare function retractSpan(steering: readonly {
53
+ readonly id: string;
54
+ }[], targetId: string): readonly string[];
@@ -19,8 +19,17 @@ import type { RewindKey } from './locales.ts';
19
19
  type Translate = (key: RewindKey, params?: Record<string, unknown>) => string;
20
20
  export interface PopoverOptions {
21
21
  readonly session: SessionFace;
22
- readonly seq: number;
23
- readonly time: number;
22
+ /** Durable variant: the target message seq (mode-selection flow). */
23
+ readonly seq?: number;
24
+ /** Durable variant: the target message time. */
25
+ readonly time?: number;
26
+ /** Pending variant: retract a pre-sent steering message (single-confirm flow). */
27
+ readonly retract?: {
28
+ readonly itemId: string;
29
+ readonly text: string | null;
30
+ };
31
+ /** Pending variant: executed after the retract confirm closes the popover. */
32
+ readonly onRetract?: () => void;
24
33
  readonly preview: string;
25
34
  /** The button that opened the popover (outside-click ignore target). */
26
35
  readonly anchor: HTMLElement;
@@ -30,7 +39,7 @@ export interface PopoverOptions {
30
39
  * the callback owns the command + composer-refill lifecycle (see
31
40
  * runRewindAndFill in index.ts).
32
41
  */
33
- readonly onRewind: (mode: 'chat' | 'both') => void;
42
+ readonly onRewind?: (mode: 'chat' | 'both') => void;
34
43
  }
35
44
  /** Close the current popover, if any. */
36
45
  export declare function closePopover(): void;
@@ -26,6 +26,28 @@ import { type ReactNode } from 'react';
26
26
  import type { SessionFace } from '@deepseek-ai/dsh-client-runtime/client';
27
27
  import type { RewindKey } from './locales.ts';
28
28
  type Translate = (key: RewindKey, params?: Record<string, unknown>) => string;
29
+ /** One portal target: the actions row of a user/steering seat + its durable node. */
30
+ export type PortalTarget = {
31
+ readonly kind: 'durable';
32
+ /** The seat's chat node key (React reconciliation + diff identity). */
33
+ readonly key: string;
34
+ /** The row's actions container (React portal target). */
35
+ readonly container: HTMLElement;
36
+ readonly seq: number;
37
+ readonly time: number;
38
+ readonly preview: string;
39
+ } | {
40
+ readonly kind: 'pending';
41
+ /** `pending:${itemId}` — stable per inbox occurrence. */
42
+ readonly key: string;
43
+ /** The row's actions container (React portal target). */
44
+ readonly container: HTMLElement;
45
+ /** The host inbox occurrence the retract button addresses. */
46
+ readonly itemId: string;
47
+ /** Complete editable text; null when the message contains non-text blocks. */
48
+ readonly text: string | null;
49
+ readonly preview: string;
50
+ };
29
51
  /** Capabilities the session-scoped bridge receives from the plugin apply(). */
30
52
  export interface RewindBridgeDeps {
31
53
  readonly sessionOf: (sessionId: string) => SessionFace | undefined;
@@ -9,6 +9,24 @@
9
9
  * transcript) is untouched; only the model-visible surface is cut, so the
10
10
  * next request derives its context from the target message onward.
11
11
  *
12
+ * Marker shape (v0.3.4+): the marker is an EMPTY `assistant/message` with a
13
+ * replace `surfaceOp`, wrapped in its own step frame —
14
+ *
15
+ * step/start (turn, step) → assistant/message (marker) → step/end (turn, step)
16
+ *
17
+ * The step frame exists because the harness token-meter replays the log and
18
+ * requires every `assistant/message` to sit inside an OPEN step whose
19
+ * `(turn, step)` matches exactly (`token meter: assistant/message at seq N
20
+ * has no matching step/start event` otherwise) — a bare marker appended while
21
+ * idle (every step already closed) makes every later measure() call throw,
22
+ * which disables /compact and automatic compaction. The frame's `turn` is the
23
+ * LAST STARTED turn (`markerTurnOf`) and `step` is that turn's next unused
24
+ * step number (`markerStepOf`): never a reused one, or the client
25
+ * conversation assembler sees a duplicate `step/start` and rejects the log
26
+ * with "received more than one start Match". The agent loop numbers its own
27
+ * steps from memory (each new turn restarts at 1), so the ghost step can
28
+ * never collide with a future real step.
29
+ *
12
30
  * @module dsh-rewind/rewind
13
31
  */
14
32
  import type { SessionEvent, UserMessage } from '@deepseek-ai/dsh-session';
@@ -89,6 +107,25 @@ export declare const DEFAULT_CANDIDATE_LIMIT = 50;
89
107
  * @returns a turn number the harness can never reuse for a future `turn/start`.
90
108
  */
91
109
  export declare function markerTurnOf(events: readonly SessionEvent[]): number;
110
+ /**
111
+ * Step number for the rewind marker's ghost step frame.
112
+ *
113
+ * The marker's `assistant/message` must be wrapped in `step/start` …
114
+ * `step/end` of the SAME `(turn, step)` so the harness token-meter replay
115
+ * accepts it (see the module doc). The step number MUST be a step this turn
116
+ * has never started: the client conversation assembler treats `step/start`
117
+ * as the start of an `assistant-step` context keyed `turn:step`, so reusing
118
+ * an already-started step number makes the log replay throw "received more
119
+ * than one start Match" and the history disappears from the UI. Reusing
120
+ * `lastStep + 1` is always safe: the harness numbers a turn's steps from
121
+ * memory (each new turn restarts at 1), so the ghost step can never collide
122
+ * with a future real step of this turn.
123
+ *
124
+ * @param events - the full session event log.
125
+ * @param turn - the marker's turn (normally `markerTurnOf(events)`).
126
+ * @returns the smallest step number this turn has never started (≥ 1).
127
+ */
128
+ export declare function markerStepOf(events: readonly SessionEvent[], turn: number): number;
92
129
  /** Narrow an event to a user message. */
93
130
  export declare function isUserMessageEvent(event: SessionEvent): event is SessionEvent<'user/message'>;
94
131
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-rewind-plugin",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "DeepSeek Harness plugin: in-place conversation rewind in the same session window (Claude Code /rewind semantics) with optional workspace file restore",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -23,9 +23,6 @@
23
23
  "type": "module",
24
24
  "main": "lib/index.js",
25
25
  "types": "lib/types/index.d.ts",
26
- "bin": {
27
- "dsh-rewind-repair": "scripts/repair-markers.mjs"
28
- },
29
26
  "exports": {
30
27
  ".": {
31
28
  "types": "./lib/types/index.d.ts",
@@ -44,8 +41,7 @@
44
41
  "README.zh.md",
45
42
  "docs",
46
43
  "assets",
47
- "LICENSE",
48
- "scripts/repair-markers.mjs"
44
+ "LICENSE"
49
45
  ],
50
46
  "dsh": {
51
47
  "bundle": {
@@ -63,7 +59,7 @@
63
59
  },
64
60
  "scripts": {
65
61
  "build": "node scripts/build.mjs",
66
- "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.client.json",
62
+ "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.client.json && tsc --noEmit -p tsconfig.client-test.json",
67
63
  "test": "vitest run",
68
64
  "verify:host": "node scripts/verify-host.mjs",
69
65
  "prepare": "npm run build"
@@ -132,6 +128,8 @@
132
128
  "@deepseek-ai/dsh-client-ui-input-trigger": "^0.1.0-rc.7",
133
129
  "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.7",
134
130
  "@deepseek-ai/dsh-commands": "^0.1.0-rc.7",
131
+ "@deepseek-ai/dsh-compaction": "^0.1.0-rc.7",
132
+ "@deepseek-ai/dsh-token-meter": "^0.1.0-rc.7",
135
133
  "@deepseek-ai/dsh-fs": "^0.1.0-rc.7",
136
134
  "@deepseek-ai/dsh-llm": "^0.1.0-rc.7",
137
135
  "@deepseek-ai/dsh-sandbox": "^0.1.0-rc.7",
@@ -1,176 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Offline repair for sessions corrupted by the ≤ 0.2.4 marker turn-number bug.
4
- *
5
- * Symptom: reopening a session shows
6
- * Failed to load history: conversation Context …:turn-tail… received an
7
- * update before its start Match (internal)
8
- * and the history disappears. Root cause: the rewind marker was numbered
9
- * `lastTurn + 1`, which is exactly how the harness numbers its NEXT real turn —
10
- * so the log holds an `assistant/message` (the marker) BEFORE the `turn/start`
11
- * of the same turn, and the client conversation-context builder rejects that
12
- * ordering.
13
- *
14
- * This script rewrites ONLY the `data.turn` of `dsh-rewind` empty-marker events
15
- * to the last turn/start that precedes them (a turn the harness has already
16
- * consumed and can never reuse). It preserves:
17
- * - every other event byte-for-byte (seqs, times, surface metadata, …),
18
- * - the JSONL record order and line structure,
19
- * - the zstd multi-frame structure the persistence backend expects
20
- * (each frame is decoded, fixed, and re-compressed independently, with the
21
- * "last started turn" state threaded across frames).
22
- *
23
- * The original file is backed up to `session.jsonl.zstd.bak-<timestamp>` before
24
- * the first write. Run with the host fully stopped (a resident session would
25
- * overwrite the repaired file at its next checkpoint), then restart dsh web.
26
- *
27
- * Usage:
28
- * node scripts/repair-markers.mjs # scan ~/.dsh/sessions
29
- * node scripts/repair-markers.mjs --dry-run # report only, no writes
30
- * node scripts/repair-markers.mjs --dir <sessions root>
31
- */
32
-
33
- import { readdirSync, readFileSync, writeFileSync, copyFileSync, statSync, existsSync } from 'node:fs'
34
- import { join } from 'node:path'
35
- import { homedir } from 'node:os'
36
- import { zstdCompressSync, zstdDecompressSync } from 'node:zlib'
37
-
38
- const ZSTD_MAGIC = 0xfd2fb528
39
- const MARKER_SOURCE = { provider: 'dsh-rewind', model: 'rewind-marker' }
40
- const DEFAULT_ROOT = join(homedir(), '.dsh', 'sessions')
41
-
42
- /** @param {Buffer} buffer @returns {{start:number,end:number}[]} */
43
- function scanFrames(buffer) {
44
- const frames = []
45
- let offset = 0
46
- while (offset < buffer.length) {
47
- const start = offset
48
- if (buffer.length - offset < 4 || buffer.readUInt32LE(offset) !== ZSTD_MAGIC) break
49
- offset += 4
50
- const next = buffer.indexOf(Buffer.from([0x28, 0xb5, 0x2f, 0xfd]), offset)
51
- frames.push(next === -1 ? { start, end: buffer.length } : { start, end: next })
52
- offset = next === -1 ? buffer.length : next
53
- }
54
- return frames
55
- }
56
-
57
- /**
58
- * Rewrite marker turn numbers in ONE frame's JSONL text. Line-preserving: the
59
- * fix never adds or removes lines, only rewrites a marker line's turn. State
60
- * (`lastStarted`) is threaded across frames, because a marker in a later frame
61
- * must know the turn/start events of earlier frames.
62
- * @param {string} text - this frame's JSONL text.
63
- * @param {{lastStarted:number}} state - running "last started turn" state.
64
- * @returns {{text:string, changed:number}} the fixed text (identical when
65
- * nothing changed) and the number of rewritten markers in this frame.
66
- */
67
- function fixMarkerTurns(text, state) {
68
- const lines = text.split('\n')
69
- let changed = 0
70
- const out = []
71
- for (const line of lines) {
72
- if (line.trim() === '') {
73
- out.push(line)
74
- continue
75
- }
76
- let record
77
- try {
78
- record = JSON.parse(line)
79
- } catch {
80
- out.push(line) // keep unknown/torn lines verbatim
81
- continue
82
- }
83
- if (record && record.type === 'turn/start' && Number.isSafeInteger(record.data?.turn)) {
84
- if (record.data.turn > state.lastStarted) state.lastStarted = record.data.turn
85
- out.push(line)
86
- continue
87
- }
88
- const isMarker =
89
- record?.type === 'assistant/message' &&
90
- record.data?.message?.source?.provider === MARKER_SOURCE.provider &&
91
- record.data?.message?.source?.model === MARKER_SOURCE.model
92
- if (!isMarker) {
93
- out.push(line)
94
- continue
95
- }
96
- const badTurn = record.data.turn
97
- if (badTurn === state.lastStarted) {
98
- out.push(line) // already safe (post-0.2.5 marker) — idempotent
99
- continue
100
- }
101
- changed += 1
102
- out.push(JSON.stringify({ ...record, data: { ...record.data, turn: state.lastStarted } }))
103
- }
104
- return { text: out.join('\n'), changed }
105
- }
106
-
107
- /** Repair one session artifact file. @returns {string} a human summary line. */
108
- function repairFile(file, dryRun) {
109
- const original = readFileSync(file)
110
- const frames = scanFrames(original)
111
- const state = { lastStarted: 0 }
112
- const fixedTexts = []
113
- let changed = 0
114
- for (const frame of frames) {
115
- const frameText = zstdDecompressSync(original.subarray(frame.start, frame.end)).toString('utf8')
116
- const result = fixMarkerTurns(frameText, state)
117
- changed += result.changed
118
- fixedTexts.push(result.text)
119
- }
120
- if (changed === 0) return ` ok (no marker fix needed): ${file}`
121
- if (dryRun) return ` would fix ${changed} marker(s): ${file}`
122
- const stamp = new Date().toISOString().replace(/[:.]/g, '-')
123
- copyFileSync(file, `${file}.bak-${stamp}`)
124
- // Preserve the zstd frame structure: one re-compressed frame per original
125
- // frame, in order.
126
- writeFileSync(file, Buffer.concat(fixedTexts.map(text => zstdCompressSync(Buffer.from(text)))))
127
- return ` fixed ${changed} marker(s): ${file} (backup: ${file}.bak-${stamp})`
128
- }
129
-
130
- function collectSessions(root) {
131
- const files = []
132
- for (const project of readdirSync(root)) {
133
- const projectDir = join(root, project)
134
- let stat
135
- try {
136
- stat = statSync(projectDir)
137
- } catch {
138
- continue
139
- }
140
- if (!stat.isDirectory()) continue
141
- for (const entry of readdirSync(projectDir)) {
142
- const file = join(projectDir, entry, 'session.jsonl.zstd')
143
- if (existsSync(file)) files.push(file)
144
- }
145
- }
146
- return files
147
- }
148
-
149
- const args = process.argv.slice(2)
150
- const dryRun = args.includes('--dry-run')
151
- const dirIndex = args.indexOf('--dir')
152
- const root = dirIndex !== -1 && args[dirIndex + 1] !== undefined ? args[dirIndex + 1] : DEFAULT_ROOT
153
-
154
- if (!existsSync(root)) {
155
- console.error(`sessions root not found: ${root}`)
156
- process.exit(1)
157
- }
158
-
159
- console.log(`${dryRun ? '[dry-run] ' : ''}scanning ${root}`)
160
- const files = collectSessions(root)
161
- console.log(`${files.length} session artifact(s) found`)
162
- let fixedCount = 0
163
- for (const file of files) {
164
- try {
165
- const line = repairFile(file, dryRun)
166
- if (!line.includes('no marker fix needed')) {
167
- console.log(line)
168
- fixedCount += 1
169
- }
170
- } catch (error) {
171
- console.error(` ERROR ${file}: ${error instanceof Error ? error.message : String(error)}`)
172
- }
173
- }
174
- console.log(fixedCount === 0
175
- ? 'no corrupted sessions found — nothing to do'
176
- : `${dryRun ? 'would repair' : 'repaired'} ${fixedCount} session(s). ${dryRun ? '' : 'Restart dsh web to reload the repaired histories.'}`)