dsh-rewind-plugin 0.15.3 → 0.16.0-alpha.2

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/SECURITY.md CHANGED
@@ -91,19 +91,21 @@ hides the `[target, marker]` span from the rendered transcript.
91
91
  ## Filesystem containment
92
92
 
93
93
  - **Store root**: `<dsh home>/rewind-snapshots/` by default (resolved via
94
- `resolveDshHome`, so `~/.dsh/...` when `DSH_HOME` is unset); the `snapshotDir`
95
- config, then `DSH_REWIND_SNAPSHOT_DIR` env, override it. Deleting it only
96
- removes file backups and the store rebuilds from scratch.
94
+ `resolveDshHome`, so `$DSH_HOME` feeds the base and `~/.dsh` is the fallback);
95
+ the `snapshotDir` config overrides the whole path. The plugin reads no
96
+ environment variable of its own. Deleting the root only removes file backups
97
+ and the store rebuilds from scratch.
97
98
  - **Path sanitization**: session ids and call ids are scrubbed to
98
99
  `[a-zA-Z0-9._-]` (`safeSessionId` / `safeFileId`) before joining the store
99
100
  root; `.` and `..` bare values are replaced — hostile ids cannot traverse
100
101
  out of the root.
101
- - **Never written through a link or a moved directory**: two checks guard a
102
- tracked path before any write or unlink. (1) A path whose last component is a
103
- symlink or a hard link (`lstat().nlink > 1`) is skipped and reported, never
104
- restored — a symlink would redirect the write outside the checkpoint, and a
105
- hard link would clobber every other name of the same inode (e.g.
106
- pnpm-installed files). (2) Every record this build writes — entry, link and
102
+ - **Links and moved directories are refused**: two checks guard a tracked path.
103
+ (1) Planning skips a path whose last component is a symlink or a hard link
104
+ (`lstat().nlink > 1`) and reports it, never restoring it — a symlink would
105
+ redirect the write outside the checkpoint, and a hard link would clobber every
106
+ other name of the same inode (e.g. pnpm-installed files); a link swapped in
107
+ after planning is not re-checked. (2) At apply time, every record this build
108
+ writes — entry, link and
107
109
  journal action — carries the `realpath` of the file's directory at commit time
108
110
  (the location pin; best-effort, and absent on released-v1 data or a commit
109
111
  whose parent could not be resolved), and a restore re-checks it before
@@ -120,8 +122,8 @@ hides the `[target, marker]` span from the rendered transcript.
120
122
  - **Restores name only recorded paths**: the store contains resolved display
121
123
  paths of the session's own write-class tool calls (plus boundary re-checks
122
124
  over that same tracked set) — a restore can never write an arbitrary path.
123
- - **Path resolution rule**: relative paths resolve against the session
124
- workspace cwd, mirroring the fs tools' own rule (`src/session-cwd.ts`).
125
+ - **Path resolution rule**: relative paths resolve against the session's current
126
+ working directory, read from the same service the fs tools resolve through.
125
127
  - **Bounded backups**: `prune` keeps the newest 100 anchor groups per session
126
128
  (`MAX_ANCHOR_GROUPS`), except groups a non-terminal restore journal still
127
129
  references (pinned so the op can be finished), so backup accumulation stays
@@ -145,11 +147,13 @@ hides the `[target, marker]` span from the rendered transcript.
145
147
  planned path's pre-restore ("rescue") state and persists an intent journal,
146
148
  then marks each action done as it is applied. A crash at any point leaves
147
149
  the journal on disk.
148
- - **Disk is truth**: after a restart, `reconcileRestores` re-derives from the
149
- real disk which paths already match the goal (restored) and which are
150
- pending; `continueRestore` finishes the interrupted op, `rollbackRestore`
151
- undoes it to the exact pre-restore state. A journal whose goal is already
152
- reached auto-heals to its terminal state.
150
+ - **Disk is truth**: an interrupted op keeps its journal on disk, and the store's
151
+ recovery API re-derives the true state from the real disk — `reconcileRestores`
152
+ reports which paths already match the goal (restored) and which are pending,
153
+ `continueRestore` finishes the op, `rollbackRestore` undoes it to the exact
154
+ pre-restore state, and a journal whose goal is already reached auto-heals to
155
+ its terminal state. The plugin never runs them on its own: an interrupted op
156
+ stays interrupted until an explicit caller resumes or undoes it.
153
157
  - **Fail-loud vs fail-soft**: a corrupt **journal** is reported
154
158
  `recovery-required` — never silently dropped (dropping it would erase the
155
159
  interrupted restore's recovery record). Corrupt **checkpoint entries** are
package/lib/client.js CHANGED
@@ -1095,6 +1095,13 @@ function hideWithdrawnSeats(chat, seats, hiddenSeqs, hidden) {
1095
1095
  }
1096
1096
  }
1097
1097
  }
1098
+ function showHiddenSeats(hidden) {
1099
+ for (const element of hidden) {
1100
+ element.style.display = "";
1101
+ delete element.dataset.dshRewindHidden;
1102
+ }
1103
+ hidden.clear();
1104
+ }
1098
1105
  function collectTargets(chat, hiddenSeqs) {
1099
1106
  const rows = /* @__PURE__ */ new Map();
1100
1107
  for (const element of document.querySelectorAll(USER_SEAT_SELECTOR)) {
@@ -1168,7 +1175,7 @@ function sameTargets(left, right) {
1168
1175
  }
1169
1176
  function RewindPortals({ sessionId, sessionOf, chatOf, isMainViewSession, watchChat, t, subscribeLocale, setComposerText, restoreImages }) {
1170
1177
  const [targets, setTargets] = (0, import_react.useState)([]);
1171
- const hidden = (0, import_react.useRef)(/* @__PURE__ */ new WeakSet());
1178
+ const hidden = (0, import_react.useRef)(/* @__PURE__ */ new Set());
1172
1179
  const [, forceRender] = (0, import_react.useReducer)((count) => count + 1, 0);
1173
1180
  (0, import_react.useEffect)(() => subscribeLocale(() => {
1174
1181
  forceRender();
@@ -1189,6 +1196,7 @@ function RewindPortals({ sessionId, sessionOf, chatOf, isMainViewSession, watchC
1189
1196
  const snapshot = session.getSnapshot();
1190
1197
  const chat = chatOf(session);
1191
1198
  const hiddenSeqs = chat === void 0 ? /* @__PURE__ */ new Set() : hiddenSeqsOf(chat);
1199
+ for (const element of hidden.current) if (!element.isConnected) hidden.current.delete(element);
1192
1200
  hideWithdrawnSeats(
1193
1201
  chat,
1194
1202
  chat === void 0 ? [] : document.querySelectorAll(CHAT_SEAT_SELECTOR),
@@ -1225,6 +1233,7 @@ function RewindPortals({ sessionId, sessionOf, chatOf, isMainViewSession, watchC
1225
1233
  return () => {
1226
1234
  active = false;
1227
1235
  observer.disconnect();
1236
+ showHiddenSeats(hidden.current);
1228
1237
  unsubscribeInbox?.();
1229
1238
  unsubscribeInbox = void 0;
1230
1239
  inboxOwner = void 0;
@@ -1359,9 +1368,9 @@ function createRewindBridge(deps) {
1359
1368
  }
1360
1369
 
1361
1370
  // src/client/build-info.ts
1362
- var PLUGIN_VERSION = true ? "0.15.3" : "dev";
1371
+ var PLUGIN_VERSION = true ? "0.16.0-alpha.2" : "dev";
1363
1372
  var PLUGIN_PACKAGE = "dsh-rewind-plugin";
1364
- var BUILD_HASH = true ? "f36f8be8" : "dev";
1373
+ var BUILD_HASH = true ? "c6cfbc55" : "dev";
1365
1374
 
1366
1375
  // src/client/locales.ts
1367
1376
  var zh = {
package/lib/index.js CHANGED
@@ -224,14 +224,6 @@ function planRewind(events, surface, target) {
224
224
  };
225
225
  }
226
226
 
227
- // src/session-cwd.ts
228
- function sessionCwd(cwd) {
229
- return cwd;
230
- }
231
- function execSessionCwd(exec) {
232
- return sessionCwd(exec.agent?.session.header.cwd);
233
- }
234
-
235
227
  // src/snapshot.ts
236
228
  import { createHash } from "node:crypto";
237
229
  import { chmod, copyFile, lstat, mkdir, open, readFile, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
@@ -246,15 +238,7 @@ var LEGACY_JOURNAL_PREFIX = "restore-journal-";
246
238
  var PENDING_MAX_AGE_MS = 24 * 60 * 60 * 1e3;
247
239
  var COMPARE_CHUNK_BYTES = 64 * 1024;
248
240
  var REPLACEMENT_CHAR = "\uFFFD";
249
- var SNAPSHOT_ROOT_ENV = "DSH_REWIND_SNAPSHOT_DIR";
250
- var MAX_FILE_BYTES_ENV = "DSH_REWIND_MAX_FILE_BYTES";
251
241
  var DEFAULT_MAX_FILE_BYTES = 8 * 1024 * 1024;
252
- function maxFileBytesFromEnv() {
253
- const raw = process.env[MAX_FILE_BYTES_ENV]?.trim();
254
- if (raw === void 0 || raw === "") return void 0;
255
- const parsed = Number(raw);
256
- return Number.isInteger(parsed) ? parsed : void 0;
257
- }
258
242
  var MAX_ANCHOR_GROUPS = 100;
259
243
  var CURRENT_STORE_VERSION = 2;
260
244
  var UnknownStoreVersionError = class extends Error {
@@ -683,6 +667,11 @@ var SnapshotStore = class _SnapshotStore {
683
667
  * and the restore planner refuses a pre-cap record over an over-cap file.
684
668
  */
685
669
  maxFileBytes;
670
+ /**
671
+ * Newest anchor groups (user messages) kept per session; `prune` evicts past
672
+ * this bound. A `Config` field (`maxAnchorGroups`).
673
+ */
674
+ maxAnchorGroups;
686
675
  /** Resolved checkpoint store root (absolute); see the constructor's fallback. */
687
676
  root;
688
677
  /**
@@ -708,8 +697,9 @@ var SnapshotStore = class _SnapshotStore {
708
697
  formatVersion = null;
709
698
  constructor(root, opts) {
710
699
  this.dedup = opts?.dedup ?? true;
711
- this.maxFileBytes = opts?.maxFileBytes ?? maxFileBytesFromEnv() ?? DEFAULT_MAX_FILE_BYTES;
712
- this.root = root ?? process.env[SNAPSHOT_ROOT_ENV] ?? join(resolveDshHome(opts?.dshHome), SNAPSHOT_DIR_NAME);
700
+ this.maxFileBytes = opts?.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES;
701
+ this.maxAnchorGroups = opts?.maxAnchorGroups ?? MAX_ANCHOR_GROUPS;
702
+ this.root = root ?? join(resolveDshHome(opts?.dshHome), SNAPSHOT_DIR_NAME);
713
703
  }
714
704
  /** Absolute path of one session's snapshot directory (id sanitized). */
715
705
  sessionDir(sessionId) {
@@ -1687,7 +1677,7 @@ var SnapshotStore = class _SnapshotStore {
1687
1677
  * real is still on disk and the kept link still resolves, so nothing dangles
1688
1678
  * and a later prune simply re-materializes.
1689
1679
  */
1690
- async prune(sessionId, keep = MAX_ANCHOR_GROUPS, opts) {
1680
+ async prune(sessionId, keep = this.maxAnchorGroups, opts) {
1691
1681
  const sessionDir = this.sessionDir(sessionId);
1692
1682
  let names;
1693
1683
  try {
@@ -2252,7 +2242,7 @@ async function runAutoCleanupCheck(deps, sessionId) {
2252
2242
  }
2253
2243
  if (!loaded.config.enabled) return;
2254
2244
  if (isAborted(deps.signal)) return;
2255
- if (!shouldRunAutoSweep(await loadLastSweepAt(deps.statePath), Date.now())) return;
2245
+ if (!shouldRunAutoSweep(await loadLastSweepAt(deps.statePath), Date.now(), deps.intervalMs)) return;
2256
2246
  if (isAborted(deps.signal)) return;
2257
2247
  await deps.pruner.pruneStale({ keepActiveId: sessionId, maxAgeDays: loaded.config.maxAgeDays });
2258
2248
  if (isAborted(deps.signal)) return;
@@ -2317,8 +2307,8 @@ function parseCleanupCommand(rawInput) {
2317
2307
  return { error: `unknown /snapshot-auto-cleanup subcommand "${parts[0]}"` };
2318
2308
  }
2319
2309
  }
2320
- function shouldRunAutoSweep(lastAtMs, nowMs) {
2321
- return nowMs - lastAtMs >= AUTO_SWEEP_INTERVAL_MS;
2310
+ function shouldRunAutoSweep(lastAtMs, nowMs, intervalMs = AUTO_SWEEP_INTERVAL_MS) {
2311
+ return nowMs - lastAtMs >= intervalMs;
2322
2312
  }
2323
2313
 
2324
2314
  // src/index.ts
@@ -2329,6 +2319,9 @@ var Config = z.object({
2329
2319
  snapshotDir: z.string().required(false),
2330
2320
  dshHome: z.string().required(false),
2331
2321
  dedup: z.boolean().default(true),
2322
+ maxFileBytes: z.number().step(1).min(0).default(DEFAULT_MAX_FILE_BYTES),
2323
+ maxAnchorGroups: z.number().step(1).min(1).default(MAX_ANCHOR_GROUPS),
2324
+ autoSweepIntervalMs: z.number().step(1).min(1).default(AUTO_SWEEP_INTERVAL_MS),
2332
2325
  autoCleanupEnabled: z.boolean().default(DEFAULT_CLEANUP_CONFIG.enabled).volatile(),
2333
2326
  autoCleanupMaxAgeDays: z.number().step(1).min(1).default(DEFAULT_CLEANUP_CONFIG.maxAgeDays).volatile()
2334
2327
  });
@@ -2388,10 +2381,7 @@ function anchorSeqOf(session, cache) {
2388
2381
  }
2389
2382
  async function resolveTarget(fs, path, cwd, signal) {
2390
2383
  try {
2391
- return await fs.resolve(path, {
2392
- ...cwd !== void 0 ? { cwd } : {},
2393
- signal
2394
- });
2384
+ return await fs.resolve(path, { cwd, signal });
2395
2385
  } catch {
2396
2386
  return void 0;
2397
2387
  }
@@ -2400,20 +2390,20 @@ function isNotFoundError(error) {
2400
2390
  const code = error?.code;
2401
2391
  return code === "ENOENT" || code === "FS_NOT_FOUND";
2402
2392
  }
2403
- async function captureBefore(fs, store, exec, pending) {
2393
+ async function captureBefore(fs, workingDirectory, store, exec, pending) {
2404
2394
  if (!TRACKED_TOOLS.has(exec.name)) return;
2405
- const session = exec.agent?.session;
2406
- if (session !== void 0 && isSubagentSession(session)) return;
2395
+ const agent = exec.agent;
2396
+ if (agent === void 0) return;
2397
+ if (isSubagentSession(agent.session)) return;
2407
2398
  const path = mutationPathOf(exec);
2408
2399
  if (path === void 0) return;
2409
- const cwd = execSessionCwd(exec);
2400
+ const cwd = await workingDirectory.ensure(agent, exec.signal);
2410
2401
  const target = await resolveTarget(fs, path, cwd, exec.signal);
2411
2402
  if (target === void 0) return;
2412
2403
  const info = await fs.stat(target, exec.signal).catch((error) => {
2413
2404
  if (isNotFoundError(error)) return void 0;
2414
2405
  throw error;
2415
2406
  });
2416
- if (session === void 0) return;
2417
2407
  if (info !== void 0 && info.type !== "file") return;
2418
2408
  if (info !== void 0 && store.isOverFileCap(info.size)) return;
2419
2409
  const key = `${exec.agent?.id ?? "anon"}:${exec.callId}`;
@@ -2421,7 +2411,7 @@ async function captureBefore(fs, store, exec, pending) {
2421
2411
  pending.set(key, { path: target.displayPath, backup: null });
2422
2412
  return;
2423
2413
  }
2424
- const staged = await store.stageCapture(session.id, key);
2414
+ const staged = await store.stageCapture(agent.session.id, key);
2425
2415
  let backup;
2426
2416
  try {
2427
2417
  await copyFile2(target.displayPath, staged);
@@ -2457,11 +2447,16 @@ async function commitEntry(store, pending, anchorCache, trackedBySession, exec,
2457
2447
  await discardCapture(capture);
2458
2448
  return;
2459
2449
  }
2460
- await store.recordBackup(agent.session.id, {
2461
- callId: exec.callId,
2462
- anchorSeq,
2463
- path: capture.path
2464
- }, capture.backup);
2450
+ try {
2451
+ await store.recordBackup(agent.session.id, {
2452
+ callId: exec.callId,
2453
+ anchorSeq,
2454
+ path: capture.path
2455
+ }, capture.backup);
2456
+ } catch (error) {
2457
+ await discardCapture(capture);
2458
+ throw error;
2459
+ }
2465
2460
  let tracked = trackedBySession.get(agent.session.id);
2466
2461
  if (tracked === void 0) {
2467
2462
  tracked = /* @__PURE__ */ new Set();
@@ -2541,7 +2536,7 @@ async function syncRestoreObservations(ctx, fs, agent, outcome) {
2541
2536
  for (const path of outcome.restored) {
2542
2537
  const target = await resolveObservationTarget(fs, path);
2543
2538
  if (target === void 0) continue;
2544
- const info = await fs.stat(target);
2539
+ const info = await fs.stat(target).catch(() => void 0);
2545
2540
  if (info === void 0) continue;
2546
2541
  ctx.emit("fs/observed", target, { kind: "present", version: info.version }, actor);
2547
2542
  }
@@ -2699,7 +2694,7 @@ async function handleRewind(ctx, store, fs, invocation, inflight) {
2699
2694
  return executeRewind(ctx, store, fs, invocation, target, mode, inflight);
2700
2695
  }
2701
2696
  var autoSweepChecked = false;
2702
- async function maybeRunAutoCleanup(ctx, store, sessionId, dshHome, signal) {
2697
+ async function maybeRunAutoCleanup(ctx, store, sessionId, dshHome, sweepIntervalMs, signal) {
2703
2698
  if (autoSweepChecked || signal?.aborted === true) return;
2704
2699
  autoSweepChecked = true;
2705
2700
  try {
@@ -2708,6 +2703,7 @@ async function maybeRunAutoCleanup(ctx, store, sessionId, dshHome, signal) {
2708
2703
  readConfig: () => readCleanupPolicy(),
2709
2704
  statePath: resolveCleanupStatePath(dshHome),
2710
2705
  log: (msg) => ctx.logger.warn(msg),
2706
+ ...sweepIntervalMs === void 0 ? {} : { intervalMs: sweepIntervalMs },
2711
2707
  ...signal === void 0 ? {} : { signal }
2712
2708
  }, sessionId);
2713
2709
  } catch (error) {
@@ -2845,7 +2841,12 @@ function apply(ctx, config) {
2845
2841
  child.effect(() => child.settings.configure({ auto: false }, ctx.fiber), "dsh-rewind page policy");
2846
2842
  });
2847
2843
  const dshHome = config?.dshHome;
2848
- const store = new SnapshotStore(config?.snapshotDir, { dedup: config?.dedup, dshHome });
2844
+ const store = new SnapshotStore(config?.snapshotDir, {
2845
+ dedup: config?.dedup,
2846
+ dshHome,
2847
+ maxFileBytes: config?.maxFileBytes,
2848
+ maxAnchorGroups: config?.maxAnchorGroups
2849
+ });
2849
2850
  const configKey = cleanupConfigKey(rewindConfigKey(ctx));
2850
2851
  if (config !== void 0 && configKey !== void 0) {
2851
2852
  const writer = {
@@ -2941,11 +2942,12 @@ function apply(ctx, config) {
2941
2942
  ctx.on("session/event", (session, event) => {
2942
2943
  if (event.type !== "user/message") return;
2943
2944
  if (isSubagentSession(session)) return;
2945
+ if (inflight.has(session.id)) return;
2944
2946
  void (async () => {
2945
2947
  try {
2946
2948
  if (lifecycle.signal.aborted) return;
2947
2949
  const sessionId = session.id;
2948
- void maybeRunAutoCleanup(ctx, store, sessionId, dshHome, lifecycle.signal);
2950
+ void maybeRunAutoCleanup(ctx, store, sessionId, dshHome, config?.autoSweepIntervalMs, lifecycle.signal);
2949
2951
  let tracked = trackedBySession.get(sessionId);
2950
2952
  if (tracked === void 0) {
2951
2953
  tracked = await store.trackedPaths(sessionId);
@@ -2959,12 +2961,16 @@ function apply(ctx, config) {
2959
2961
  }
2960
2962
  })();
2961
2963
  }, { global: true });
2962
- ctx.inject(["fs"], (scope) => {
2964
+ ctx.inject(["fs", "workingDirectory"], (scope) => {
2963
2965
  const fs = scope.fs;
2966
+ const workingDirectory = scope.workingDirectory;
2964
2967
  fsService = fs;
2968
+ scope.effect(() => () => {
2969
+ fsService = void 0;
2970
+ }, "dsh-rewind fs capture");
2965
2971
  scope.on("tools/execute", async (exec, next) => {
2966
2972
  try {
2967
- await captureBefore(fs, store, exec, pending);
2973
+ await captureBefore(fs, workingDirectory, store, exec, pending);
2968
2974
  } catch (error) {
2969
2975
  ctx.logger.warn(`[dsh-rewind] before-capture failed for ${exec.name}: ${error instanceof Error ? error.message : String(error)}`);
2970
2976
  }
@@ -2974,7 +2980,7 @@ function apply(ctx, config) {
2974
2980
  try {
2975
2981
  const session = exec.agent?.session;
2976
2982
  if (session !== void 0 && !isSubagentSession(session)) {
2977
- void maybeRunAutoCleanup(ctx, store, session.id, dshHome, lifecycle.signal);
2983
+ void maybeRunAutoCleanup(ctx, store, session.id, dshHome, config?.autoSweepIntervalMs, lifecycle.signal);
2978
2984
  }
2979
2985
  await commitEntry(store, pending, anchorCache, trackedBySession, exec, result);
2980
2986
  } catch (error) {
@@ -235,7 +235,19 @@ export declare function actionsContainerOf(row: HTMLElement | undefined): HTMLEl
235
235
  * @param hiddenSeqs - anchor seqs withdrawn by the executed rewinds.
236
236
  * @param hidden - elements this pass has hidden, so a re-created row is re-judged.
237
237
  */
238
- export declare function hideWithdrawnSeats(chat: HiddenChat | undefined, seats: Iterable<HTMLElement>, hiddenSeqs: ReadonlySet<number>, hidden: WeakSet<HTMLElement>): void;
238
+ export declare function hideWithdrawnSeats(chat: HiddenChat | undefined, seats: Iterable<HTMLElement>, hiddenSeqs: ReadonlySet<number>, hidden: Set<HTMLElement>): void;
239
+ /**
240
+ * Undo every hide the given set recorded, so a live disable (or a session-slot
241
+ * collapse) cannot leave the transcript truncated.
242
+ *
243
+ * The hide is the plugin's OWN mutation of host rows ({@link hideWithdrawnSeats}),
244
+ * and nothing else would ever revert it: a disposed bridge runs no further
245
+ * refresh, so rows it hid would stay hidden with no affordance to bring them
246
+ * back.
247
+ *
248
+ * @param hidden - the elements this bridge currently hides; emptied.
249
+ */
250
+ export declare function showHiddenSeats(hidden: Set<HTMLElement>): void;
239
251
  /**
240
252
  * Collect the portal targets of one session: user rows × snapshot nodes.
241
253
  * Exported as a test seam — the DOM→targets pairing that drives the ↶ button
@@ -37,12 +37,18 @@ export declare const inject: string[];
37
37
  export declare const ENGINE_COMMAND = "rewind-plugin";
38
38
  /** Plugin config. */
39
39
  export interface RewindConfig {
40
- /** Checkpoint store root (exact path; beats `DSH_REWIND_SNAPSHOT_DIR` and the harness-home default). */
40
+ /** Checkpoint store root (exact path; beats the harness-home default). */
41
41
  readonly snapshotDir?: string;
42
42
  /** Harness home override (`config.dshHome` > `$DSH_HOME` > `~/.dsh`); feeds the default snapshot/cleanup paths. */
43
43
  readonly dshHome?: string;
44
44
  /** In-place content dedup (identical before-content → link). Default `true`. */
45
45
  readonly dedup?: boolean;
46
+ /** Per-file backup cap in bytes; `0` disables the cap. Default {@link DEFAULT_MAX_FILE_BYTES}. */
47
+ readonly maxFileBytes?: number;
48
+ /** Newest anchor groups (user messages) kept per session. Default {@link MAX_ANCHOR_GROUPS}. */
49
+ readonly maxAnchorGroups?: number;
50
+ /** Automatic snapshot-sweep cadence in ms. Default {@link AUTO_SWEEP_INTERVAL_MS}. */
51
+ readonly autoSweepIntervalMs?: number;
46
52
  }
47
53
  /**
48
54
  * The plugin's full configuration: the ordinary store options plus the
@@ -62,12 +68,18 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
62
68
  snapshotDir: z<string, string, "plain">;
63
69
  dshHome: z<string, string, "plain">;
64
70
  dedup: z<boolean, boolean, "defined">;
71
+ maxFileBytes: z<number, number, "defined">;
72
+ maxAnchorGroups: z<number, number, "defined">;
73
+ autoSweepIntervalMs: z<number, number, "defined">;
65
74
  autoCleanupEnabled: z<boolean, boolean, "volatile-defined">;
66
75
  autoCleanupMaxAgeDays: z<number, number, "volatile-defined">;
67
76
  }>>, Schemastery.ObjectT<NoInfer<{
68
77
  snapshotDir: z<string, string, "plain">;
69
78
  dshHome: z<string, string, "plain">;
70
79
  dedup: z<boolean, boolean, "defined">;
80
+ maxFileBytes: z<number, number, "defined">;
81
+ maxAnchorGroups: z<number, number, "defined">;
82
+ autoSweepIntervalMs: z<number, number, "defined">;
71
83
  autoCleanupEnabled: z<boolean, boolean, "volatile-defined">;
72
84
  autoCleanupMaxAgeDays: z<number, number, "volatile-defined">;
73
85
  }>>, "plain">;
@@ -74,7 +74,7 @@ export interface CleanupConfigStore {
74
74
  * field is cleared rather than pinned.
75
75
  */
76
76
  export declare function volatileCleanupStore(settings: CleanupSettings, writer: CleanupConfigWriter): CleanupConfigStore;
77
- /** Auto-sweep cadence (the user's hardcoded 24h rhythm — not user-set). */
77
+ /** Default auto-sweep cadence (once a day); `autoSweepIntervalMs` overrides it. */
78
78
  export declare const AUTO_SWEEP_INTERVAL_MS: number;
79
79
  /** The state file that records the last automatic-sweep wall-clock time. */
80
80
  export declare const STATE_FILENAME = "snapshot-cleanup-last-sweep.json";
@@ -123,6 +123,8 @@ export declare function runAutoCleanupCheck(deps: {
123
123
  }>;
124
124
  statePath: string;
125
125
  log: (msg: string) => void;
126
+ /** Sweep cadence in ms; defaults to {@link AUTO_SWEEP_INTERVAL_MS}. */
127
+ intervalMs?: number;
126
128
  /** Optional mount lifecycle signal; absent means "never cancelled". */
127
129
  signal?: AbortSignal;
128
130
  }, sessionId: string | undefined): Promise<void>;
@@ -165,8 +167,8 @@ export declare function parseCleanupCommand(rawInput: string): CleanupCommand |
165
167
  error: string;
166
168
  };
167
169
  /**
168
- * The 24h auto-sweep throttle. `lastAtMs` of `0` means "never ran" (a fresh
169
- * process), so the first call always sweeps; after that a call within 24h is
170
- * a no-op, matching the "every machine at most once per day" model.
170
+ * The auto-sweep throttle: `lastAtMs` of `0` means "never ran" (a fresh
171
+ * process), so the first call always sweeps; after that a call within
172
+ * `intervalMs` is a no-op. `autoSweepIntervalMs` sets the cadence.
171
173
  */
172
- export declare function shouldRunAutoSweep(lastAtMs: number, nowMs: number): boolean;
174
+ export declare function shouldRunAutoSweep(lastAtMs: number, nowMs: number, intervalMs?: number): boolean;
@@ -36,15 +36,12 @@
36
36
  *
37
37
  * @module dsh-rewind/snapshot
38
38
  */
39
- /** Environment variable overriding the store root (tests, exotic homes). */
40
- export declare const SNAPSHOT_ROOT_ENV = "DSH_REWIND_SNAPSHOT_DIR";
41
- /** Env override for the per-file backup cap (`0` = unlimited); tests set it too. */
42
- export declare const MAX_FILE_BYTES_ENV = "DSH_REWIND_MAX_FILE_BYTES";
43
39
  /**
44
40
  * A regular file larger than this is NOT backed up. This plugin is a
45
41
  * lightweight, conversation-scoped undo, not a backup system: copying a very
46
42
  * large file on every edit is what grows the store without bound and saturates
47
- * IO (issue #39). The cap is a declared limitation, not a delta scheme.
43
+ * IO (issue #39). The cap is a declared limitation, not a delta scheme, and is
44
+ * exposed as the `maxFileBytes` config field (`0` disables the cap).
48
45
  */
49
46
  export declare const DEFAULT_MAX_FILE_BYTES: number;
50
47
  /** Number of newest anchor groups (user messages) kept per session. */
@@ -453,6 +450,11 @@ export declare class SnapshotStore {
453
450
  * and the restore planner refuses a pre-cap record over an over-cap file.
454
451
  */
455
452
  private readonly maxFileBytes;
453
+ /**
454
+ * Newest anchor groups (user messages) kept per session; `prune` evicts past
455
+ * this bound. A `Config` field (`maxAnchorGroups`).
456
+ */
457
+ private readonly maxAnchorGroups;
456
458
  /** Resolved checkpoint store root (absolute); see the constructor's fallback. */
457
459
  readonly root: string;
458
460
  /**
@@ -481,6 +483,8 @@ export declare class SnapshotStore {
481
483
  readonly dshHome?: string;
482
484
  /** Per-file backup cap in bytes; `<= 0` disables it. */
483
485
  readonly maxFileBytes?: number;
486
+ /** Newest anchor groups kept per session; see {@link MAX_ANCHOR_GROUPS}. */
487
+ readonly maxAnchorGroups?: number;
484
488
  });
485
489
  /** Absolute path of one session's snapshot directory (id sanitized). */
486
490
  sessionDir(sessionId: string): string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-rewind-plugin",
3
- "version": "0.15.3",
3
+ "version": "0.16.0-alpha.2",
4
4
  "description": "In-window conversation rewind with workspace file restore · 同窗口内对话回退并可还原工作区文件",
5
5
  "icon": "./assets/icon.svg",
6
6
  "keywords": [
@@ -20,7 +20,7 @@
20
20
  "license": "MIT",
21
21
  "engines": {
22
22
  "node": "^22.19.0 || >=24.0.0",
23
- "dsh": ">=0.2.0-rc.1"
23
+ "dsh": ">=0.2.1-alpha.2 <0.3.0-0"
24
24
  },
25
25
  "type": "module",
26
26
  "main": "lib/index.js",
@@ -74,20 +74,21 @@
74
74
  },
75
75
  "peerDependencies": {
76
76
  "@deepseek-ai/cordis": "^4.0.1",
77
- "@deepseek-ai/dsh-client-locale": "^0.2.0-rc.1",
78
- "@deepseek-ai/dsh-client-ui-commands": "^0.2.0-rc.1",
79
- "@deepseek-ai/dsh-client-ui-plugin-manager": "^0.2.0-rc.1",
80
- "@deepseek-ai/dsh-client-ui-primitives": "^0.2.0-rc.1",
81
- "@deepseek-ai/dsh-client-ui-settings": "^0.2.0-rc.1",
82
- "@deepseek-ai/dsh-client-ui-slots": "^0.2.0-rc.1",
83
- "@deepseek-ai/dsh-commands": "^0.2.0-rc.1",
84
- "@deepseek-ai/dsh-fs": "^0.2.0-rc.1",
85
- "@deepseek-ai/dsh-home-paths": "^0.2.0-rc.1",
86
- "@deepseek-ai/dsh-llm": "^0.2.0-rc.1",
87
- "@deepseek-ai/dsh-sandbox": "^0.2.0-rc.1",
88
- "@deepseek-ai/dsh-session": "^0.2.0-rc.1",
89
- "@deepseek-ai/dsh-settings": "^0.2.0-rc.1",
90
- "@deepseek-ai/dsh-tools": "^0.2.0-rc.1",
77
+ "@deepseek-ai/dsh-client-locale": "^0.2.1-alpha.2",
78
+ "@deepseek-ai/dsh-client-ui-commands": "^0.2.1-alpha.2",
79
+ "@deepseek-ai/dsh-client-ui-plugin-manager": "^0.2.1-alpha.2",
80
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.2.1-alpha.2",
81
+ "@deepseek-ai/dsh-client-ui-settings": "^0.2.1-alpha.2",
82
+ "@deepseek-ai/dsh-client-ui-slots": "^0.2.1-alpha.2",
83
+ "@deepseek-ai/dsh-commands": "^0.2.1-alpha.2",
84
+ "@deepseek-ai/dsh-fs": "^0.2.1-alpha.2",
85
+ "@deepseek-ai/dsh-home-paths": "^0.2.1-alpha.2",
86
+ "@deepseek-ai/dsh-llm": "^0.2.1-alpha.2",
87
+ "@deepseek-ai/dsh-sandbox": "^0.2.1-alpha.2",
88
+ "@deepseek-ai/dsh-session": "^0.2.1-alpha.2",
89
+ "@deepseek-ai/dsh-settings": "^0.2.1-alpha.2",
90
+ "@deepseek-ai/dsh-tools": "^0.2.1-alpha.2",
91
+ "@deepseek-ai/dsh-working-directory": "^0.2.1-alpha.2",
91
92
  "@deepseek-ai/schemastery": "^3.18.4"
92
93
  },
93
94
  "peerDependenciesMeta": {
@@ -136,6 +137,9 @@
136
137
  "@deepseek-ai/dsh-tools": {
137
138
  "optional": true
138
139
  },
140
+ "@deepseek-ai/dsh-working-directory": {
141
+ "optional": true
142
+ },
139
143
  "@deepseek-ai/schemastery": {
140
144
  "optional": true
141
145
  }
@@ -143,41 +147,42 @@
143
147
  "devDependencies": {
144
148
  "@deepseek-ai/cordis": "4.0.3",
145
149
  "@deepseek-ai/cosmokit": "1.8.4",
146
- "@deepseek-ai/dsh-agent": "0.2.1-alpha.1",
147
- "@deepseek-ai/dsh-api-session-controller": "0.2.1-alpha.1",
148
- "@deepseek-ai/dsh-attachment": "0.2.1-alpha.1",
149
- "@deepseek-ai/dsh-client-connection": "0.2.1-alpha.1",
150
- "@deepseek-ai/dsh-client-locale": "0.2.1-alpha.1",
151
- "@deepseek-ai/dsh-client-store": "0.2.1-alpha.1",
152
- "@deepseek-ai/dsh-client-ui-commands": "0.2.1-alpha.1",
153
- "@deepseek-ai/dsh-client-ui-conversation": "0.2.1-alpha.1",
154
- "@deepseek-ai/dsh-client-ui-input-trigger": "0.2.1-alpha.1",
155
- "@deepseek-ai/dsh-client-ui-plugin-manager": "0.2.1-alpha.1",
156
- "@deepseek-ai/dsh-client-ui-primitives": "0.2.1-alpha.1",
157
- "@deepseek-ai/dsh-client-ui-session": "0.2.1-alpha.1",
158
- "@deepseek-ai/dsh-client-ui-settings": "0.2.1-alpha.1",
159
- "@deepseek-ai/dsh-client-ui-slots": "0.2.1-alpha.1",
160
- "@deepseek-ai/dsh-command-compact": "0.2.1-alpha.1",
161
- "@deepseek-ai/dsh-commands": "0.2.1-alpha.1",
162
- "@deepseek-ai/dsh-compaction": "0.2.1-alpha.1",
163
- "@deepseek-ai/dsh-compaction-basic": "0.2.1-alpha.1",
164
- "@deepseek-ai/dsh-compaction-image-offload": "0.2.1-alpha.1",
165
- "@deepseek-ai/dsh-fs": "0.2.1-alpha.1",
166
- "@deepseek-ai/dsh-goal": "0.2.1-alpha.1",
167
- "@deepseek-ai/dsh-home-paths": "0.2.1-alpha.1",
168
- "@deepseek-ai/dsh-llm": "0.2.1-alpha.1",
169
- "@deepseek-ai/dsh-plan-mode": "0.2.1-alpha.1",
170
- "@deepseek-ai/dsh-sandbox": "0.2.1-alpha.1",
171
- "@deepseek-ai/dsh-scope": "0.2.1-alpha.1",
172
- "@deepseek-ai/dsh-session": "0.2.1-alpha.1",
173
- "@deepseek-ai/dsh-session-projection": "0.2.1-alpha.1",
174
- "@deepseek-ai/dsh-session-stats": "0.2.1-alpha.1",
175
- "@deepseek-ai/dsh-session-title": "0.2.1-alpha.1",
176
- "@deepseek-ai/dsh-settings": "0.2.1-alpha.1",
177
- "@deepseek-ai/dsh-token-meter": "0.2.1-alpha.1",
178
- "@deepseek-ai/dsh-tools": "0.2.1-alpha.1",
179
- "@deepseek-ai/dsh-typert-protocol": "0.2.1-alpha.1",
180
- "@deepseek-ai/dsh-user-questions": "0.2.1-alpha.1",
150
+ "@deepseek-ai/dsh-agent": "0.2.1-alpha.4",
151
+ "@deepseek-ai/dsh-api-session-controller": "0.2.1-alpha.4",
152
+ "@deepseek-ai/dsh-attachment": "0.2.1-alpha.4",
153
+ "@deepseek-ai/dsh-client-connection": "0.2.1-alpha.4",
154
+ "@deepseek-ai/dsh-client-locale": "0.2.1-alpha.4",
155
+ "@deepseek-ai/dsh-client-store": "0.2.1-alpha.4",
156
+ "@deepseek-ai/dsh-client-ui-commands": "0.2.1-alpha.4",
157
+ "@deepseek-ai/dsh-client-ui-conversation": "0.2.1-alpha.4",
158
+ "@deepseek-ai/dsh-client-ui-input-trigger": "0.2.1-alpha.4",
159
+ "@deepseek-ai/dsh-client-ui-plugin-manager": "0.2.1-alpha.4",
160
+ "@deepseek-ai/dsh-client-ui-primitives": "0.2.1-alpha.4",
161
+ "@deepseek-ai/dsh-client-ui-session": "0.2.1-alpha.4",
162
+ "@deepseek-ai/dsh-client-ui-settings": "0.2.1-alpha.4",
163
+ "@deepseek-ai/dsh-client-ui-slots": "0.2.1-alpha.4",
164
+ "@deepseek-ai/dsh-command-compact": "0.2.1-alpha.4",
165
+ "@deepseek-ai/dsh-commands": "0.2.1-alpha.4",
166
+ "@deepseek-ai/dsh-compaction": "0.2.1-alpha.4",
167
+ "@deepseek-ai/dsh-compaction-basic": "0.2.1-alpha.4",
168
+ "@deepseek-ai/dsh-compaction-image-offload": "0.2.1-alpha.4",
169
+ "@deepseek-ai/dsh-fs": "0.2.1-alpha.4",
170
+ "@deepseek-ai/dsh-goal": "0.2.1-alpha.4",
171
+ "@deepseek-ai/dsh-home-paths": "0.2.1-alpha.4",
172
+ "@deepseek-ai/dsh-llm": "0.2.1-alpha.4",
173
+ "@deepseek-ai/dsh-plan-mode": "0.2.1-alpha.4",
174
+ "@deepseek-ai/dsh-sandbox": "0.2.1-alpha.4",
175
+ "@deepseek-ai/dsh-scope": "0.2.1-alpha.4",
176
+ "@deepseek-ai/dsh-session": "0.2.1-alpha.4",
177
+ "@deepseek-ai/dsh-session-projection": "0.2.1-alpha.4",
178
+ "@deepseek-ai/dsh-session-stats": "0.2.1-alpha.4",
179
+ "@deepseek-ai/dsh-session-title": "0.2.1-alpha.4",
180
+ "@deepseek-ai/dsh-settings": "0.2.1-alpha.4",
181
+ "@deepseek-ai/dsh-token-meter": "0.2.1-alpha.4",
182
+ "@deepseek-ai/dsh-tools": "0.2.1-alpha.4",
183
+ "@deepseek-ai/dsh-typert-protocol": "0.2.1-alpha.4",
184
+ "@deepseek-ai/dsh-user-questions": "0.2.1-alpha.4",
185
+ "@deepseek-ai/dsh-working-directory": "0.2.1-alpha.4",
181
186
  "@deepseek-ai/schemastery": "3.18.4",
182
187
  "@types/node": "^24.0.0",
183
188
  "@types/react": "^18.3.31",
@@ -1,21 +0,0 @@
1
- /**
2
- * Session-cwd resolution for snapshot tracking reads: relative paths resolve
3
- * against the calling agent's session workspace (`exec.agent.session.header.cwd`),
4
- * returned verbatim — no canonicalization — or `undefined` when the session has
5
- * none (the filesystem backend then applies its own default base).
6
- *
7
- * @module dsh-rewind/session-cwd
8
- */
9
- import type { ToolExecution } from '@deepseek-ai/dsh-tools';
10
- /**
11
- * The session workspace cwd to resolve a requested path against.
12
- * @param cwd - the session's `header.cwd`, if any.
13
- * @returns the cwd unchanged, or undefined when there is none.
14
- */
15
- export declare function sessionCwd(cwd: string | undefined): string | undefined;
16
- /**
17
- * Session cwd for one tool execution.
18
- * @param exec - the tool-execution context; only its optional `agent` is read.
19
- * @returns the calling agent's session cwd, or undefined for a non-agent caller.
20
- */
21
- export declare function execSessionCwd(exec: ToolExecution): string | undefined;