dsh-session-rewind 1.3.0 → 1.3.1

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/lib/client.js CHANGED
@@ -66,7 +66,7 @@ loader.load({
66
66
  const CUTS_ROUTE = '/api/session-rewind.cuts';
67
67
  const DIAGNOSE_ROUTE = '/api/session-rewind.diagnose';
68
68
  const TAIL_ROUTE = '/api/session-rewind.tail';
69
- const BOOT_REVISION = '1.3.0';
69
+ const BOOT_REVISION = '1.3.1';
70
70
  const HIDDEN_ATTR = 'data-session-rewind-hidden';
71
71
 
72
72
  /** One per-message bridge per session, shared by every TurnRewind instance. */
@@ -870,6 +870,7 @@ loader.load({
870
870
  : `${tr('notice.failed')}(HTTP ${response.status})`),
871
871
  );
872
872
  error.status = response.status;
873
+ error.code = value !== null && typeof value === 'object' && typeof value.code === 'string' ? value.code : null;
873
874
  throw error;
874
875
  }
875
876
  return value;
@@ -949,7 +950,7 @@ loader.load({
949
950
  }
950
951
  };
951
952
 
952
- const runRewind = async (seq, restoreFiles, notice) => {
953
+ const runRewind = async (seq, restoreFiles, notice, fromTurn = null) => {
953
954
  notice(tr('notice.working'));
954
955
  try {
955
956
  const result = await postJson(INPLACE_ROUTE, { sessionId, seq, restoreFiles });
@@ -982,11 +983,24 @@ loader.load({
982
983
  }
983
984
  notice(parts.length === 0 ? tr('notice.rewound') : `${tr('notice.rewound')} · ${parts.join(' · ')}`);
984
985
  } catch (error) {
986
+ const code = error !== null && typeof error === 'object' ? error.code ?? null : null;
985
987
  sendDiagnose(sessionId, {
986
988
  reason: 'rewind-failed',
987
989
  error: String(error && error.message ? error.message : error),
990
+ code,
988
991
  sequence: seq,
989
992
  });
993
+ if (code === 'not-on-surface') {
994
+ // Already withdrawn by an earlier rewind: hide the row and say so,
995
+ // instead of reporting a failure the user cannot act on.
996
+ const turn = Number.isInteger(fromTurn) ? fromTurn : null;
997
+ if (Number.isInteger(turn)) {
998
+ state.hiddenTurns.add(turn);
999
+ applyHiding();
1000
+ }
1001
+ notice(tr('notice.already'));
1002
+ return;
1003
+ }
990
1004
  notice(error instanceof Error && error.message !== '' ? error.message : tr('notice.failed'));
991
1005
  }
992
1006
  };
@@ -1039,11 +1053,11 @@ loader.load({
1039
1053
  };
1040
1054
  popover.appendChild(makeButton(tr('popover.chat'), () => {
1041
1055
  closePopover();
1042
- void runRewind(seq, false, notice);
1056
+ void runRewind(seq, false, notice, turn);
1043
1057
  }));
1044
1058
  popover.appendChild(makeButton(tr('popover.both'), () => {
1045
1059
  closePopover();
1046
- void runRewind(seq, true, notice);
1060
+ void runRewind(seq, true, notice, turn);
1047
1061
  }));
1048
1062
  document.body.appendChild(popover);
1049
1063
  state.popover = popover;
package/locale/en.json CHANGED
@@ -2,5 +2,6 @@
2
2
  "meta": {
3
3
  "title": "Session Rewind",
4
4
  "description": "Adds a rewind control to every finished Turn plus one entry in the session header. Pick a Turn to branch the conversation there into a new session, put that Turn's original prompt back in the composer so you can edit and resend it, and restore the workspace files that Turn changed to their pre-Turn content."
5
- }
5
+ },
6
+ "notice.already": "That message was already rewound; its row is now hidden."
6
7
  }
package/locale/zh.json CHANGED
@@ -2,5 +2,6 @@
2
2
  "meta": {
3
3
  "title": "会话回退",
4
4
  "description": "在每个已完成的回合旁加一个回退按钮,并在会话标题栏提供总入口:选中某个回合即可把对话从该回合处分叉成新会话,把该回合原本的输入放回输入框以便修改后重发,同时把该回合改动过的工作区文件恢复到修改前的内容。"
5
- }
5
+ },
6
+ "notice.already": "这条消息已经回退过了,已从界面隐藏。"
6
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-session-rewind",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
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",
package/src/lib/route.js CHANGED
@@ -22,6 +22,7 @@ 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
24
  export const TAIL_PATH = '/api/session-rewind.tail';
25
+ export const SURFACE_PATH = '/api/session-rewind.surface';
25
26
  export const SELFCHECK_PATH = '/api/session-rewind.selfcheck';
26
27
 
27
28
  /** A rewind request is a session id plus a Turn number or a seq. */
@@ -199,6 +200,58 @@ function targetOf(value) {
199
200
  return { sessionId, turn, seq };
200
201
  }
201
202
 
203
+ /**
204
+ * Build the surface route.
205
+ *
206
+ * `planInPlace` refuses a rewind when the target Seq is not a node of the live
207
+ * Session's surface, so when a click is refused the only useful question is what
208
+ * that surface actually contains. This exposes it read-only.
209
+ *
210
+ * @param {{
211
+ * surfaceOf: (sessionId: string) => { nodes?: readonly unknown[]; replaceGeneration?: number; contentGeneration?: number } | null,
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 createSurfaceHandler({ surfaceOf, 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 live = surfaceOf(sessionId);
238
+ if (live === null) throw new RequestBoundaryError(409, 'this session is not open right now');
239
+ const nodes = Array.isArray(live.nodes) ? live.nodes.map((node) => (typeof node === 'number' ? node : null)) : [];
240
+ const tail = Number.isInteger(value.tail) && value.tail > 0 && value.tail <= 200 ? value.tail : 20;
241
+ writeJson(response, 200, {
242
+ ok: true,
243
+ count: nodes.length,
244
+ replaceGeneration: live.replaceGeneration ?? null,
245
+ contentGeneration: live.contentGeneration ?? null,
246
+ head: nodes.slice(0, 10),
247
+ tail: nodes.slice(-tail),
248
+ });
249
+ } catch (error) {
250
+ fail(response, error, onError);
251
+ }
252
+ };
253
+ }
254
+
202
255
  /**
203
256
  * Build the event-tail route.
204
257
  *
@@ -172,34 +172,38 @@ export function hiddenFromTurn(events) {
172
172
  /**
173
173
  * Turns whose rows belong to a withdrawn stretch of the log.
174
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.
175
+ * A Session can be rewound more than once, and each rewind appends its own
176
+ * marker, so the withdrawn turns are the union over every marker of the turns
177
+ * spanned by `[surfaceOp.startSeq, markerSeq)`. Taking only the earliest marker —
178
+ * as the first version did — leaves the turns withdrawn later still on screen.
179
179
  *
180
180
  * @param {readonly unknown[]} events
181
181
  * @returns {readonly number[]}
182
182
  */
183
183
  export function withdrawnTurns(events) {
184
- const startSeq = hiddenFromSeq(events);
185
- if (startSeq === null) return [];
186
- let markerSeq = null;
184
+ const spans = [];
187
185
  for (const event of events) {
188
186
  if (!isRecord(event) || event.type !== 'user/message') continue;
189
187
  if (event.data?.source?.kind !== MARKER_SOURCE_KIND) continue;
190
- if (typeof event.seq === 'number' && (markerSeq === null || event.seq < markerSeq)) markerSeq = event.seq;
188
+ if (typeof event.seq !== 'number') continue;
189
+ const op = event.surfaceOp;
190
+ if (!isRecord(op) || op.op !== 'replace' || typeof op.startSeq !== 'number') continue;
191
+ spans.push({ from: op.startSeq, before: event.seq });
191
192
  }
192
- if (markerSeq === null || markerSeq <= startSeq) return [];
193
+ if (spans.length === 0) return [];
193
194
  const turns = new Set();
194
195
  let current = null;
195
196
  for (const event of events) {
196
197
  if (!isRecord(event) || typeof event.seq !== 'number') continue;
197
198
  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);
199
+ if (Number.isInteger(turn) && (event.type === 'turn/start' || event.type === 'turn/end')) current = turn;
200
+ if (!Number.isInteger(current)) continue;
201
+ for (const span of spans) {
202
+ if (event.seq >= span.from && event.seq < span.before) {
203
+ turns.add(current);
204
+ break;
205
+ }
206
+ }
203
207
  }
204
208
  return [...turns].sort((left, right) => left - right);
205
209
  }
package/src/plugin.js CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  CUTS_PATH,
30
30
  DIAGNOSE_PATH,
31
31
  INPLACE_PATH,
32
+ SURFACE_PATH,
32
33
  TAIL_PATH,
33
34
  PLAN_PATH,
34
35
  RESTORE_PATH,
@@ -37,6 +38,7 @@ import {
37
38
  createCutsHandler,
38
39
  createDiagnoseHandler,
39
40
  createInplaceHandler,
41
+ createSurfaceHandler,
40
42
  createTailHandler,
41
43
  createPlanHandler,
42
44
  createRestoreHandler,
@@ -595,7 +597,7 @@ export function apply(ctx) {
595
597
  plugin: 'dsh-session-rewind',
596
598
  archive: { directory: archive.directory, ...archiveStats },
597
599
  client: { discovered: clientPath !== null, path: clientPath },
598
- routes: [SELFCHECK_PATH, DIAGNOSE_PATH, TAIL_PATH, CANDIDATES_PATH, INPLACE_PATH, CUTS_PATH, PLAN_PATH, RESTORE_PATH],
600
+ routes: [SELFCHECK_PATH, DIAGNOSE_PATH, TAIL_PATH, SURFACE_PATH, CANDIDATES_PATH, INPLACE_PATH, CUTS_PATH, PLAN_PATH, RESTORE_PATH],
599
601
  };
600
602
  },
601
603
  trustedHosts,
@@ -629,6 +631,12 @@ export function apply(ctx) {
629
631
  onError,
630
632
  });
631
633
 
634
+ const surfaceHandler = createSurfaceHandler({
635
+ surfaceOf: (sessionId) => liveAgentOf(ctx, sessionId)?.session?.surface ?? null,
636
+ trustedHosts,
637
+ onError,
638
+ });
639
+
632
640
  const tailHandler = createTailHandler({
633
641
  events: async (sessionId) => readEvents(ctx, sessionId),
634
642
  trustedHosts,
@@ -650,6 +658,10 @@ export function apply(ctx) {
650
658
  () => scope.webServer.register({ kind: 'exact', path: SELFCHECK_PATH, handler: statusHandler }),
651
659
  'session-rewind: selfcheck route',
652
660
  );
661
+ scope.effect(
662
+ () => scope.webServer.register({ kind: 'exact', path: SURFACE_PATH, handler: surfaceHandler }),
663
+ 'session-rewind: surface route',
664
+ );
653
665
  scope.effect(
654
666
  () => scope.webServer.register({ kind: 'exact', path: TAIL_PATH, handler: tailHandler }),
655
667
  'session-rewind: tail route',
package/test/selftest.mjs CHANGED
@@ -116,6 +116,25 @@ assert.deepEqual(payload.source, { kind: MARKER_SOURCE_KIND });
116
116
  assert.deepEqual(withdrawnTurns(events), []);
117
117
  }
118
118
 
119
+ // --- two rewinds in one Session -------------------------------------------
120
+ // Each marker withdraws its own span; the union is what must disappear. Missing
121
+ // the second marker is what left the later turns on screen.
122
+ {
123
+ const twice = [
124
+ { seq: 1, type: 'turn/start', data: { turn: 1 } },
125
+ { seq: 2, type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: 'one' }] } },
126
+ { seq: 5, type: 'turn/end', data: { turn: 1 } },
127
+ { seq: 6, type: 'user/message', data: { source: { kind: MARKER_SOURCE_KIND } }, surfaceOp: { op: 'replace', startSeq: 2, endSeq: 5 } },
128
+ { seq: 7, type: 'turn/start', data: { turn: 2 } },
129
+ { seq: 8, type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: 'two' }] } },
130
+ { seq: 11, type: 'turn/end', data: { turn: 2 } },
131
+ { seq: 12, type: 'user/message', data: { source: { kind: MARKER_SOURCE_KIND } }, surfaceOp: { op: 'replace', startSeq: 8, endSeq: 11 } },
132
+ { seq: 13, type: 'turn/start', data: { turn: 3 } },
133
+ { seq: 14, type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: 'three' }] } },
134
+ ];
135
+ assert.deepEqual(withdrawnTurns(twice), [1, 2]);
136
+ }
137
+
119
138
  // --- archive -------------------------------------------------------------
120
139
  const root = await mkdtemp(join(tmpdir(), 'rewind-test-'));
121
140
  const archiveDir = join(root, 'archive');