dsh-session-rewind 1.2.8 → 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
@@ -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.1';
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,
@@ -867,6 +870,7 @@ loader.load({
867
870
  : `${tr('notice.failed')}(HTTP ${response.status})`),
868
871
  );
869
872
  error.status = response.status;
873
+ error.code = value !== null && typeof value === 'object' && typeof value.code === 'string' ? value.code : null;
870
874
  throw error;
871
875
  }
872
876
  return value;
@@ -908,24 +912,33 @@ loader.load({
908
912
  };
909
913
 
910
914
  /** Hide or restore the rendered rows at or after the executed cut. */
915
+ /**
916
+ * Hide exactly the rows of the Turns a rewind withdrew.
917
+ *
918
+ * The attribute (plus a `display: none !important` rule in SHEET) is used
919
+ * instead of an inline style because the Chat re-renders its rows and would
920
+ * drop an inline override; an unknown attribute survives that. Turns the user
921
+ * starts after rewinding are not in the set, so they stay visible.
922
+ */
911
923
  const applyHiding = () => {
912
924
  if (state.disposed) return;
913
925
  const rows = document.querySelectorAll('[data-chat-turn]');
914
926
  for (const row of rows) {
915
927
  const turn = Number(row.getAttribute('data-chat-turn'));
916
928
  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);
929
+ if (state.hiddenTurns.has(turn)) {
930
+ if (!row.hasAttribute(HIDDEN_ATTR)) row.setAttribute(HIDDEN_ATTR, 'true');
931
+ } else if (row.hasAttribute(HIDDEN_ATTR)) {
932
+ row.removeAttribute(HIDDEN_ATTR);
925
933
  }
926
934
  }
927
935
  };
928
936
 
937
+ const revealAll = () => {
938
+ state.hiddenTurns.clear();
939
+ for (const row of document.querySelectorAll(`[${HIDDEN_ATTR}]`)) row.removeAttribute(HIDDEN_ATTR);
940
+ };
941
+
929
942
  const closePopover = () => {
930
943
  if (state.popover !== null) {
931
944
  try {
@@ -937,12 +950,19 @@ loader.load({
937
950
  }
938
951
  };
939
952
 
940
- const runRewind = async (seq, restoreFiles, notice) => {
953
+ const runRewind = async (seq, restoreFiles, notice, fromTurn = null) => {
941
954
  notice(tr('notice.working'));
942
955
  try {
943
956
  const result = await postJson(INPLACE_ROUTE, { sessionId, seq, restoreFiles });
944
957
  if (state.disposed) return;
945
958
  if (Number.isInteger(result.hiddenFromTurn)) state.cutTurn = result.hiddenFromTurn;
959
+ if (Array.isArray(result.hiddenTurns) && result.hiddenTurns.length > 0) {
960
+ for (const turn of result.hiddenTurns) {
961
+ if (Number.isInteger(turn)) state.hiddenTurns.add(turn);
962
+ }
963
+ } else if (Number.isInteger(result.hiddenFromTurn)) {
964
+ state.hiddenTurns = new Set([result.hiddenFromTurn]);
965
+ }
946
966
  applyHiding();
947
967
  const filled = setComposer(typeof result.prompt === 'string' ? result.prompt : '');
948
968
  sendDiagnose(sessionId, {
@@ -953,7 +973,7 @@ loader.load({
953
973
  promptChars: typeof result.prompt === 'string' ? result.prompt.length : 0,
954
974
  restore: result.restore ?? null,
955
975
  injected: state.buttons.size,
956
- hiddenRows: state.hiddenRows.size,
976
+ hiddenRows: state.hiddenTurns.size,
957
977
  });
958
978
  const restore = result.restore;
959
979
  const parts = [];
@@ -963,11 +983,24 @@ loader.load({
963
983
  }
964
984
  notice(parts.length === 0 ? tr('notice.rewound') : `${tr('notice.rewound')} · ${parts.join(' · ')}`);
965
985
  } catch (error) {
986
+ const code = error !== null && typeof error === 'object' ? error.code ?? null : null;
966
987
  sendDiagnose(sessionId, {
967
988
  reason: 'rewind-failed',
968
989
  error: String(error && error.message ? error.message : error),
990
+ code,
969
991
  sequence: seq,
970
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
+ }
971
1004
  notice(error instanceof Error && error.message !== '' ? error.message : tr('notice.failed'));
972
1005
  }
973
1006
  };
@@ -1020,11 +1053,11 @@ loader.load({
1020
1053
  };
1021
1054
  popover.appendChild(makeButton(tr('popover.chat'), () => {
1022
1055
  closePopover();
1023
- void runRewind(seq, false, notice);
1056
+ void runRewind(seq, false, notice, turn);
1024
1057
  }));
1025
1058
  popover.appendChild(makeButton(tr('popover.both'), () => {
1026
1059
  closePopover();
1027
- void runRewind(seq, true, notice);
1060
+ void runRewind(seq, true, notice, turn);
1028
1061
  }));
1029
1062
  document.body.appendChild(popover);
1030
1063
  state.popover = popover;
@@ -1152,6 +1185,7 @@ loader.load({
1152
1185
  button.addEventListener('click', (event) => {
1153
1186
  event.preventDefault();
1154
1187
  event.stopPropagation();
1188
+ sendDiagnose(sessionId, { reason: 'click', turn, seq, buttons: state.buttons.size, mapped: state.turnToSeq.size });
1155
1189
  openPopover(button, seq, notice);
1156
1190
  });
1157
1191
  anchor.appendChild(button);
@@ -1219,7 +1253,11 @@ loader.load({
1219
1253
  try {
1220
1254
  const value = await postJson(CUTS_ROUTE, { sessionId });
1221
1255
  if (state.disposed) return;
1222
- state.cutTurn = Number.isInteger(value.hiddenFromTurn) ? value.hiddenFromTurn : null;
1256
+ if (Array.isArray(value.hiddenTurns)) {
1257
+ state.hiddenTurns = new Set(value.hiddenTurns.filter((turn) => Number.isInteger(turn)));
1258
+ } else if (Number.isInteger(value.hiddenFromTurn)) {
1259
+ state.hiddenTurns = new Set([value.hiddenFromTurn]);
1260
+ }
1223
1261
  applyHiding();
1224
1262
  } catch {
1225
1263
  /* ignore */
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.2.8",
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",
@@ -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,8 @@ 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';
25
+ export const SURFACE_PATH = '/api/session-rewind.surface';
24
26
  export const SELFCHECK_PATH = '/api/session-rewind.selfcheck';
25
27
 
26
28
  /** A rewind request is a session id plus a Turn number or a seq. */
@@ -198,6 +200,129 @@ function targetOf(value) {
198
200
  return { sessionId, turn, seq };
199
201
  }
200
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
+
255
+ /**
256
+ * Build the event-tail route.
257
+ *
258
+ * Returns the last N events of a Session in reading order with just enough shape
259
+ * to reason about a rewind: seq, type, source kind and a short text sample. It is
260
+ * the only way to see whether a marker actually landed, because the on-disk log is
261
+ * compressed and may lag behind the live surface.
262
+ *
263
+ * @param {{
264
+ * events: (sessionId: string) => Promise<readonly unknown[]>,
265
+ * trustedHosts?: () => readonly string[],
266
+ * onError?: (error: unknown) => void,
267
+ * }} options
268
+ * @returns {(request: import('node:http').IncomingMessage, response: import('node:http').ServerResponse) => Promise<void>}
269
+ */
270
+ export function createTailHandler({ events, trustedHosts = () => [], onError = () => {} }) {
271
+ return async (request, response) => {
272
+ try {
273
+ if (request.method !== 'POST') {
274
+ writeText(response, 405, 'method not allowed');
275
+ return;
276
+ }
277
+ if (!isTrustedRequest(request, hostsOf(trustedHosts))) {
278
+ writeText(response, 403, 'forbidden');
279
+ return;
280
+ }
281
+ const mediaType = header(request.headers, 'content-type')?.split(';', 1)[0]?.trim().toLowerCase();
282
+ if (mediaType !== 'application/json') {
283
+ writeText(response, 415, 'content type must be application/json');
284
+ return;
285
+ }
286
+ const value = await readJsonBody(request);
287
+ if (!isRecord(value)) throw new RequestBoundaryError(400, 'request body must be a JSON object');
288
+ const sessionId = typeof value.sessionId === 'string' ? value.sessionId : '';
289
+ if (sessionId === '') throw new RequestBoundaryError(400, 'sessionId is required');
290
+ const limit = Number.isInteger(value.limit) && value.limit > 0 && value.limit <= 300 ? value.limit : 40;
291
+ const all = await events(sessionId);
292
+ const slice = all.slice(-limit);
293
+ writeJson(response, 200, {
294
+ ok: true,
295
+ total: all.length,
296
+ events: slice.map((event) => summarizeEvent(event)),
297
+ });
298
+ } catch (error) {
299
+ fail(response, error, onError);
300
+ }
301
+ };
302
+ }
303
+
304
+ /**
305
+ * Reduce one Session event to the fields worth inspecting.
306
+ *
307
+ * @param {unknown} event
308
+ * @returns {{ seq: number | null, type: string, sourceKind: string | null, role: string | null, text: string | null }}
309
+ */
310
+ function summarizeEvent(event) {
311
+ const record = isRecord(event) ? event : {};
312
+ const data = isRecord(record.data) ? record.data : {};
313
+ const source = isRecord(data.source) ? data.source : {};
314
+ const content = Array.isArray(data.content) ? data.content : [];
315
+ const first = content.find((part) => isRecord(part) && part.type === 'text');
316
+ const text = isRecord(first) && typeof first.text === 'string' ? first.text.slice(0, 60) : null;
317
+ return {
318
+ seq: Number.isInteger(record.seq) ? record.seq : null,
319
+ type: typeof record.type === 'string' ? record.type : '(none)',
320
+ sourceKind: typeof source.kind === 'string' ? source.kind : null,
321
+ role: typeof data.role === 'string' ? data.role : null,
322
+ text,
323
+ };
324
+ }
325
+
201
326
  /**
202
327
  * Build the browser-diagnostics route.
203
328
  *
@@ -169,6 +169,45 @@ 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
+ * 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
+ *
180
+ * @param {readonly unknown[]} events
181
+ * @returns {readonly number[]}
182
+ */
183
+ export function withdrawnTurns(events) {
184
+ const spans = [];
185
+ for (const event of events) {
186
+ if (!isRecord(event) || event.type !== 'user/message') continue;
187
+ if (event.data?.source?.kind !== MARKER_SOURCE_KIND) continue;
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 });
192
+ }
193
+ if (spans.length === 0) return [];
194
+ const turns = new Set();
195
+ let current = null;
196
+ for (const event of events) {
197
+ if (!isRecord(event) || typeof event.seq !== 'number') continue;
198
+ const turn = event.data?.turn;
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
+ }
207
+ }
208
+ return [...turns].sort((left, right) => left - right);
209
+ }
210
+
172
211
  /**
173
212
  * Build the marker event payload. `id` is minted here because the Host's own
174
213
  * `createUserMessage` helper is not importable from a profile bundle.
package/src/plugin.js CHANGED
@@ -29,6 +29,8 @@ import {
29
29
  CUTS_PATH,
30
30
  DIAGNOSE_PATH,
31
31
  INPLACE_PATH,
32
+ SURFACE_PATH,
33
+ TAIL_PATH,
32
34
  PLAN_PATH,
33
35
  RESTORE_PATH,
34
36
  SELFCHECK_PATH,
@@ -36,12 +38,14 @@ import {
36
38
  createCutsHandler,
37
39
  createDiagnoseHandler,
38
40
  createInplaceHandler,
41
+ createSurfaceHandler,
42
+ createTailHandler,
39
43
  createPlanHandler,
40
44
  createRestoreHandler,
41
45
  createSelfcheckHandler,
42
46
  } from './lib/route.js';
43
47
  import { describeInPlace, executeInPlace, liveAgentOf } from './lib/inplace.js';
44
- import { hiddenFromSeq, hiddenFromTurn } from './lib/surface.js';
48
+ import { hiddenFromSeq, hiddenFromTurn, withdrawnTurns } from './lib/surface.js';
45
49
 
46
50
  export const name = 'session-rewind';
47
51
 
@@ -473,12 +477,17 @@ export function apply(ctx) {
473
477
  restoreTurns: restoreFiles === true
474
478
  ? async (fromTurn) => restoreTurns(sessionId, fromTurn)
475
479
  : undefined,
480
+ readEvents: (id) => readEvents(ctx, id),
476
481
  });
477
482
 
478
483
  const cutsOf = async (sessionId) => {
479
484
  // Read the durable log: the client needs the cut after a page reload too.
480
485
  const events = await readEvents(ctx, sessionId);
481
- return { hiddenFromSeq: hiddenFromSeq(events), hiddenFromTurn: hiddenFromTurn(events) };
486
+ return {
487
+ hiddenFromSeq: hiddenFromSeq(events),
488
+ hiddenFromTurn: hiddenFromTurn(events),
489
+ hiddenTurns: withdrawnTurns(events),
490
+ };
482
491
  };
483
492
 
484
493
  /**
@@ -588,7 +597,7 @@ export function apply(ctx) {
588
597
  plugin: 'dsh-session-rewind',
589
598
  archive: { directory: archive.directory, ...archiveStats },
590
599
  client: { discovered: clientPath !== null, path: clientPath },
591
- routes: [SELFCHECK_PATH, DIAGNOSE_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],
592
601
  };
593
602
  },
594
603
  trustedHosts,
@@ -622,6 +631,18 @@ export function apply(ctx) {
622
631
  onError,
623
632
  });
624
633
 
634
+ const surfaceHandler = createSurfaceHandler({
635
+ surfaceOf: (sessionId) => liveAgentOf(ctx, sessionId)?.session?.surface ?? null,
636
+ trustedHosts,
637
+ onError,
638
+ });
639
+
640
+ const tailHandler = createTailHandler({
641
+ events: async (sessionId) => readEvents(ctx, sessionId),
642
+ trustedHosts,
643
+ onError,
644
+ });
645
+
625
646
  const cutsHandler = createCutsHandler({
626
647
  cutsOf,
627
648
  trustedHosts,
@@ -637,6 +658,14 @@ export function apply(ctx) {
637
658
  () => scope.webServer.register({ kind: 'exact', path: SELFCHECK_PATH, handler: statusHandler }),
638
659
  'session-rewind: selfcheck route',
639
660
  );
661
+ scope.effect(
662
+ () => scope.webServer.register({ kind: 'exact', path: SURFACE_PATH, handler: surfaceHandler }),
663
+ 'session-rewind: surface route',
664
+ );
665
+ scope.effect(
666
+ () => scope.webServer.register({ kind: 'exact', path: TAIL_PATH, handler: tailHandler }),
667
+ 'session-rewind: tail route',
668
+ );
640
669
  scope.effect(
641
670
  () => scope.webServer.register({ kind: 'exact', path: CANDIDATES_PATH, handler: candidatesHandler }),
642
671
  'session-rewind: candidates route',
@@ -0,0 +1,42 @@
1
+ // Search decompressed session logs for a needle and print the sessions + events.
2
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { zstdDecompressSync } from 'node:zlib';
5
+
6
+ const NEEDLE = process.argv[2] ?? 'session-rewind';
7
+ const ROOT = 'C:/Users/23107/.dsh/sessions';
8
+
9
+ const hits = [];
10
+ for (const workspace of readdirSync(ROOT)) {
11
+ const workspaceDir = join(ROOT, workspace);
12
+ if (!statSync(workspaceDir).isDirectory()) continue;
13
+ for (const session of readdirSync(workspaceDir)) {
14
+ const sessionDir = join(workspaceDir, session);
15
+ if (!statSync(sessionDir).isDirectory()) continue;
16
+ const file = join(sessionDir, 'session.v4.jsonl.zstd');
17
+ let text;
18
+ try {
19
+ text = zstdDecompressSync(readFileSync(file)).toString('utf8');
20
+ } catch {
21
+ continue;
22
+ }
23
+ if (!text.includes(NEEDLE)) continue;
24
+ const lines = text.split('\n').filter((line) => line !== '');
25
+ const marked = lines.filter((line) => line.includes(NEEDLE));
26
+ hits.push({
27
+ session,
28
+ modified: statSync(file).mtime.toISOString(),
29
+ events: lines.length,
30
+ marked: marked.length,
31
+ samples: marked.slice(-2).map((line) => line.slice(0, 260)),
32
+ });
33
+ }
34
+ }
35
+
36
+ hits.sort((a, b) => (a.modified < b.modified ? 1 : -1));
37
+ console.log(`sessions containing ${JSON.stringify(NEEDLE)}: ${hits.length}`);
38
+ for (const hit of hits.slice(0, 6)) {
39
+ console.log(`\n=== ${hit.session}`);
40
+ console.log(`modified: ${hit.modified} events: ${hit.events} marked: ${hit.marked}`);
41
+ for (const sample of hit.samples) console.log(` ${sample}`);
42
+ }
package/test/selftest.mjs CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  listCandidates,
21
21
  markerPayload,
22
22
  planInPlace,
23
+ withdrawnTurns,
23
24
  } from '../src/lib/surface.js';
24
25
 
25
26
  const events = [
@@ -95,6 +96,45 @@ assert.equal(payload.content[0].text, MARKER_TEXT);
95
96
  assert.notEqual(payload.content[0].text, '');
96
97
  assert.deepEqual(payload.source, { kind: MARKER_SOURCE_KIND });
97
98
 
99
+ // --- withdrawn turns ------------------------------------------------------
100
+ // A rewind at seq 7 withdraws turns 1-2 up to the marker; turn 3 starts after it
101
+ // and must stay visible.
102
+ {
103
+ const rewound = [
104
+ { seq: 0, type: 'session', data: {} },
105
+ { seq: 1, type: 'turn/start', data: { turn: 1 } },
106
+ { seq: 2, type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: 'one' }] } },
107
+ { seq: 5, type: 'turn/end', data: { turn: 1 } },
108
+ { seq: 6, type: 'turn/start', data: { turn: 2 } },
109
+ { seq: 7, type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: 'two' }] } },
110
+ { seq: 9, type: 'turn/end', data: { turn: 2 } },
111
+ { seq: 10, type: 'user/message', data: { source: { kind: MARKER_SOURCE_KIND } }, surfaceOp: { op: 'replace', startSeq: 7, endSeq: 9 } },
112
+ { seq: 11, type: 'turn/start', data: { turn: 3 } },
113
+ { seq: 12, type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: 'three' }] } },
114
+ ];
115
+ assert.deepEqual(withdrawnTurns(rewound), [2]);
116
+ assert.deepEqual(withdrawnTurns(events), []);
117
+ }
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
+
98
138
  // --- archive -------------------------------------------------------------
99
139
  const root = await mkdtemp(join(tmpdir(), 'rewind-test-'));
100
140
  const archiveDir = join(root, 'archive');