dsh-rewind-plugin 0.2.4 → 0.2.6

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
@@ -4,7 +4,7 @@
4
4
 
5
5
  In-place conversation rewind for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness): the Claude Code `/rewind` semantics inside the **same session window** — cut the model context back to an earlier user message, and optionally restore workspace files from **disk-persisted before-backups**.
6
6
 
7
- > **Status:** published to npm (`dsh-rewind-plugin`, v0.2.4) via GitHub Actions Trusted Publishing + Sigstore provenance. Targets the web profile (`dsh --profile web`). Interaction mirrors Claude Code's rewind, adapted to dsh's real web UI.
7
+ > **Status:** published to npm (`dsh-rewind-plugin`, v0.2.6) via GitHub Actions Trusted Publishing + Sigstore provenance. Targets the web profile (`dsh --profile web`). Interaction mirrors Claude Code's rewind, adapted to dsh's real web UI.
8
8
 
9
9
  [![npm version](https://img.shields.io/npm/v/dsh-rewind-plugin.svg)](https://www.npmjs.com/package/dsh-rewind-plugin)
10
10
  [![npm license](https://img.shields.io/npm/l/dsh-rewind-plugin.svg)](https://github.com/SiriLee/dsh-rewind/blob/main/LICENSE)
@@ -61,6 +61,7 @@ The plugin appends an **empty-content marker** `assistant/message` into the sess
61
61
 
62
62
  - The marker carries `sourceEventSeqs` covering every shadowed node, and the `Session.append` surface rules validate the cut (only a contiguous range on the current surface).
63
63
  - Because the marker is **empty**, the harness derives it to `null` — it never enters the model context and never renders as conversation content. The agent and the user both see the conversation exactly as it was at the target.
64
+ - The marker's **turn number reuses the LAST STARTED turn** (`markerTurnOf`), never `lastTurn + 1`: the harness's agent loop numbers its next real turn exactly `last turn/start + 1`, so a marker numbered the same way would leave an `assistant/message` BEFORE the `turn/start` of the same turn — the client conversation-context builder rejects that ordering with `conversation Context …:turn-tail… received an update before its start Match`, history load fails, and the whole conversation disappears from the UI (the real defect in ≤ 0.2.4, fixed in 0.2.5). 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.
64
65
  - The append-only log is **untouched** — the audit trail keeps every withdrawn event; only the model-visible surface is cut, so the next request derives its context from the target onward.
65
66
 
66
67
  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.
@@ -76,6 +77,38 @@ The plugin tracks the write-class tools — `write`, `edit`, `str_replace_editor
76
77
 
77
78
  Backups persist across host restarts, bounded to the newest 100 anchor groups per session.
78
79
 
80
+ ## 🔧 Troubleshooting: history load failure (`…turn-tail… received an update before its start Match`)
81
+
82
+ Versions ≤ 0.2.4 corrupted client replay when a rewind was **followed by further
83
+ conversation**: the marker's turn number collided with the next real turn's
84
+ `turn/start`, so reopening the session showed
85
+ `Failed to load history: conversation Context …:turn-tail… received an update before its start Match (internal)`
86
+ and the history vanished. Rewinds created from 0.2.5 on no longer produce the
87
+ collision, but **already-corrupted sessions need an offline repair** (the log is
88
+ append-only — it cannot be rewritten in memory).
89
+
90
+ The repair tool ships **inside the npm package** (`dsh-rewind-repair`) — no
91
+ source checkout needed:
92
+
93
+ ```sh
94
+ # 1. Fully quit dsh web / host first (while a session is resident in memory,
95
+ # a disk repair is overwritten by the next checkpoint)
96
+ # 2. Run the offline repair (scans every session under ~/.dsh/sessions,
97
+ # rewriting each marker's turn back to the last started turn)
98
+ npm exec --yes --package=dsh-rewind-plugin -- dsh-rewind-repair
99
+ npm exec --yes --package=dsh-rewind-plugin -- dsh-rewind-repair -- --dry-run # preview only
100
+ # 3. Restart dsh web — the repaired sessions load their history again
101
+ ```
102
+
103
+ Or install it globally once (`npm i -g dsh-rewind-plugin`) and run
104
+ `dsh-rewind-repair` directly; from a source checkout the same tool is
105
+ `node scripts/repair-markers.mjs` (identical flags).
106
+
107
+ The tool only rewrites the `data.turn` of `dsh-rewind` empty-marker events
108
+ (keeping seqs, order, and the zstd frame structure intact), backs up the original
109
+ file to `session.jsonl.zstd.bak-<timestamp>` before writing, and never touches
110
+ any other event — safe to run repeatedly.
111
+
79
112
  ## 📦 Install
80
113
 
81
114
  Published to npm — the registry path is the recommended one. **Restart dsh web (`--profile web`) after installing.**
package/README.zh.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 插件:**同一会话窗口的 in-place 对话回退**(Claude Code `/rewind` 语义)——把模型上下文剪回更早的一条用户消息,并可基于**落盘的写前备份**还原工作区文件。
6
6
 
7
- > **状态**:已发布 npm(`dsh-rewind-plugin`,v0.2.4),经 GitHub Actions Trusted Publishing + Sigstore provenance 构建发布。目标为 web 配置档(`dsh --profile web`)。交互以 Claude Code 的 rewind 为参考,并贴合 dsh Web 实际 UI。
7
+ > **状态**:已发布 npm(`dsh-rewind-plugin`,v0.2.6),经 GitHub Actions Trusted Publishing + Sigstore provenance 构建发布。目标为 web 配置档(`dsh --profile web`)。交互以 Claude Code 的 rewind 为参考,并贴合 dsh Web 实际 UI。
8
8
 
9
9
  [![npm version](https://img.shields.io/npm/v/dsh-rewind-plugin.svg)](https://www.npmjs.com/package/dsh-rewind-plugin)
10
10
  [![npm license](https://img.shields.io/npm/l/dsh-rewind-plugin.svg)](https://github.com/SiriLee/dsh-rewind/blob/main/LICENSE)
@@ -61,6 +61,7 @@
61
61
 
62
62
  - 标记携带 `sourceEventSeqs` 覆盖所有被遮蔽节点,`Session.append` 的 surface 规则校验切割合法性(仅限当前 surface 上的连续区间)。
63
63
  - 因为标记**内容为空**,harness 会将其派生为 `null`——永不进入模型上下文、也永不渲染成对话内容。agent 与用户看到的对话都回到目标消息当时的样子。
64
+ - 标记的 **turn 号复用最后一个已开始的回合**(`markerTurnOf`),而不是「最后回合 + 1」:harness 的 agent loop 恰好用 `最后 turn/start + 1` 编号下一条真实回合。若标记也取这个数,日志里就会出现同一 turn 的 `assistant/message` 先于 `turn/start` 的乱序,客户端 conversation 构建器会以 `conversation Context …:turn-tail… received an update before its start Match` 拒绝重放——历史加载失败、整个对话从界面消失(0.2.4 及之前的真实缺陷,已在 0.2.5 修复)。复用已消费的 turn 号则标记只是上一个已完成回合尾部的一次无害追加,永不与新回合冲突。
64
65
  - append-only 日志**不被改写**——审计轨迹完整保留每条被撤回的事件,只有模型可见的 surface 被剪掉,下一条请求从目标消息起派生上下文。
65
66
 
66
67
  若 agent 正在运行(LLM 思考/流式输出),会先强制停止(`cancel({ kind: 'user' })`)并等待 quiescence 再回退;停不下来则中止并报错。
@@ -76,6 +77,30 @@
76
77
 
77
78
  备份跨 host 重启持久化,每会话有界保留最近 100 组锚点。
78
79
 
80
+ ## 🔧 故障修复:历史加载失败(`…turn-tail… received an update before its start Match`)
81
+
82
+ 0.2.4 及之前版本在回退**之后继续对话**的场景下会损坏会话的客户端重放:标记的 turn 号
83
+ 与下一条真实回合的 `turn/start` 编号冲突,重新打开会话时界面报
84
+ `历史加载失败:conversation Context …:turn-tail… received an update before its start Match(internal)`,
85
+ 历史整段消失。0.2.5 起新的回退不再产生该冲突;但**已损坏的会话需要离线修复**(日志是
86
+ append-only 的,不能在内存中改写)。
87
+
88
+ 修复工具**已随 npm 包发布**(`dsh-rewind-repair`)——无需下载源码:
89
+
90
+ ```sh
91
+ # 1. 先完全退出 dsh web / host(会话处于驻留内存时,磁盘修复会被下次 checkpoint 覆盖)
92
+ # 2. 运行离线修复(扫描 ~/.dsh/sessions 下所有会话,把标记 turn 改回最后一个已开始的回合)
93
+ npm exec --yes --package=dsh-rewind-plugin -- dsh-rewind-repair
94
+ npm exec --yes --package=dsh-rewind-plugin -- dsh-rewind-repair -- --dry-run # 只预览不写盘
95
+ # 3. 重启 dsh web,损坏的会话即可正常加载历史
96
+ ```
97
+
98
+ 也可以全局安装一次(`npm i -g dsh-rewind-plugin`)后直接运行 `dsh-rewind-repair`;
99
+ 源码方式为 `node scripts/repair-markers.mjs`(参数相同)。
100
+
101
+ 工具只改写 `dsh-rewind` 空标记事件的 `data.turn` 字段(保持 seq / 顺序 / zstd 帧结构不变),
102
+ 改前自动备份原文件为 `session.jsonl.zstd.bak-<时间戳>`;不改动任何其它事件,可安全重复运行。
103
+
79
104
  ## 📦 安装
80
105
 
81
106
  已发布 npm——推荐走 registry 路径。**装完重启 dsh web(`--profile web`)。**
package/lib/client.js CHANGED
@@ -586,7 +586,20 @@ function apply(ctx) {
586
586
  hidden.delete(seat);
587
587
  }
588
588
  }
589
- if (session !== void 0) fillComposerForRewind(session, filledTargets);
589
+ if (session !== void 0) {
590
+ const snap = session.getSnapshot();
591
+ if (!fillBaselineTaken && snap.chat.order.length > 0) {
592
+ fillBaselineTaken = true;
593
+ for (const key of snap.chat.order) {
594
+ const node = snap.chat.nodes.get(key);
595
+ if (node?.kind === "command") {
596
+ const command = node.data;
597
+ if (command.seq > fillBaselineSeq) fillBaselineSeq = command.seq;
598
+ }
599
+ }
600
+ }
601
+ fillComposerForRewind(session, filledTargets);
602
+ }
590
603
  if (hiddenSeqs.size > 0 || hiddenCount > 0) {
591
604
  console.info(
592
605
  `[dsh-rewind] hiding: ${hiddenCount} rows, seqs [${[...hiddenSeqs].slice(0, 20).join(", ")}${hiddenSeqs.size > 20 ? "\u2026" : ""}]`
@@ -597,6 +610,8 @@ function apply(ctx) {
597
610
  }
598
611
  };
599
612
  const filledTargets = /* @__PURE__ */ new Set();
613
+ let fillBaselineSeq = -1;
614
+ let fillBaselineTaken = false;
600
615
  const fillComposerForRewind = (session, filled) => {
601
616
  const snap = session.getSnapshot();
602
617
  for (const key of snap.chat.order) {
@@ -604,6 +619,7 @@ function apply(ctx) {
604
619
  if (node === void 0 || node.kind !== "command") continue;
605
620
  const command = node.data;
606
621
  if (command.name !== "rewind" || command.outcome?.kind !== "success") continue;
622
+ if (command.seq <= fillBaselineSeq) continue;
607
623
  if (command.outcome.sourceEventSeq === void 0) continue;
608
624
  const target = targetOfOutcome(command.outcome.text);
609
625
  if (target === void 0 || filled.has(target)) continue;
package/lib/index.js CHANGED
@@ -12,6 +12,15 @@ var RewindError = class extends Error {
12
12
  code;
13
13
  };
14
14
  var CANDIDATE_PREVIEW_CHARS = 80;
15
+ function markerTurnOf(events) {
16
+ let lastStarted = 0;
17
+ for (const event of events) {
18
+ if (event.type === "turn/start" && event.data.turn > lastStarted) {
19
+ lastStarted = event.data.turn;
20
+ }
21
+ }
22
+ return lastStarted;
23
+ }
15
24
  function isUserMessageEvent(event) {
16
25
  return event.type === "user/message";
17
26
  }
@@ -364,15 +373,6 @@ function buildMarker() {
364
373
  source: { provider: "dsh-rewind", model: "rewind-marker" }
365
374
  });
366
375
  }
367
- function nextTurnOf(session) {
368
- let max = -1;
369
- for (const event of session.events) {
370
- if (event.type === "turn/start" || event.type === "turn/end" || event.type === "assistant/message") {
371
- if (event.data.turn > max) max = event.data.turn;
372
- }
373
- }
374
- return max + 1;
375
- }
376
376
  function describeTarget(target) {
377
377
  return target.kind === "seq" ? `seq ${target.seq}` : `\u7B2C ${target.index} \u6761\u6D88\u606F`;
378
378
  }
@@ -427,7 +427,7 @@ async function executeRewind(ctx, store, invocation, rawTarget, mode) {
427
427
  const marker = buildMarker();
428
428
  let event;
429
429
  try {
430
- event = agent.session.append("assistant/message", { turn: nextTurnOf(agent.session), step: 0, message: marker }, {
430
+ event = agent.session.append("assistant/message", { turn: markerTurnOf(agent.session.events), step: 0, message: marker }, {
431
431
  surfaceOp: { op: "replace", start: plan.surfaceStart, end: plan.surfaceEnd },
432
432
  sourceEventSeqs: [...plan.shadowedSeqs]
433
433
  });
@@ -60,6 +60,29 @@ export interface RewindPlan {
60
60
  }
61
61
  /** Preview length cap for candidate listings. */
62
62
  export declare const CANDIDATE_PREVIEW_CHARS = 80;
63
+ /**
64
+ * Turn number for the rewind marker.
65
+ *
66
+ * The marker MUST NOT reuse the harness's next-turn number. The agent loop
67
+ * numbers its next real turn `lastTurn/start + 1` (dsh-agent-loop), so a
68
+ * marker numbered `maxTurn + 1` collides: the log then holds an
69
+ * `assistant/message` (the marker) BEFORE the `turn/start` of the same turn,
70
+ * and the client conversation-context builder rejects that ordering with
71
+ * `conversation Context …:turn-tail… received an update before its start
72
+ * Match` — history load fails and the whole conversation disappears from the
73
+ * UI (reproduced across real sessions).
74
+ *
75
+ * The marker therefore reuses the LAST STARTED turn's number: the harness has
76
+ * already consumed it (its next turn is strictly larger), so it can never be
77
+ * reused by a future `turn/start`, and the marker lands as a harmless
78
+ * trailing update on that turn's already-closed tail context (its `turn/end`
79
+ * is already matched) — no new context, no reordering, nothing rendered, and
80
+ * the empty content still derives to `null` in the model context.
81
+ *
82
+ * @param events - the full session event log.
83
+ * @returns a turn number the harness can never reuse for a future `turn/start`.
84
+ */
85
+ export declare function markerTurnOf(events: readonly SessionEvent[]): number;
63
86
  /** Narrow an event to a user message. */
64
87
  export declare function isUserMessageEvent(event: SessionEvent): event is SessionEvent<'user/message'>;
65
88
  /** Join the text blocks of a message into one plain string. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-rewind-plugin",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
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,6 +23,9 @@
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
+ },
26
29
  "exports": {
27
30
  ".": {
28
31
  "types": "./lib/types/index.d.ts",
@@ -41,7 +44,8 @@
41
44
  "README.zh.md",
42
45
  "docs",
43
46
  "assets",
44
- "LICENSE"
47
+ "LICENSE",
48
+ "scripts/repair-markers.mjs"
45
49
  ],
46
50
  "dsh": {
47
51
  "bundle": {
@@ -0,0 +1,176 @@
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.'}`)