dsh-session-rewind 1.2.7 → 1.3.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
@@ -1,163 +1,163 @@
1
- # dsh-session-rewind · 会话回退
2
-
3
- A DSH Web plugin that puts **Rewind** on every finished Turn: a per-Turn control
4
- in the Chat timeline plus one entry in the Session header, branching the
5
- conversation at that Turn and restoring the files that Turn changed.
6
-
7
- 在 DSH Web 里回退到任意**已完成的回合**:对话时间线的每个回合旁有 `回退` 按钮,会话标题栏还有一个
8
- 总入口,列出本会话所有可回退的回合;回退会从该回合处分叉出新会话,并把该回合改动过的文件恢复原状。
9
-
10
- ## 两个入口 · Two entry points
11
-
12
- | 位置 | 作用 |
13
- | --- | --- |
14
- | **你发出的那条消息旁的 ↶** | 点开选「仅回退对话」或「回退对话并恢复文件」。**就地回退**:这条消息及之后的对话内容从模型上下文撤回,原话立刻回到本会话输入框,改完直接重发。 |
15
- | 会话标题栏的 ↶(`conversation.session.header.actions`) | 列出本会话**所有**可回退的消息(第 N 轮 + 提问预览 + 文件数),↑↓ 选择、Enter 确认、Esc 返回;选中后同样是就地回退,也可以选「在新会话中分支」。 |
16
- | 每个已完成回合末尾的 `回退`(`conversation.chat.turnTail`) | 旧入口,保留兼容:就这一轮做分叉回退,面板里可勾选要恢复的文件。 |
17
-
18
- **核心行为(就地回退)**:选中你某条消息 → 追加一条 `surfaceOp: replace` 标记,把模型可见的
19
- surface 从这条消息起整体撤回 → 被撤回的行从对话里消失(轨迹面板仍可查,日志从未删改)→
20
- 这条消息的**原文放进输入框**,你可以修改后重新发送。会话不换、不新建。
21
-
22
- ## What it does · 功能
23
-
24
- Choosing a Turn opens a small panel with two independent halves:
25
-
26
- 1. **Rewind the conversation.** A committed Turn cannot be edited in place: the
27
- session log is append-only and branching is the supported operation. The
28
- panel therefore creates a *new* session that ends exactly at that Turn
29
- (`sessions.fork` with the Turn's `turn/end` seq), opens it, and pre-fills its
30
- composer with the Turn's original user prompt so you can edit it and send it
31
- again. The original session is left untouched.
32
- 2. **Restore the files that Turn changed.** While a Turn runs, every file the
33
- model creates or rewrites is captured *before* the mutation and stored as a
34
- content-addressed blob. The panel lists those files; the checked ones are put
35
- back to their pre-Turn content (a file the Turn created is removed).
36
-
37
- The two halves can be used together (`回退对话`) or separately
38
- (`仅恢复文件`).
39
-
40
- 选中某个回合后:
41
-
42
- 1. **回退对话**:会话日志是只追加的,所以“回退”= 在该回合的 `turn/end` 处**分叉**出一个新会话并打开它,
43
- 同时把该回合原本的用户输入写回新会话的输入框,你可以直接修改后重新发送;原会话完全不动。
44
- 2. **恢复文件**:该回合运行期间,每个被创建或改写的文件都会在**写入前**抓取一份原始字节存档。
45
- 面板会列出这些文件,勾选的会被恢复为修改前的内容;该回合新建的文件会被删除。
46
-
47
- ### 就地撤回 vs 分叉:两个都给了
48
-
49
- - **就地回退(主)**:同一会话内撤回,靠一条 `surfaceOp: replace` 标记实现——这是 DSH 自己
50
- 压缩(compaction)用的同一机制,日志只追加、不删改。
51
- - **分叉(次)**:`sessions.fork` 在该回合 `turn/end` 处新建一份会话,内置「在新对话中分支」
52
- 按钮用的就是它,升级最稳。
53
-
54
- 唯一的妥协在**前端隐藏被撤回的行**:DSH 没有为用户消息提供动作槽,也没有公开「隐藏某段行」
55
- 的接口,所以这里按参考插件 `SiriLee/dsh-rewind` 的做法在前端补:给每条用户消息注入一个 ↶
56
- (锚点取行内最后一个按钮的父节点),回退后按 `data-chat-turn` 隐藏该行及之后的行。全部特性
57
- 检测、卸载即还原;DSH 若在未来提供动作槽,这里会切成槽位实现。
58
-
59
- ### 参考插件为什么没有全盘照搬
60
-
61
- 社区里的 [`SiriLee/dsh-rewind`](https://github.com/SiriLee/dsh-rewind) 提供**就地回退**(同一会话内
62
- 把模型可见 surface 切回更早的用户消息,靠追加一条 `surfaceOp: replace` 标记实现)。我们借鉴了它的
63
- **入口与交互**(每条消息/每轮旁的 ↶、模式选择、文件影响清单、↑↓/Enter/Esc、标题栏候选列表),但**没有**
64
- 采用它的就地撤回,原因是:
65
-
66
- - 就地撤回不仅要追加标记,还必须在**前端**把被撤回的行隐藏起来,而 DSH 没有为此提供扩展点——
67
- 参考插件是通过包装私有的 `uiConversation.views` registry 做到的,其源码自己注明
68
- *"The seam is private … not an extension point"*,随版本升级会破。
69
- - `sessions.fork` 是 DSH 给 UI 的官方分支能力(内置「在新对话中分支」按钮用的就是它),
70
- 升级稳定,且天然满足“上下文被截断 + 可改可重发”。
71
-
72
- 如果你更希望要就地撤回(同一会话、不产生新会话),可以另装 `dsh-rewind-plugin`;两者不冲突。
73
-
74
- ## Install · 安装
75
-
76
- **从插件市场安装(推荐)**:左侧 **插件 → 发现(Discover)** → **索引源** → 添加
77
- `https://raw.githubusercontent.com/2310770803/dsh-session-rewind/main/index.json`
78
- → 搜索 `rewind` 或 `会话回退` → 安装。索引格式与发布流程见 [docs/publishing.md](docs/publishing.md)。
79
-
80
- 本机(profile `desktop`)已把这个索引源写进 `cordis.patch.yml`,打开「发现」即可看到条目。
81
-
82
- **从源码目录安装**(本机当前就是这种方式):
83
-
84
- ```js
85
- // plugin_manager
86
- { action: 'install_bundle', target: 'E:\\DeepSeek Harness\\dsh-session-rewind' }
87
- ```
88
-
89
- ## How it is put together · 实现
90
-
91
- | half | mechanism |
92
- | --- | --- |
93
- | conversation | Client `sessions.fork({ sessionId, atSeq })` + `uiWorkspace.openSession`, the same idiom as the built-in branch button; the draft is pre-seeded through the Conversation store's per-session `localStorage` key so the composer already holds the original prompt |
94
- | files (capture) | `fs/write-intent` / `fs/edit-intent` waterfalls (`{ prepend: true }`), with the tool execution mapped back to its Session through a `tools/pre-execute` `WeakMap` — the same coupling `workspace-changes` uses |
95
- | files (restore) | text pre-images through the `fs` service (path resolution, locks, version guard); binary pre-images and deletions through Node, on the archive's own recorded absolute path |
96
-
97
- ## Where data lives · 数据位置
98
-
99
- ```
100
- $DSH_HOME/plugin-data/dsh-session-rewind/
101
- index.json per-session, per-Turn file records
102
- blobs/<sha256>.bin the pre-Turn bytes
103
- ```
104
-
105
- Nothing leaves the machine: the four Host routes are loopback-only,
106
- same-origin `/api` routes with DSH Web's own Host/Origin trust fence.
107
-
108
- `GET /api/session-rewind.selfcheck` reports what the running Host has actually
109
- wired up (archive totals, whether the Web half is in the boot graph, the route
110
- list). It is read-only and never exposes session content.
111
-
112
- ## Limits · 限制
113
-
114
- - Only mutations that go through the DSH filesystem service inside a Turn are
115
- captured. Files changed by a shell command, by `git`, or outside a Turn are
116
- not recorded and therefore not restored.
117
- - Files written by tools that bypass the `fs` write-intent waterfall are not
118
- captured either: the Office tools (`word_create`, `excel_update`, …) call the
119
- filesystem service directly, so a `.docx`/`.xlsx` they produce is **not** in the
120
- archive and cannot be rewound. Plain text, Markdown, and source code edited
121
- through the `write`/`edit` tools are covered.
122
- - A Turn snapshots at most 200 files, and no pre-image larger than 2 MiB; larger
123
- files are listed as "not restorable" instead of being silently skipped.
124
- - Files inside `.git` are never captured, so a restore cannot corrupt the
125
- repository.
126
- - The archive keeps the newest 400 Turns; older records are pruned with their
127
- blobs.
128
- - A restore writes the recorded content back. It does not re-run anything, and it
129
- does not delete files the Turn merely *modified*.
130
- - A plugin-originated write has no Agent context, so the `fs` service refuses it
131
- by default. Restores rebuild the policy from the Turn's own workspace
132
- (`workspace-write`) and widen to `danger-full-access` only for a path the
133
- archive recorded outside it.
134
- - Capture starts with the Turn that is open when the plugin mounts: a Turn
135
- already running at install time is recovered from the live log tail, but a
136
- plugin loaded later than the Turn's first write cannot see earlier mutations.
137
- - The naming and wording of the panel follow the DSH locale service; the bundled
138
- dictionaries are Chinese and English.
139
-
140
- ## Tests · 测试
141
-
142
- The package ships the suites used to verify it; they need no browser and no
143
- profile:
144
-
145
- ```
146
- node test/selftest.mjs # Turn location, prompt extraction, archive, restorer
147
- node test/route-smoke.mjs # all routes, their fences, and path allow-listing
148
- node test/client-smoke.mjs # the Web half: both slots, panel, candidate list, fork, draft
149
- ```
150
-
151
- ## Package layout
152
-
153
- | file | role |
154
- | --- | --- |
155
- | `cordis.patch.yml` | inserts the `session-rewind` plugin row |
156
- | `src/index.js` | Host entry, re-exports `apply` / `inject` / `name` |
157
- | `src/plugin.js` | Host wiring: Turn tracking, mutation capture, the four routes |
158
- | `src/lib/archive.js` | durable pre-Turn archive, pruning, blob store |
159
- | `src/lib/restore.js` | putting one captured file back |
160
- | `src/lib/route.js` | routes and the `/api` trust fence |
161
- | `lib/client.js` | Web half: the turn-tail control, the header entry, the panel |
162
- | `locale/*.json` | `meta.title` / `meta.description` for the plugin list |
163
- | `index.json` | format-1 plugin-market index (see `docs/publishing.md`) |
1
+ # dsh-session-rewind · 会话回退
2
+
3
+ A DSH Web plugin that puts **Rewind** on every finished Turn: a per-Turn control
4
+ in the Chat timeline plus one entry in the Session header, branching the
5
+ conversation at that Turn and restoring the files that Turn changed.
6
+
7
+ 在 DSH Web 里回退到任意**已完成的回合**:对话时间线的每个回合旁有 `回退` 按钮,会话标题栏还有一个
8
+ 总入口,列出本会话所有可回退的回合;回退会从该回合处分叉出新会话,并把该回合改动过的文件恢复原状。
9
+
10
+ ## 两个入口 · Two entry points
11
+
12
+ | 位置 | 作用 |
13
+ | --- | --- |
14
+ | **你发出的那条消息旁的 ↶** | 点开选「仅回退对话」或「回退对话并恢复文件」。**就地回退**:这条消息及之后的对话内容从模型上下文撤回,原话立刻回到本会话输入框,改完直接重发。 |
15
+ | 会话标题栏的 ↶(`conversation.session.header.actions`) | 列出本会话**所有**可回退的消息(第 N 轮 + 提问预览 + 文件数),↑↓ 选择、Enter 确认、Esc 返回;选中后同样是就地回退,也可以选「在新会话中分支」。 |
16
+ | 每个已完成回合末尾的 `回退`(`conversation.chat.turnTail`) | 旧入口,保留兼容:就这一轮做分叉回退,面板里可勾选要恢复的文件。 |
17
+
18
+ **核心行为(就地回退)**:选中你某条消息 → 追加一条 `surfaceOp: replace` 标记,把模型可见的
19
+ surface 从这条消息起整体撤回 → 被撤回的行从对话里消失(轨迹面板仍可查,日志从未删改)→
20
+ 这条消息的**原文放进输入框**,你可以修改后重新发送。会话不换、不新建。
21
+
22
+ ## What it does · 功能
23
+
24
+ Choosing a Turn opens a small panel with two independent halves:
25
+
26
+ 1. **Rewind the conversation.** A committed Turn cannot be edited in place: the
27
+ session log is append-only and branching is the supported operation. The
28
+ panel therefore creates a *new* session that ends exactly at that Turn
29
+ (`sessions.fork` with the Turn's `turn/end` seq), opens it, and pre-fills its
30
+ composer with the Turn's original user prompt so you can edit it and send it
31
+ again. The original session is left untouched.
32
+ 2. **Restore the files that Turn changed.** While a Turn runs, every file the
33
+ model creates or rewrites is captured *before* the mutation and stored as a
34
+ content-addressed blob. The panel lists those files; the checked ones are put
35
+ back to their pre-Turn content (a file the Turn created is removed).
36
+
37
+ The two halves can be used together (`回退对话`) or separately
38
+ (`仅恢复文件`).
39
+
40
+ 选中某个回合后:
41
+
42
+ 1. **回退对话**:会话日志是只追加的,所以“回退”= 在该回合的 `turn/end` 处**分叉**出一个新会话并打开它,
43
+ 同时把该回合原本的用户输入写回新会话的输入框,你可以直接修改后重新发送;原会话完全不动。
44
+ 2. **恢复文件**:该回合运行期间,每个被创建或改写的文件都会在**写入前**抓取一份原始字节存档。
45
+ 面板会列出这些文件,勾选的会被恢复为修改前的内容;该回合新建的文件会被删除。
46
+
47
+ ### 就地撤回 vs 分叉:两个都给了
48
+
49
+ - **就地回退(主)**:同一会话内撤回,靠一条 `surfaceOp: replace` 标记实现——这是 DSH 自己
50
+ 压缩(compaction)用的同一机制,日志只追加、不删改。
51
+ - **分叉(次)**:`sessions.fork` 在该回合 `turn/end` 处新建一份会话,内置「在新对话中分支」
52
+ 按钮用的就是它,升级最稳。
53
+
54
+ 唯一的妥协在**前端隐藏被撤回的行**:DSH 没有为用户消息提供动作槽,也没有公开「隐藏某段行」
55
+ 的接口,所以这里按参考插件 `SiriLee/dsh-rewind` 的做法在前端补:给每条用户消息注入一个 ↶
56
+ (锚点取行内最后一个按钮的父节点),回退后按 `data-chat-turn` 隐藏该行及之后的行。全部特性
57
+ 检测、卸载即还原;DSH 若在未来提供动作槽,这里会切成槽位实现。
58
+
59
+ ### 参考插件为什么没有全盘照搬
60
+
61
+ 社区里的 [`SiriLee/dsh-rewind`](https://github.com/SiriLee/dsh-rewind) 提供**就地回退**(同一会话内
62
+ 把模型可见 surface 切回更早的用户消息,靠追加一条 `surfaceOp: replace` 标记实现)。我们借鉴了它的
63
+ **入口与交互**(每条消息/每轮旁的 ↶、模式选择、文件影响清单、↑↓/Enter/Esc、标题栏候选列表),但**没有**
64
+ 采用它的就地撤回,原因是:
65
+
66
+ - 就地撤回不仅要追加标记,还必须在**前端**把被撤回的行隐藏起来,而 DSH 没有为此提供扩展点——
67
+ 参考插件是通过包装私有的 `uiConversation.views` registry 做到的,其源码自己注明
68
+ *"The seam is private … not an extension point"*,随版本升级会破。
69
+ - `sessions.fork` 是 DSH 给 UI 的官方分支能力(内置「在新对话中分支」按钮用的就是它),
70
+ 升级稳定,且天然满足“上下文被截断 + 可改可重发”。
71
+
72
+ 如果你更希望要就地撤回(同一会话、不产生新会话),可以另装 `dsh-rewind-plugin`;两者不冲突。
73
+
74
+ ## Install · 安装
75
+
76
+ **从插件市场安装(推荐)**:左侧 **插件 → 发现(Discover)** → **索引源** → 添加
77
+ `https://raw.githubusercontent.com/2310770803/dsh-session-rewind/main/index.json`
78
+ → 搜索 `rewind` 或 `会话回退` → 安装。索引格式与发布流程见 [docs/publishing.md](docs/publishing.md)。
79
+
80
+ 本机(profile `desktop`)已把这个索引源写进 `cordis.patch.yml`,打开「发现」即可看到条目。
81
+
82
+ **从源码目录安装**(本机当前就是这种方式):
83
+
84
+ ```js
85
+ // plugin_manager
86
+ { action: 'install_bundle', target: 'E:\\DeepSeek Harness\\dsh-session-rewind' }
87
+ ```
88
+
89
+ ## How it is put together · 实现
90
+
91
+ | half | mechanism |
92
+ | --- | --- |
93
+ | conversation | Client `sessions.fork({ sessionId, atSeq })` + `uiWorkspace.openSession`, the same idiom as the built-in branch button; the draft is pre-seeded through the Conversation store's per-session `localStorage` key so the composer already holds the original prompt |
94
+ | files (capture) | `fs/write-intent` / `fs/edit-intent` waterfalls (`{ prepend: true }`), with the tool execution mapped back to its Session through a `tools/pre-execute` `WeakMap` — the same coupling `workspace-changes` uses |
95
+ | files (restore) | text pre-images through the `fs` service (path resolution, locks, version guard); binary pre-images and deletions through Node, on the archive's own recorded absolute path |
96
+
97
+ ## Where data lives · 数据位置
98
+
99
+ ```
100
+ $DSH_HOME/plugin-data/dsh-session-rewind/
101
+ index.json per-session, per-Turn file records
102
+ blobs/<sha256>.bin the pre-Turn bytes
103
+ ```
104
+
105
+ Nothing leaves the machine: the four Host routes are loopback-only,
106
+ same-origin `/api` routes with DSH Web's own Host/Origin trust fence.
107
+
108
+ `GET /api/session-rewind.selfcheck` reports what the running Host has actually
109
+ wired up (archive totals, whether the Web half is in the boot graph, the route
110
+ list). It is read-only and never exposes session content.
111
+
112
+ ## Limits · 限制
113
+
114
+ - Only mutations that go through the DSH filesystem service inside a Turn are
115
+ captured. Files changed by a shell command, by `git`, or outside a Turn are
116
+ not recorded and therefore not restored.
117
+ - Files written by tools that bypass the `fs` write-intent waterfall are not
118
+ captured either: the Office tools (`word_create`, `excel_update`, …) call the
119
+ filesystem service directly, so a `.docx`/`.xlsx` they produce is **not** in the
120
+ archive and cannot be rewound. Plain text, Markdown, and source code edited
121
+ through the `write`/`edit` tools are covered.
122
+ - A Turn snapshots at most 200 files, and no pre-image larger than 2 MiB; larger
123
+ files are listed as "not restorable" instead of being silently skipped.
124
+ - Files inside `.git` are never captured, so a restore cannot corrupt the
125
+ repository.
126
+ - The archive keeps the newest 400 Turns; older records are pruned with their
127
+ blobs.
128
+ - A restore writes the recorded content back. It does not re-run anything, and it
129
+ does not delete files the Turn merely *modified*.
130
+ - A plugin-originated write has no Agent context, so the `fs` service refuses it
131
+ by default. Restores rebuild the policy from the Turn's own workspace
132
+ (`workspace-write`) and widen to `danger-full-access` only for a path the
133
+ archive recorded outside it.
134
+ - Capture starts with the Turn that is open when the plugin mounts: a Turn
135
+ already running at install time is recovered from the live log tail, but a
136
+ plugin loaded later than the Turn's first write cannot see earlier mutations.
137
+ - The naming and wording of the panel follow the DSH locale service; the bundled
138
+ dictionaries are Chinese and English.
139
+
140
+ ## Tests · 测试
141
+
142
+ The package ships the suites used to verify it; they need no browser and no
143
+ profile:
144
+
145
+ ```
146
+ node test/selftest.mjs # Turn location, prompt extraction, archive, restorer
147
+ node test/route-smoke.mjs # all routes, their fences, and path allow-listing
148
+ node test/client-smoke.mjs # the Web half: both slots, panel, candidate list, fork, draft
149
+ ```
150
+
151
+ ## Package layout
152
+
153
+ | file | role |
154
+ | --- | --- |
155
+ | `cordis.patch.yml` | inserts the `session-rewind` plugin row |
156
+ | `src/index.js` | Host entry, re-exports `apply` / `inject` / `name` |
157
+ | `src/plugin.js` | Host wiring: Turn tracking, mutation capture, the four routes |
158
+ | `src/lib/archive.js` | durable pre-Turn archive, pruning, blob store |
159
+ | `src/lib/restore.js` | putting one captured file back |
160
+ | `src/lib/route.js` | routes and the `/api` trust fence |
161
+ | `lib/client.js` | Web half: the turn-tail control, the header entry, the panel |
162
+ | `locale/*.json` | `meta.title` / `meta.description` for the plugin list |
163
+ | `index.json` | format-1 plugin-market index (see `docs/publishing.md`) |
package/lib/client.js CHANGED
@@ -65,7 +65,9 @@ loader.load({
65
65
  const INPLACE_ROUTE = '/api/session-rewind.inplace';
66
66
  const CUTS_ROUTE = '/api/session-rewind.cuts';
67
67
  const DIAGNOSE_ROUTE = '/api/session-rewind.diagnose';
68
- const BOOT_REVISION = '1.2.5';
68
+ const TAIL_ROUTE = '/api/session-rewind.tail';
69
+ const BOOT_REVISION = '1.3.0';
70
+ const HIDDEN_ATTR = 'data-session-rewind-hidden';
69
71
 
70
72
  /** One per-message bridge per session, shared by every TurnRewind instance. */
71
73
  const bridgeRegistry = new Map();
@@ -236,6 +238,7 @@ loader.load({
236
238
  '.dshSessionRewindRoundFiles{flex:none;font-size:10px;line-height:15px;color:var(--dsw-alias-label-secondary)}',
237
239
  '.dshSessionRewindModeText{margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-primary);',
238
240
  'font-family:var(--dsw-font-family);word-break:break-word;max-height:96px;overflow:auto}',
241
+ '[data-session-rewind-hidden="true"]{display:none !important}',
239
242
  ].join('\n');
240
243
  //#endregion
241
244
 
@@ -767,7 +770,7 @@ loader.load({
767
770
  const state = {
768
771
  disposed: false,
769
772
  turnToSeq: new Map(),
770
- cutTurn: null,
773
+ hiddenTurns: new Set(),
771
774
  buttons: new Map(),
772
775
  hiddenRows: new Map(),
773
776
  observer: null,
@@ -908,24 +911,33 @@ loader.load({
908
911
  };
909
912
 
910
913
  /** Hide or restore the rendered rows at or after the executed cut. */
914
+ /**
915
+ * Hide exactly the rows of the Turns a rewind withdrew.
916
+ *
917
+ * The attribute (plus a `display: none !important` rule in SHEET) is used
918
+ * instead of an inline style because the Chat re-renders its rows and would
919
+ * drop an inline override; an unknown attribute survives that. Turns the user
920
+ * starts after rewinding are not in the set, so they stay visible.
921
+ */
911
922
  const applyHiding = () => {
912
923
  if (state.disposed) return;
913
924
  const rows = document.querySelectorAll('[data-chat-turn]');
914
925
  for (const row of rows) {
915
926
  const turn = Number(row.getAttribute('data-chat-turn'));
916
927
  if (!Number.isInteger(turn)) continue;
917
- const shouldHide = state.cutTurn !== null && turn >= state.cutTurn;
918
- const already = state.hiddenRows.get(row);
919
- if (shouldHide && already === undefined) {
920
- state.hiddenRows.set(row, row.style.display);
921
- row.style.display = 'none';
922
- } else if (!shouldHide && already !== undefined) {
923
- row.style.display = already;
924
- state.hiddenRows.delete(row);
928
+ if (state.hiddenTurns.has(turn)) {
929
+ if (!row.hasAttribute(HIDDEN_ATTR)) row.setAttribute(HIDDEN_ATTR, 'true');
930
+ } else if (row.hasAttribute(HIDDEN_ATTR)) {
931
+ row.removeAttribute(HIDDEN_ATTR);
925
932
  }
926
933
  }
927
934
  };
928
935
 
936
+ const revealAll = () => {
937
+ state.hiddenTurns.clear();
938
+ for (const row of document.querySelectorAll(`[${HIDDEN_ATTR}]`)) row.removeAttribute(HIDDEN_ATTR);
939
+ };
940
+
929
941
  const closePopover = () => {
930
942
  if (state.popover !== null) {
931
943
  try {
@@ -943,6 +955,13 @@ loader.load({
943
955
  const result = await postJson(INPLACE_ROUTE, { sessionId, seq, restoreFiles });
944
956
  if (state.disposed) return;
945
957
  if (Number.isInteger(result.hiddenFromTurn)) state.cutTurn = result.hiddenFromTurn;
958
+ if (Array.isArray(result.hiddenTurns) && result.hiddenTurns.length > 0) {
959
+ for (const turn of result.hiddenTurns) {
960
+ if (Number.isInteger(turn)) state.hiddenTurns.add(turn);
961
+ }
962
+ } else if (Number.isInteger(result.hiddenFromTurn)) {
963
+ state.hiddenTurns = new Set([result.hiddenFromTurn]);
964
+ }
946
965
  applyHiding();
947
966
  const filled = setComposer(typeof result.prompt === 'string' ? result.prompt : '');
948
967
  sendDiagnose(sessionId, {
@@ -953,7 +972,7 @@ loader.load({
953
972
  promptChars: typeof result.prompt === 'string' ? result.prompt.length : 0,
954
973
  restore: result.restore ?? null,
955
974
  injected: state.buttons.size,
956
- hiddenRows: state.hiddenRows.size,
975
+ hiddenRows: state.hiddenTurns.size,
957
976
  });
958
977
  const restore = result.restore;
959
978
  const parts = [];
@@ -1152,6 +1171,7 @@ loader.load({
1152
1171
  button.addEventListener('click', (event) => {
1153
1172
  event.preventDefault();
1154
1173
  event.stopPropagation();
1174
+ sendDiagnose(sessionId, { reason: 'click', turn, seq, buttons: state.buttons.size, mapped: state.turnToSeq.size });
1155
1175
  openPopover(button, seq, notice);
1156
1176
  });
1157
1177
  anchor.appendChild(button);
@@ -1219,7 +1239,11 @@ loader.load({
1219
1239
  try {
1220
1240
  const value = await postJson(CUTS_ROUTE, { sessionId });
1221
1241
  if (state.disposed) return;
1222
- state.cutTurn = Number.isInteger(value.hiddenFromTurn) ? value.hiddenFromTurn : null;
1242
+ if (Array.isArray(value.hiddenTurns)) {
1243
+ state.hiddenTurns = new Set(value.hiddenTurns.filter((turn) => Number.isInteger(turn)));
1244
+ } else if (Number.isInteger(value.hiddenFromTurn)) {
1245
+ state.hiddenTurns = new Set([value.hiddenFromTurn]);
1246
+ }
1223
1247
  applyHiding();
1224
1248
  } catch {
1225
1249
  /* ignore */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-session-rewind",
3
- "version": "1.2.7",
3
+ "version": "1.3.0",
4
4
  "type": "module",
5
5
  "description": "Rewind a finished Turn: branch the conversation at any Turn (session header or per-Turn control) and restore every workspace file that Turn changed. · 回合回退:从任意已完成的回合重新开始,并把该回合改动过的工作区文件恢复原状。",
6
6
  "icon": "./icon.svg",
@@ -10,7 +10,7 @@
10
10
 
11
11
  import { randomUUID } from 'node:crypto';
12
12
 
13
- import { hiddenFromSeq, markerPayload, planInPlace, textOf } from './surface.js';
13
+ import { hiddenFromSeq, markerPayload, planInPlace, textOf, withdrawnTurns } from './surface.js';
14
14
 
15
15
  /** Waiting budget for a cancelled Turn to reach quiescence. */
16
16
  const IDLE_TIMEOUT_MS = 20_000;
@@ -128,7 +128,14 @@ export function describeInPlace(session, seq) {
128
128
  * hiddenFromSeq: number | null, restore?: object,
129
129
  * }>}
130
130
  */
131
- export async function executeInPlace({ ctx, sessionId, seq, restoreTurns, timeoutMs = IDLE_TIMEOUT_MS }) {
131
+ export async function executeInPlace({
132
+ ctx,
133
+ sessionId,
134
+ seq,
135
+ restoreTurns,
136
+ readEvents,
137
+ timeoutMs = IDLE_TIMEOUT_MS,
138
+ }) {
132
139
  const agent = liveAgentOf(ctx, sessionId);
133
140
  if (agent === undefined) {
134
141
  const error = new Error('this session is not live, so it cannot be rewound in place');
@@ -192,12 +199,21 @@ export async function executeInPlace({ ctx, sessionId, seq, restoreTurns, timeou
192
199
  hidden = plan.surfaceStart;
193
200
  }
194
201
 
202
+ let hiddenTurns = [];
203
+ try {
204
+ const after = typeof readEvents === 'function' ? await readEvents(sessionId) : [];
205
+ hiddenTurns = [...withdrawnTurns(after)];
206
+ } catch {
207
+ /* the cut still applied; the client falls back to hiding from plan.turn */
208
+ }
209
+
195
210
  return {
196
211
  markerSeq: typeof event?.seq === 'number' ? event.seq : -1,
197
212
  targetSeq: plan.targetSeq,
198
213
  prompt: plan.prompt,
199
214
  turn: plan.turn,
200
215
  hiddenFromSeq: hidden,
216
+ hiddenTurns,
201
217
  ...(restore === undefined ? {} : { restore }),
202
218
  };
203
219
  }
package/src/lib/route.js CHANGED
@@ -21,6 +21,7 @@ export const CANDIDATES_PATH = '/api/session-rewind.candidates';
21
21
  export const INPLACE_PATH = '/api/session-rewind.inplace';
22
22
  export const CUTS_PATH = '/api/session-rewind.cuts';
23
23
  export const DIAGNOSE_PATH = '/api/session-rewind.diagnose';
24
+ export const TAIL_PATH = '/api/session-rewind.tail';
24
25
  export const SELFCHECK_PATH = '/api/session-rewind.selfcheck';
25
26
 
26
27
  /** A rewind request is a session id plus a Turn number or a seq. */
@@ -198,6 +199,77 @@ function targetOf(value) {
198
199
  return { sessionId, turn, seq };
199
200
  }
200
201
 
202
+ /**
203
+ * Build the event-tail route.
204
+ *
205
+ * Returns the last N events of a Session in reading order with just enough shape
206
+ * to reason about a rewind: seq, type, source kind and a short text sample. It is
207
+ * the only way to see whether a marker actually landed, because the on-disk log is
208
+ * compressed and may lag behind the live surface.
209
+ *
210
+ * @param {{
211
+ * events: (sessionId: string) => Promise<readonly unknown[]>,
212
+ * trustedHosts?: () => readonly string[],
213
+ * onError?: (error: unknown) => void,
214
+ * }} options
215
+ * @returns {(request: import('node:http').IncomingMessage, response: import('node:http').ServerResponse) => Promise<void>}
216
+ */
217
+ export function createTailHandler({ events, trustedHosts = () => [], onError = () => {} }) {
218
+ return async (request, response) => {
219
+ try {
220
+ if (request.method !== 'POST') {
221
+ writeText(response, 405, 'method not allowed');
222
+ return;
223
+ }
224
+ if (!isTrustedRequest(request, hostsOf(trustedHosts))) {
225
+ writeText(response, 403, 'forbidden');
226
+ return;
227
+ }
228
+ const mediaType = header(request.headers, 'content-type')?.split(';', 1)[0]?.trim().toLowerCase();
229
+ if (mediaType !== 'application/json') {
230
+ writeText(response, 415, 'content type must be application/json');
231
+ return;
232
+ }
233
+ const value = await readJsonBody(request);
234
+ if (!isRecord(value)) throw new RequestBoundaryError(400, 'request body must be a JSON object');
235
+ const sessionId = typeof value.sessionId === 'string' ? value.sessionId : '';
236
+ if (sessionId === '') throw new RequestBoundaryError(400, 'sessionId is required');
237
+ const limit = Number.isInteger(value.limit) && value.limit > 0 && value.limit <= 300 ? value.limit : 40;
238
+ const all = await events(sessionId);
239
+ const slice = all.slice(-limit);
240
+ writeJson(response, 200, {
241
+ ok: true,
242
+ total: all.length,
243
+ events: slice.map((event) => summarizeEvent(event)),
244
+ });
245
+ } catch (error) {
246
+ fail(response, error, onError);
247
+ }
248
+ };
249
+ }
250
+
251
+ /**
252
+ * Reduce one Session event to the fields worth inspecting.
253
+ *
254
+ * @param {unknown} event
255
+ * @returns {{ seq: number | null, type: string, sourceKind: string | null, role: string | null, text: string | null }}
256
+ */
257
+ function summarizeEvent(event) {
258
+ const record = isRecord(event) ? event : {};
259
+ const data = isRecord(record.data) ? record.data : {};
260
+ const source = isRecord(data.source) ? data.source : {};
261
+ const content = Array.isArray(data.content) ? data.content : [];
262
+ const first = content.find((part) => isRecord(part) && part.type === 'text');
263
+ const text = isRecord(first) && typeof first.text === 'string' ? first.text.slice(0, 60) : null;
264
+ return {
265
+ seq: Number.isInteger(record.seq) ? record.seq : null,
266
+ type: typeof record.type === 'string' ? record.type : '(none)',
267
+ sourceKind: typeof source.kind === 'string' ? source.kind : null,
268
+ role: typeof data.role === 'string' ? data.role : null,
269
+ text,
270
+ };
271
+ }
272
+
201
273
  /**
202
274
  * Build the browser-diagnostics route.
203
275
  *
@@ -169,6 +169,41 @@ export function hiddenFromTurn(events) {
169
169
  return Number.isInteger(turn) ? turn : null;
170
170
  }
171
171
 
172
+ /**
173
+ * Turns whose rows belong to a withdrawn stretch of the log.
174
+ *
175
+ * `hiddenFromSeq` gives the first withdrawn Seq, but the Chat view keys rows by
176
+ * Turn, not Seq — and a plain "turn >= first" rule would also hide the turns the
177
+ * user starts *after* rewinding. The exact set is the turns spanned by the events
178
+ * in `[startSeq, markerSeq)`: everything the marker replaced, and nothing after it.
179
+ *
180
+ * @param {readonly unknown[]} events
181
+ * @returns {readonly number[]}
182
+ */
183
+ export function withdrawnTurns(events) {
184
+ const startSeq = hiddenFromSeq(events);
185
+ if (startSeq === null) return [];
186
+ let markerSeq = null;
187
+ for (const event of events) {
188
+ if (!isRecord(event) || event.type !== 'user/message') continue;
189
+ if (event.data?.source?.kind !== MARKER_SOURCE_KIND) continue;
190
+ if (typeof event.seq === 'number' && (markerSeq === null || event.seq < markerSeq)) markerSeq = event.seq;
191
+ }
192
+ if (markerSeq === null || markerSeq <= startSeq) return [];
193
+ const turns = new Set();
194
+ let current = null;
195
+ for (const event of events) {
196
+ if (!isRecord(event) || typeof event.seq !== 'number') continue;
197
+ const turn = event.data?.turn;
198
+ if (Number.isInteger(turn) && event.type === 'turn/start') current = turn;
199
+ else if (Number.isInteger(turn) && event.type === 'turn/end') current = turn;
200
+ if (event.seq < startSeq) continue;
201
+ if (event.seq >= markerSeq) break;
202
+ if (Number.isInteger(current)) turns.add(current);
203
+ }
204
+ return [...turns].sort((left, right) => left - right);
205
+ }
206
+
172
207
  /**
173
208
  * Build the marker event payload. `id` is minted here because the Host's own
174
209
  * `createUserMessage` helper is not importable from a profile bundle.