dsh-rewind-plugin 0.4.2 → 0.5.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.
@@ -37,6 +37,6 @@ export declare const inject: string[];
37
37
  export declare function apply(ctx: ClientContext): void;
38
38
  /**
39
39
  * Public contract — rewind visibility. Stable, semver-protected; the rest of
40
- * this module is internal. See `docs/client-contract.md`.
40
+ * this module is internal. See `docs/contract/client-contract.md`.
41
41
  */
42
42
  export { hiddenSeqsOf, targetSeqOfArgs, type HiddenChat } from './hidden.ts';
@@ -26,13 +26,15 @@
26
26
  */
27
27
  import type { Context } from '@deepseek-ai/cordis';
28
28
  export { SnapshotStore } from './snapshot.ts';
29
- export type { CheckpointEntry, FileImpact, RestoreOutcome, RestoreJournal, RestoreJournalState, RestoreReconcileReport } from './snapshot.ts';
29
+ export type { CheckpointEntry, FileImpact, PruneStaleReport, RestoreOutcome, RestoreJournal, RestoreJournalState, RestoreReconcileReport } from './snapshot.ts';
30
30
  export declare const name = "dsh-rewind";
31
31
  export declare const inject: string[];
32
32
  /** Plugin config: optional override of the checkpoint store root. */
33
33
  export interface RewindConfig {
34
34
  /** Checkpoint store root (defaults to `~/.dsh/rewind-snapshots`). */
35
35
  readonly snapshotDir?: string;
36
+ /** In-place content dedup (identical before-content → link). Default `true`. */
37
+ readonly dedup?: boolean;
36
38
  }
37
39
  /**
38
40
  * Register the `/rewind` command and the checkpoint pipeline (before-capture
@@ -49,6 +49,23 @@ export declare const en: {
49
49
  noUserMessages: string;
50
50
  chooseMode: string;
51
51
  'command.description': string;
52
+ 'cleanup.description': string;
53
+ 'cleanup.inputHint': string;
54
+ 'cleanup.status': string;
55
+ 'cleanup.enabled': string;
56
+ 'cleanup.disabled': string;
57
+ 'cleanup.present': string;
58
+ 'cleanup.absent': string;
59
+ 'cleanup.onOk': string;
60
+ 'cleanup.offOk': string;
61
+ 'cleanup.maxAgeOk': string;
62
+ 'cleanup.cfgInvalid': string;
63
+ 'cleanup.saveFailed': string;
64
+ 'cleanup.runDry': string;
65
+ 'cleanup.runApply': string;
66
+ 'cleanup.runFailed': string;
67
+ 'cleanup.skipped': string;
68
+ 'cleanup.usage': string;
52
69
  };
53
70
  /** The host rewind dictionary key union. */
54
71
  export type HostKey = keyof typeof en;
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Snapshot cleanup policy: the persisted config file, its validation, the
3
+ * `/snapshot-auto-cleanup` command's argument grammar, and the auto-sweep
4
+ * throttle. Kept free of host wiring so the policy and the parser are
5
+ * unit-testable in isolation; `src/index.ts` is the only consumer.
6
+ *
7
+ * Semantics (the "cleanup" vocabulary deliberately avoids "retention"):
8
+ * - `enabled` toggles the AUTOMATIC (24h) sweep. `false` (the default) keeps
9
+ * every snapshot — the pre-feature behavior — and never writes a file.
10
+ * - `maxAgeDays` is the only "keep" knob: a finished session dir whose newest
11
+ * member stamp is older than this many days of idle is removed by a sweep.
12
+ * `0`/negative/non-integer are rejected, so a broken file can never steer
13
+ * the sweep into deleting everything.
14
+ * - The config file is created ONLY by an explicit `/snapshot-auto-cleanup`
15
+ * write. An absent file reads as the safe default (off); an unreadable or
16
+ * invalid file reports `ok:false` so a sweep fail-closes (deletes nothing)
17
+ * instead of guessing.
18
+ *
19
+ * @module dsh-rewind/snapshot-cleanup
20
+ */
21
+ /** The cleanup policy, as persisted under `~/.dsh/snapshot-cleanup.json`. */
22
+ export interface CleanupConfig {
23
+ readonly enabled: boolean;
24
+ readonly maxAgeDays: number;
25
+ }
26
+ export declare const CLEANUP_CONFIG_FILENAME = "snapshot-cleanup.json";
27
+ /** Environment variable overriding the config file path. */
28
+ export declare const CLEANUP_CONFIG_ENV = "DSH_SNAPSHOT_CLEANUP_CONFIG";
29
+ /** The default keep threshold: finished sessions idle > 30 days are pruned. */
30
+ export declare const DEFAULT_MAX_AGE_DAYS = 30;
31
+ /** The safe default policy (off) — a missing/corrupt file behaves like this. */
32
+ export declare const DEFAULT_CLEANUP_CONFIG: CleanupConfig;
33
+ /** Auto-sweep cadence (the user's hardcoded 24h rhythm — not user-set). */
34
+ export declare const AUTO_SWEEP_INTERVAL_MS: number;
35
+ /** Resolve the config file path: env override, else `~/.dsh/snapshot-cleanup.json`. */
36
+ export declare function resolveCleanupConfigPath(): string;
37
+ /** The state file that records the last automatic-sweep wall-clock time. */
38
+ export declare const STATE_FILENAME = "snapshot-cleanup-last-sweep.json";
39
+ /**
40
+ * Resolve the last-sweep state path. It sits beside the config file so the
41
+ * 24h cadence SURVIVES a host restart (a real deployment is rarely up 24/7,
42
+ * so an in-memory timestamp would reset on every boot and re-sweep too often).
43
+ */
44
+ export declare function resolveCleanupStatePath(): string;
45
+ /**
46
+ * Read the persisted last-sweep time (epoch ms). A missing or corrupt file
47
+ * reads as `0` ("never swept"), so the next activity runs the sweep — which is
48
+ * safe because the sweep is idempotent and never deletes the active session.
49
+ */
50
+ export declare function loadLastSweepAt(path: string): Promise<number>;
51
+ /** Persist the last-sweep time, atomically (temp + rename). */
52
+ export declare function saveLastSweepAt(path: string, ms: number): Promise<void>;
53
+ /** The slice of a store `runAutoCleanupCheck` needs (pruneStale). */
54
+ export interface AutoCleanupPruner {
55
+ pruneStale(opts: {
56
+ keepActiveId?: string;
57
+ maxAgeDays: number;
58
+ dryRun?: boolean;
59
+ }): Promise<unknown>;
60
+ }
61
+ /**
62
+ * The one-shot auto-cleanup check. Loads the policy + persisted last-sweep time
63
+ * and, only when enabled AND >=24h since the last sweep, runs the sweep and
64
+ * re-anchors the window on disk. Dependencies (store, paths, logger) are
65
+ * injected so the composition is unit-testable without a host. Never rejects:
66
+ * a corrupt config fail-closes (no deletion) and logs, a prune failure logs.
67
+ *
68
+ * `sessionId` is the active session directory that must never be pruned.
69
+ */
70
+ export declare function runAutoCleanupCheck(deps: {
71
+ pruner: AutoCleanupPruner;
72
+ configPath: string;
73
+ statePath: string;
74
+ log: (msg: string) => void;
75
+ }, sessionId: string | undefined): Promise<void>;
76
+ /**
77
+ * Validate one parsed JSON value into a {@link CleanupConfig}. Tolerates
78
+ * unknown extra keys; rejects a present-but-wrong-typed known key. Missing
79
+ * known keys fall back to the safe default.
80
+ */
81
+ export declare function parseCleanupConfig(raw: unknown): {
82
+ ok: true;
83
+ config: CleanupConfig;
84
+ } | {
85
+ ok: false;
86
+ error: string;
87
+ };
88
+ /**
89
+ * Load and validate the config file. A missing file is NOT an error: it reads
90
+ * as the safe default (off, `fromFile:false`). An unreadable, non-JSON, or
91
+ * structurally-invalid file is `ok:false` so a sweep fail-closes.
92
+ */
93
+ export declare function loadCleanupConfig(path: string): Promise<{
94
+ ok: true;
95
+ config: CleanupConfig;
96
+ fromFile: boolean;
97
+ } | {
98
+ ok: false;
99
+ error: string;
100
+ }>;
101
+ /**
102
+ * Persist a validated {@link CleanupConfig}, atomically (temp + rename). Any
103
+ * invalid value throws before the file is touched, so the command can never
104
+ * write a broken policy.
105
+ */
106
+ export declare function saveCleanupConfig(path: string, config: CleanupConfig): Promise<void>;
107
+ /** The `/snapshot-auto-cleanup` sub-command the parser can resolve to. */
108
+ export type CleanupCommandAction = 'status' | 'on' | 'off' | 'max-age' | 'run' | 'run-apply';
109
+ /**
110
+ * Parse the free-form text after `/snapshot-auto-cleanup`. Pure so it is
111
+ * unit-testable; `src/index.ts` maps the resolved action onto the store / the
112
+ * config file. `max-age` returns the validated positive day count.
113
+ */
114
+ export declare function parseCleanupCommand(rawInput: string): {
115
+ action: CleanupCommandAction;
116
+ value?: number;
117
+ } | {
118
+ error: string;
119
+ };
120
+ /**
121
+ * The 24h auto-sweep throttle. `lastAtMs` of `0` means "never ran" (a fresh
122
+ * process), so the first call always sweeps; after that a call within 24h is
123
+ * a no-op, matching the "every machine at most once per day" model.
124
+ */
125
+ export declare function shouldRunAutoSweep(lastAtMs: number, nowMs: number): boolean;
@@ -66,6 +66,30 @@ export interface CheckpointEntry {
66
66
  /** Epoch ms the entry was committed (stable ordering within a group). */
67
67
  readonly time: number;
68
68
  }
69
+ /**
70
+ * One in-place dedup link, keyed by tool call. When a tracked file is
71
+ * recorded with a `before` content identical to the immediately-prior entry
72
+ * for that path, the entry is stored as a LINK instead of a full copy: it
73
+ * carries no `before`, only a `ref` naming the prior entry file
74
+ * (`<anchorSeq>/<callId>.json`). The linear (predecessor-chained) ref makes
75
+ * restore resolution and prune materialization rewrite-free.
76
+ *
77
+ * The real-entry format ({@link CheckpointEntry}) is unchanged so existing
78
+ * data reads identically; links are a NEW entry kind only the current build
79
+ * understands (old-build reads of links are explicitly out of scope).
80
+ */
81
+ export interface LinkEntry {
82
+ readonly callId: string;
83
+ readonly anchorSeq: number;
84
+ readonly path: string;
85
+ /** `<anchorSeq>/<callId>.json` of the immediately-prior entry for the path. */
86
+ readonly ref: string;
87
+ readonly time: number;
88
+ }
89
+ /** Any on-disk entry: a full before-backup or an in-place dedup link. */
90
+ export type StoredEntry = CheckpointEntry | LinkEntry;
91
+ /** True when an entry is a dedup link (carries `ref`, not `before`). */
92
+ export declare function isLinkEntry(entry: StoredEntry): entry is LinkEntry;
69
93
  /** Per-file restore impact preview (`/rewind preview @seq both`). */
70
94
  export interface FileImpact {
71
95
  readonly path: string;
@@ -199,6 +223,32 @@ export type PlannedAction = {
199
223
  };
200
224
  /** Production probe: real reads via node:fs, links detected by lstat + nlink. */
201
225
  export declare const defaultProbe: DiskProbe;
226
+ /**
227
+ * Result of a stale-session cleanup sweep ({@link SnapshotStore.pruneStale}).
228
+ *
229
+ * The sweep is ANTI-DELETE: it only ever removes WHOLE session directories
230
+ * that have been idle past `maxAgeDays`. `scanned` counts every session dir
231
+ * evaluated; `kept` + `skippedActive` + `deleted` sum to it. `remainingBytes`
232
+ * is the total of directories that SURVIVE the policy (when `dryRun` it is the
233
+ * would-be total, not the current on-disk total), so it is comparable across
234
+ * dry and real runs.
235
+ */
236
+ export interface PruneStaleReport {
237
+ /** Number of session directories evaluated. */
238
+ readonly scanned: number;
239
+ /** Session directories removed (would-be count when `dryRun`). */
240
+ readonly deleted: number;
241
+ /** Bytes reclaimed (would-be bytes when `dryRun`). */
242
+ readonly freedBytes: number;
243
+ /** Session directories retained (not past the cutoff, not the active one). */
244
+ readonly kept: number;
245
+ /** Bytes across the retained + skipped-active directories. */
246
+ readonly remainingBytes: number;
247
+ /** Directories skipped because they are the active session. */
248
+ readonly skippedActive: number;
249
+ /** Whether nothing was really removed (the sweep only reported). */
250
+ readonly dryRun: boolean;
251
+ }
202
252
  /**
203
253
  * On-disk checkpoint store. Every write goes straight through `node:fs`, so a
204
254
  * restore reliably lands on the real file system.
@@ -221,15 +271,56 @@ export declare class SnapshotStore {
221
271
  * it, and even then the in-process order still holds.
222
272
  */
223
273
  private lastEntryTime;
224
- constructor(root?: string);
274
+ /** Store options; `dedup` toggles in-place content dedup (default on). */
275
+ private readonly dedup;
276
+ /**
277
+ * In-memory per-path "most recent entry" for content dedup, keyed by
278
+ * `<sessionId>\0<path>`. Each value holds the entry's effective `before`
279
+ * content and its own file ref, so a new record with the same content links
280
+ * to the immediately-prior entry (linear chain). Seeded lazily per session
281
+ * from the bounded on-disk window, so dedup survives a host restart.
282
+ */
283
+ private readonly lastEntry;
284
+ /** Sessions whose dedup state has been seeded from disk this process. */
285
+ private readonly seededSessions;
286
+ constructor(root?: string, opts?: {
287
+ readonly dedup?: boolean;
288
+ });
225
289
  /** Absolute path of one session's snapshot directory (id sanitized). */
226
290
  sessionDir(sessionId: string): string;
227
291
  /** Absolute path of one anchor group directory. */
228
292
  anchorDir(sessionId: string, anchorSeq: number): string;
229
- /** Commit one before-backup under its turn's anchor group (atomic write). */
293
+ /** Absolute file ref (relative to the session dir) of an entry. */
294
+ private entryRefOf;
295
+ /**
296
+ * Seed a session's dedup state from the existing (bounded) on-disk window:
297
+ * scan entries newest-first and record the most recent entry per path. This
298
+ * makes content dedup survive a host restart within the session window. A
299
+ * no-op after the first seed (or when `dedup` is disabled).
300
+ */
301
+ private ensureDedupSeeded;
302
+ /**
303
+ * Resolve an entry's effective `before` content, following a link chain to
304
+ * its terminal real snapshot. Refs are strictly backward in
305
+ * `(anchorSeq, time)`, so the chain is acyclic and finite. A dangling or
306
+ * cyclic link throws — callers fail per-file (never silently dropping the
307
+ * path from a restore).
308
+ */
309
+ private resolveBefore;
310
+ /** Commit one before-backup (or an in-place dedup link) under its anchor. */
230
311
  recordEntry(sessionId: string, entry: Omit<CheckpointEntry, 'time'>, opts?: {
312
+ readonly dedup?: boolean;
231
313
  readonly crash?: (point: CrashPoint) => void;
232
314
  }): Promise<void>;
315
+ /**
316
+ * The effective content recorded by the path's MOST RECENT entry, or
317
+ * undefined when the path has never been recorded (a fresh tracking sight).
318
+ * This is the single in-memory "last known state" the boundary uses to
319
+ * decide whether a tracked file changed — the same source `recordEntry`
320
+ * dedups against, so there is one content copy and one comparison per
321
+ * decision, not two. Seeding is idempotent (once per session from disk).
322
+ */
323
+ lastKnownContent(sessionId: string, path: string): Promise<string | null | undefined>;
233
324
  /**
234
325
  * All committed entries anchored at or after `targetSeq`, newest first (for
235
326
  * preview ordering). The boundary is inclusive: rewinding to a message also
@@ -237,7 +328,7 @@ export declare class SnapshotStore {
237
328
  * turn's assistant response and tool calls), so only entries anchored at
238
329
  * earlier messages survive.
239
330
  */
240
- entriesAfter(sessionId: string, targetSeq: number): Promise<CheckpointEntry[]>;
331
+ entriesAfter(sessionId: string, targetSeq: number): Promise<StoredEntry[]>;
241
332
  /**
242
333
  * Per-path EARLIEST committed entry anchored at or after the target — the
243
334
  * single source of truth for both restore and impact preview.
@@ -269,7 +360,7 @@ export declare class SnapshotStore {
269
360
  * @param sessionId - session whose snapshot store to plan against.
270
361
  * @param targetSeq - rewind target; entries anchored at/after it apply.
271
362
  * @param probe - current-disk state probe (defaults to the real FS).
272
- * @returns the planned actions plus the link paths that were skipped.
363
+ * @returns the planned actions, the link paths skipped, and per-file failures.
273
364
  */
274
365
  private planRestore;
275
366
  /** Per-file restore impact: only actions that would actually change the disk. */
@@ -384,8 +475,23 @@ export declare class SnapshotStore {
384
475
  * recycles terminal restore journals (see {@link pruneTerminalJournals}),
385
476
  * so the per-commit cap bounds BOTH the checkpoint entries and the journal
386
477
  * accumulation.
478
+ *
479
+ * Because dedup links reference prior entries, eviction is LINK-AWARE: before
480
+ * deleting the oldest groups, any SURVIVING (kept-group) link whose `ref`
481
+ * lands on a real snapshot inside a doomed group is MATERIALIZED (rewritten
482
+ * as a real snapshot carrying the resolved content), so no kept link is left
483
+ * dangling. Links form a linear predecessor chain, so materializing the first
484
+ * link after each doomed real is enough — later links already point at that
485
+ * materialized entry (or at other kept links), requiring no rewrite.
486
+ *
487
+ * `opts.crash` is the test-only seam: a crash fired inside a materialization
488
+ * write (between its temp write and rename) leaves ONLY a `.tmp` — the doomed
489
+ * real is still on disk and the kept link still resolves, so nothing dangles
490
+ * and a later prune simply re-materializes.
387
491
  */
388
- prune(sessionId: string, keep?: number): Promise<void>;
492
+ prune(sessionId: string, keep?: number, opts?: {
493
+ readonly crash?: (point: CrashPoint) => void;
494
+ }): Promise<void>;
389
495
  /**
390
496
  * Recycle terminal restore journals (`completed` / `rolled-back`): once an
391
497
  * op finished, its journal's before + rescue content is dead weight that
@@ -397,6 +503,33 @@ export declare class SnapshotStore {
397
503
  private pruneTerminalJournals;
398
504
  /** True when a path exists on disk (used by tests and diagnostics). */
399
505
  exists(path: string): Promise<boolean>;
506
+ /**
507
+ * Cross-session retention sweep: remove WHOLE session directories whose
508
+ * newest member stamp is older than `maxAgeDays` days of idle, keeping the
509
+ * active session (`keepActiveId`) untouched. This is the anti-growth policy
510
+ * for finished sessions (rewind only ever reads the active session, so a
511
+ * finished session's backups are provably dead weight).
512
+ *
513
+ * SAFETY:
514
+ * - Only whole session directories are removed (dedup refs are
515
+ * session-relative, so there is no cross-session dangling to materialize);
516
+ * - the active session is never targeted (`keepActiveId`), and everything
517
+ * else is protected by its own mtime — a session that is still written to
518
+ * keeps scrolling its newest member stamp forward, so it is never old
519
+ * enough to be pruned;
520
+ * - a non-positive `maxAgeDays` throws instead of degenerating into a
521
+ * mass-destructive `cutoff` in the far future;
522
+ * - the walk uses `lstat` (no symlink following) and skips dot-prefixed
523
+ * temp left overs, so measurement stays inside the store root.
524
+ *
525
+ * `dryRun` computes and reports exactly what would be removed without
526
+ * deleting anything — the `/snapshot-auto-cleanup run` preview.
527
+ */
528
+ pruneStale(opts: {
529
+ readonly keepActiveId?: string;
530
+ readonly maxAgeDays: number;
531
+ readonly dryRun?: boolean;
532
+ }): Promise<PruneStaleReport>;
400
533
  /**
401
534
  * All distinct paths ever recorded for a session — the "tracked files"
402
535
  * set. Mirrors Claude Code's global `trackedFiles` collection (files stay
@@ -418,10 +551,13 @@ export declare class SnapshotStore {
418
551
  * Semantics: the recorded `before` is the file's state at the boundary —
419
552
  * the state the boundary message's turn starts from, exactly like the
420
553
  * tool-captured entries. An entry is written only when the state differs
421
- * from the last-seen state (`states`); the FIRST sighting of a path always
422
- * records (a restart leaves `states` empty, so the first boundary after a
423
- * restart unconditionally records the current state redundant but correct,
424
- * mirroring Claude's resume-then-re-stat behavior).
554
+ * from the path's most-recent recorded content (`lastKnownContent`); a fresh
555
+ * sighting (never recorded) always records. The state is compared against the
556
+ * SAME single in-memory source `recordEntry` dedups against, so there is one
557
+ * content copy and one comparison — not the two (a boundary map plus the
558
+ * dedup map) the previous model held. Only CHANGED files are recorded, and
559
+ * each is a full snapshot (`dedup: false`): a changed state always differs
560
+ * from the recent record, so the link decision would never apply there.
425
561
  *
426
562
  * Symlinked / hard-linked paths are never re-checked (restores skip them).
427
563
  * A probe failure skips the file with a warning-level no-op; it never
@@ -431,8 +567,7 @@ export declare class SnapshotStore {
431
567
  * @param sessionId - session whose tracked files to re-check.
432
568
  * @param anchorSeq - the boundary user-message seq (entry anchor).
433
569
  * @param tracked - the session's tracked path set (read-only here).
434
- * @param states - per-path last-seen state (path → content, null = absent).
435
570
  * @param probe - current-disk state probe (defaults to the real FS).
436
571
  * @returns the number of entries recorded.
437
572
  */
438
- export declare function reconcileTracked(store: SnapshotStore, sessionId: string, anchorSeq: number, tracked: ReadonlySet<string>, states: Map<string, string | null>, probe?: DiskProbe): Promise<number>;
573
+ export declare function reconcileTracked(store: SnapshotStore, sessionId: string, anchorSeq: number, tracked: ReadonlySet<string>, probe?: DiskProbe): Promise<number>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-rewind-plugin",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "DSH 插件:真正便捷无感的同窗口内对话回退,从不新建分支;自带轻量工作区备份,可一并还原文件(完整 Claude Code /rewind 语义)。 · DSH plugin: genuinely effortless in-window conversation rewind — never forking a new session; ships a lightweight workspace backup that restores files together with the rewind (full Claude Code /rewind semantics).",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -39,6 +39,8 @@
39
39
  "cordis.patch.yml",
40
40
  "README.md",
41
41
  "README.en.md",
42
+ "SECURITY.md",
43
+ "CONTRIBUTING.md",
42
44
  "docs",
43
45
  "assets",
44
46
  "LICENSE"
@@ -62,6 +64,7 @@
62
64
  "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.client.json && tsc --noEmit -p tsconfig.client-test.json",
63
65
  "test": "vitest run",
64
66
  "verify:host": "node scripts/verify-host.mjs",
67
+ "check": "npm run typecheck && npm test && npm run build && node scripts/verify-host.mjs && npm pack --dry-run",
65
68
  "prepare": "npm run build"
66
69
  },
67
70
  "publishConfig": {
@@ -1,44 +0,0 @@
1
- # Troubleshooting
2
-
3
- [简体中文](troubleshooting.zh.md)
4
-
5
- ## History load failure: `…turn-tail… received an update before its start Match`
6
-
7
- Rewinds from `≤ 0.2.4` collided with the next real turn's `turn/start`, so reopening the session showed
8
-
9
- ```
10
- Failed to load history: conversation Context …:turn-tail… received an update before its start Match (internal)
11
- ```
12
-
13
- and the history vanished. Rewinds from `0.2.5` on no longer collide; already-corrupted sessions need an offline repair (the log is append-only). The repair tool (`dsh-rewind-repair`) is no longer shipped from v0.4.0 — install a pre-v0.4.0 release to get it (fully quit dsh web / host first, then):
14
-
15
- ```sh
16
- npm exec --yes --package=dsh-rewind-plugin@0.3.3 -- dsh-rewind-repair
17
- npm exec --yes --package=dsh-rewind-plugin@0.3.3 -- dsh-rewind-repair -- --dry-run # preview only
18
- ```
19
-
20
- It only rewrites the marker events' `data.turn` (seqs, order, and zstd frame structure intact) and backs up the original file before writing — safe to run repeatedly. From a source checkout of a pre-v0.4.0 tag: `node scripts/repair-markers.mjs` (identical flags).
21
-
22
- ## Known compatibility boundaries
23
-
24
- Behavioral notes verified by the compatibility probe suites
25
- ([compat-audit.md](compat-audit.md)) — none of these is a crash:
26
-
27
- - **Session stats / telemetry do not roll back**: whole-log folds count the
28
- withdrawn turns, and telemetry records the rewind's marker and ghost-step
29
- frame under the reused turn. Expected whole-log semantics.
30
- - **Withdrawn messages stay searchable and exported**: rewind cuts only the
31
- model-visible surface; full-text search and `/export` still see the raw log.
32
- - **Session titles may regenerate** after a rewind (title derives from the
33
- current surface).
34
- - **Files written by a cancelled tool call** (write happened, no result, no
35
- snapshot commit) cannot be restored by "conversation and code".
36
-
37
- **R-OPENSTEP** (harness-side, plugin does not guard): a session log carrying
38
- an *unclosed* `step/start` (a crash before the agent loop's `finally` closed
39
- the step) makes any later step activity break token-meter replay (and
40
- `/compact`). Harness `0.1.1-rc.2` fixes the crash path on load
41
- (`interruptedTurnClosers` closes leftover step/turn boundaries). A plugin-side
42
- up-front rejection was implemented and **reverted** (false positives broke the
43
- rewind feature on real session logs, `177ec14`); the residual runtime-produced
44
- risk is accepted. Tracked in the compat-audit.
File without changes