dsh-rewind-plugin 0.3.0 → 0.3.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.
@@ -13,8 +13,12 @@ export interface HiddenChat {
13
13
  get(key: string): ChatConversationViewNode | undefined;
14
14
  };
15
15
  }
16
- /** Extract the rewind target from a command outcome text ("已撤回 seq N..."). */
17
- export declare function targetOfOutcome(text: string | undefined): number | undefined;
16
+ /**
17
+ * Extract the rewind target seq from a `/rewind` command's structured `args`
18
+ * (e.g. `@5 chat`, `preview @5 both`). Locale-independent — never parses the
19
+ * host's human outcome copy.
20
+ */
21
+ export declare function targetSeqOfArgs(args: string | null | undefined): number | undefined;
18
22
  /**
19
23
  * True when a `/rewind` command node is an EXECUTED rewind for `seq` — the
20
24
  * admission form the popover drives (`@<seq> chat` / `both`) that settled
@@ -28,12 +32,19 @@ export declare function isExecutedRewindCommand(node: CommandNode, seq: number):
28
32
  * of the "rewind conversation and code" option (Claude Code hides the
29
33
  * code-restore options when the checkpoint has no tracked changes).
30
34
  *
31
- * Prefers the machine-readable `impact=<n>` trailer the current host appends
32
- * to preview text. Older host output (or a history-loaded preview row from
33
- * before the trailer existed) has none, so it falls back to the human copy
34
- * ("将影响 …") to keep mixed-version deployments correct.
35
+ * Reads ONLY the machine-readable `impact=<n>` trailer the host appends to
36
+ * preview text. Older host output without the trailer is treated as having no
37
+ * changes (never guesses from human copy). Unknown/absent text degrades to
38
+ * always-show so a working option is never hidden on a failed probe.
35
39
  */
36
40
  export declare function hasFileImpact(text: string | undefined): boolean;
41
+ /**
42
+ * True when a `/rewind` command node is the internal candidate-list probe
43
+ * (`/rewind __candidates`) the popupSelect runs to fetch the FULL candidate
44
+ * list from the host. Like previews, its flow node never surfaces in the
45
+ * transcript — it only feeds the popup — so it is hidden in every state.
46
+ */
47
+ export declare function isCandidateCommand(command: CommandNode): boolean;
37
48
  /**
38
49
  * Anchor seqs that must be hidden from the rendered transcript so the user
39
50
  * sees the conversation as the agent sees it: every impact-preview flow node
@@ -15,6 +15,8 @@ export declare const zh: {
15
15
  'popover.impact.loading': string;
16
16
  'popover.impact.failed': string;
17
17
  'popover.impact.none': string;
18
+ 'popover.impact.restore': string;
19
+ 'popover.impact.delete': string;
18
20
  'popover.confirm': string;
19
21
  'popover.back': string;
20
22
  'guard.hint': string;
@@ -43,6 +45,8 @@ export declare const en: {
43
45
  'popover.impact.loading': string;
44
46
  'popover.impact.failed': string;
45
47
  'popover.impact.none': string;
48
+ 'popover.impact.restore': string;
49
+ 'popover.impact.delete': string;
46
50
  'popover.confirm': string;
47
51
  'popover.back': string;
48
52
  'guard.hint': string;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Host-side localization for dsh-rewind's `/rewind` command output and command
3
+ * description.
4
+ *
5
+ * Architecture (matches the dsh ecosystem): the HOST half of a dual-face
6
+ * plugin has no locale service — only the browser client carries one. The host
7
+ * therefore renders its command-adjacent copy from a durable user preference
8
+ * (`ctx.settings` → `locale.preference`, registered by dsh-client-locale),
9
+ * defaulting to English — the ecosystem's neutral default language (the harness
10
+ * `FALLBACK_LOCALE` and the language dsh's own host commands use, e.g.
11
+ * dsh-plan-mode). See packages/client/locale in deepseek-harness.
12
+ *
13
+ * The client half (`src/client/locales.ts`) owns all interactive UI copy via
14
+ * `ctx.locale` + `t()`; the host's human text is a machine channel the client
15
+ * renders through machine tokens (`impact=<n>`, `args` @seq), never by parsing
16
+ * host prose.
17
+ *
18
+ * English is the key-set source of truth; zh is checked complete against it.
19
+ *
20
+ * @module dsh-rewind/locales
21
+ */
22
+ /** Host-side supported locale ids, mirroring the harness's shipped locales. */
23
+ export type HostLocaleId = 'zh' | 'en';
24
+ /** English dictionary — the key-set source of truth (neutral default). */
25
+ export declare const en: {
26
+ 'usage.title': string;
27
+ 'usage.noArgs': string;
28
+ 'usage.seq': string;
29
+ 'usage.blocked': string;
30
+ 'describeTarget.seq': string;
31
+ 'describeTarget.index': string;
32
+ 'plan.rewinding': string;
33
+ 'plan.affects': string;
34
+ 'plan.restore': string;
35
+ 'plan.delete': string;
36
+ 'plan.noChanges': string;
37
+ 'error.invalidTarget': string;
38
+ 'failures.suffix': string;
39
+ 'failures.item': string;
40
+ inflight: string;
41
+ stopFailed: string;
42
+ cancelled: string;
43
+ failed: string;
44
+ 'restore.count': string;
45
+ 'delete.count': string;
46
+ 'skip.count': string;
47
+ noRestorable: string;
48
+ success: string;
49
+ noUserMessages: string;
50
+ chooseMode: string;
51
+ 'command.description': string;
52
+ };
53
+ /** The host rewind dictionary key union. */
54
+ export type HostKey = keyof typeof en;
55
+ /** Chinese dictionary, checked complete against the en key set. */
56
+ export declare const zh: Record<HostKey, string>;
57
+ /** The host dictionaries keyed by locale id. */
58
+ export declare const HOST_DICTS: Record<HostLocaleId, Record<HostKey, string>>;
59
+ /**
60
+ * Render one dictionary key with `{name}` template interpolation. Unknown
61
+ * params are ignored; a missing key falls back to the raw key so a dictionary
62
+ * gap is visible instead of blank.
63
+ * @param lang - the active locale.
64
+ * @param key - the dictionary key.
65
+ * @param params - `{name}` substitution values.
66
+ */
67
+ export declare function translate(lang: HostLocaleId, key: HostKey, params?: Record<string, string | number>): string;
@@ -60,6 +60,12 @@ export interface RewindPlan {
60
60
  }
61
61
  /** Preview length cap for candidate listings. */
62
62
  export declare const CANDIDATE_PREVIEW_CHARS = 80;
63
+ /**
64
+ * Default cap on how many user messages a candidate listing returns (newest
65
+ * kept). Raised from 10 so long sessions don't look incomplete; callers can
66
+ * still pass an explicit `limit`.
67
+ */
68
+ export declare const DEFAULT_CANDIDATE_LIMIT = 50;
63
69
  /**
64
70
  * Turn number for the rewind marker.
65
71
  *
@@ -85,6 +91,18 @@ export declare const CANDIDATE_PREVIEW_CHARS = 80;
85
91
  export declare function markerTurnOf(events: readonly SessionEvent[]): number;
86
92
  /** Narrow an event to a user message. */
87
93
  export declare function isUserMessageEvent(event: SessionEvent): event is SessionEvent<'user/message'>;
94
+ /**
95
+ * True for a HUMAN user message event — one whose `source.kind` is `'user'`.
96
+ *
97
+ * The surface can carry `user/message` events whose source is NOT the user:
98
+ * plugin/system context injection (including compaction checkpoints) and
99
+ * tool-result backfill all arrive as `user/message` with a non-`'user'`
100
+ * source, and the client renders those as `context` nodes, never as a user
101
+ * bubble. Only genuine user messages (and user steering during a running
102
+ * turn, which keeps `source.kind: 'user'`) are valid rewind targets — a
103
+ * rewind boundary must land on a human prompt, not on injected context.
104
+ */
105
+ export declare function isHumanUserMessageEvent(event: SessionEvent): event is SessionEvent<'user/message'>;
88
106
  /** Join the text blocks of a message into one plain string. */
89
107
  export declare function messagePreview(message: UserMessage): string;
90
108
  /**
@@ -104,6 +122,23 @@ export declare function parseRewindTarget(raw: string): RewindTarget | undefined
104
122
  * @returns candidates numbered 1..N by recency.
105
123
  */
106
124
  export declare function listRewindCandidates(events: readonly SessionEvent[], surface: readonly number[], limit?: number): RewindCandidate[];
125
+ /** Header line of the machine-readable candidate list (locale-independent). */
126
+ export declare const CANDIDATE_LIST_HEADER = "candidates=";
127
+ /**
128
+ * Encode a candidate list as the host→client machine channel (the same
129
+ * trailer pattern `formatPlan` uses for `impact=`). The client popupSelect
130
+ * parses this instead of reading the windowed chat snapshot, so the candidate
131
+ * list reflects the FULL host surface — not just the already-loaded history.
132
+ *
133
+ * Lines (each preview is already whitespace-collapsed and tab-free by
134
+ * `messagePreview`):
135
+ * candidates=<n>
136
+ * <seq>\t<time>\t<preview>
137
+ * … (one line per candidate, newest first, matching `listRewindCandidates`)
138
+ *
139
+ * A list with no candidates is just `candidates=0`.
140
+ */
141
+ export declare function formatCandidateList(candidates: readonly RewindCandidate[]): string;
107
142
  /**
108
143
  * Resolve a target against the session log and surface into a validated plan.
109
144
  * @param events - the full session event log.
@@ -65,6 +65,34 @@ export interface RestoreOutcome {
65
65
  }
66
66
  /** Deletes one file by its real path (node:fs, bypassing the fs service). */
67
67
  export type DeleteFile = (path: string) => Promise<void>;
68
+ /**
69
+ * Current-on-disk state probe used by restore planning. Injected so the plan
70
+ * logic runs against a fake FS in tests; the production default reads the
71
+ * real file system with plain `node:fs` (see {@link defaultProbe}).
72
+ */
73
+ export interface DiskProbe {
74
+ /**
75
+ * Full text of the file, or undefined when the file does not exist.
76
+ * Any thrown error is treated as a probe failure: restore planning then
77
+ * conservatively treats the file as DIFFERING from its record (a restore
78
+ * still attempts the write / a delete still attempts the unlink), so an
79
+ * unreadable file is never silently skipped.
80
+ */
81
+ readText(path: string): Promise<string | undefined>;
82
+ /** True when the path is a symlink or a hard link (never planned/restored). */
83
+ isLink(path: string): Promise<boolean>;
84
+ }
85
+ /** One restore action the planner derived from record + disk reconciliation. */
86
+ export type PlannedAction = {
87
+ readonly path: string;
88
+ readonly action: 'restore';
89
+ readonly before: string;
90
+ } | {
91
+ readonly path: string;
92
+ readonly action: 'delete';
93
+ };
94
+ /** Production probe: real reads via node:fs, links detected by lstat + nlink. */
95
+ export declare const defaultProbe: DiskProbe;
68
96
  /**
69
97
  * On-disk checkpoint store. Every write goes straight through `node:fs`, so a
70
98
  * restore reliably lands on the real file system.
@@ -94,18 +122,49 @@ export declare class SnapshotStore {
94
122
  * single source of truth for both restore and impact preview.
95
123
  */
96
124
  private earliestEntries;
97
- /** Per-file restore impact for the earliest entry at/after the target. */
98
- impactsAfter(sessionId: string, targetSeq: number): Promise<FileImpact[]>;
99
125
  /**
100
- * Restore the workspace to the target message's checkpoint: for every path
101
- * with entries anchored at or after it, apply the EARLIEST entry — write the
102
- * before content back, or delete the file when it was created after the
103
- * target. Symlinked and hard-linked paths are skipped (reported, never
104
- * written through); a restored file's parent directory is created when it
105
- * was deleted after the backup. Failures are per-file and never abort the
106
- * pass.
126
+ * The single source of truth for BOTH the impact preview and the restore
127
+ * pass: reconcile the earliest recorded entry per path (at/after the
128
+ * target) against the CURRENT on-disk state, and plan only the actions
129
+ * that would actually change the disk. This is the Claude Code model
130
+ * `fileHistoryGetDiffStats` / `applySnapshot` both compare against the
131
+ * live filesystem (`checkOriginFileChanged`) and count only real
132
+ * differences, so a rewind whose target state already matches the disk is
133
+ * a no-op with zero impact.
134
+ *
135
+ * - `before === null` (the file did not exist at the target) plans a
136
+ * `delete` ONLY when the file currently exists; an already-absent file
137
+ * is a no-op — this kills the "ghost impact" of replaying an entry a
138
+ * previous rewind already consumed.
139
+ * - `before === 'X'` plans a `restore` ONLY when the current content
140
+ * differs from X (or the file is missing); identical content is a no-op
141
+ * — this keeps repeated rewinds idempotent.
142
+ * - Symlinked / hard-linked paths are never planned (they are reported as
143
+ * skipped by the restore pass, never written through).
144
+ * - A probe failure (e.g. a permission error reading the file) plans the
145
+ * action conservatively as if the file differed, so an unreadable file
146
+ * is never silently dropped from the restore.
147
+ *
148
+ * @param sessionId - session whose snapshot store to plan against.
149
+ * @param targetSeq - rewind target; entries anchored at/after it apply.
150
+ * @param probe - current-disk state probe (defaults to the real FS).
151
+ * @returns the planned actions plus the link paths that were skipped.
152
+ */
153
+ private planRestore;
154
+ /** Per-file restore impact: only actions that would actually change the disk. */
155
+ impactsAfter(sessionId: string, targetSeq: number, probe?: DiskProbe): Promise<FileImpact[]>;
156
+ /**
157
+ * Restore the workspace to the target message's checkpoint: execute exactly
158
+ * the actions {@link planRestore} derived from the record + current disk
159
+ * reconciliation — write the before content back, or delete the file when
160
+ * it was created after the target and still exists. Symlinked and
161
+ * hard-linked paths are skipped (reported, never written through); a
162
+ * restored file's parent directory is created when it was deleted after
163
+ * the backup; a delete whose file is ALREADY absent is a silent no-op (not
164
+ * a failure — the target state is already reached). Failures are per-file
165
+ * and never abort the pass.
107
166
  */
108
- restoreAfter(sessionId: string, targetSeq: number, deleteFile: DeleteFile): Promise<RestoreOutcome>;
167
+ restoreAfter(sessionId: string, targetSeq: number, deleteFile: DeleteFile, probe?: DiskProbe): Promise<RestoreOutcome>;
109
168
  /**
110
169
  * Drop the session's oldest anchor groups beyond `keep` (default
111
170
  * {@link MAX_ANCHOR_GROUPS}), deleting their whole directories.
@@ -113,4 +172,42 @@ export declare class SnapshotStore {
113
172
  prune(sessionId: string, keep?: number): Promise<void>;
114
173
  /** True when a path exists on disk (used by tests and diagnostics). */
115
174
  exists(path: string): Promise<boolean>;
175
+ /**
176
+ * All distinct paths ever recorded for a session — the "tracked files"
177
+ * set. Mirrors Claude Code's global `trackedFiles` collection (files stay
178
+ * tracked once a write-class tool touched them), derived from the disk
179
+ * entries so no extra persistence is needed.
180
+ */
181
+ trackedPaths(sessionId: string): Promise<Set<string>>;
116
182
  }
183
+ /**
184
+ * Re-check every tracked file at a user-message boundary and record the
185
+ * current on-disk state for any file whose state changed since it was last
186
+ * seen — Claude Code's `fileHistoryMakeSnapshot` re-stats every tracked file
187
+ * at each user message and snapshots the new state (changed files get a new
188
+ * backup version, deleted files a null marker). Here the "new version" is a
189
+ * plain before-backup entry anchored at the boundary message, so an EXTERNAL
190
+ * edit or deletion (never seen by the write-class tool capture) enters the
191
+ * record and can be restored by a later rewind.
192
+ *
193
+ * Semantics: the recorded `before` is the file's state at the boundary —
194
+ * the state the boundary message's turn starts from, exactly like the
195
+ * tool-captured entries. An entry is written only when the state differs
196
+ * from the last-seen state (`states`); the FIRST sighting of a path always
197
+ * records (a restart leaves `states` empty, so the first boundary after a
198
+ * restart unconditionally records the current state — redundant but correct,
199
+ * mirroring Claude's resume-then-re-stat behavior).
200
+ *
201
+ * Symlinked / hard-linked paths are never re-checked (restores skip them).
202
+ * A probe failure skips the file with a warning-level no-op; it never
203
+ * aborts the boundary pass.
204
+ *
205
+ * @param store - the session's snapshot store.
206
+ * @param sessionId - session whose tracked files to re-check.
207
+ * @param anchorSeq - the boundary user-message seq (entry anchor).
208
+ * @param tracked - the session's tracked path set (read-only here).
209
+ * @param states - per-path last-seen state (path → content, null = absent).
210
+ * @param probe - current-disk state probe (defaults to the real FS).
211
+ * @returns the number of entries recorded.
212
+ */
213
+ export declare function reconcileTracked(store: SnapshotStore, sessionId: string, anchorSeq: number, tracked: ReadonlySet<string>, states: Map<string, string | null>, probe?: DiskProbe): Promise<number>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-rewind-plugin",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
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",
@@ -82,6 +82,7 @@
82
82
  "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
83
83
  "@deepseek-ai/dsh-sandbox": "^0.1.0-rc.6",
84
84
  "@deepseek-ai/dsh-session": "^0.1.0-rc.6",
85
+ "@deepseek-ai/dsh-settings": "^0.1.0-rc.8",
85
86
  "@deepseek-ai/dsh-tools": "^0.1.0-rc.6"
86
87
  },
87
88
  "peerDependenciesMeta": {
@@ -115,6 +116,9 @@
115
116
  "@deepseek-ai/dsh-session": {
116
117
  "optional": true
117
118
  },
119
+ "@deepseek-ai/dsh-settings": {
120
+ "optional": true
121
+ },
118
122
  "@deepseek-ai/dsh-tools": {
119
123
  "optional": true
120
124
  }
@@ -132,6 +136,7 @@
132
136
  "@deepseek-ai/dsh-llm": "^0.1.0-rc.7",
133
137
  "@deepseek-ai/dsh-sandbox": "^0.1.0-rc.7",
134
138
  "@deepseek-ai/dsh-session": "^0.1.0-rc.7",
139
+ "@deepseek-ai/dsh-settings": "^0.1.0-rc.8",
135
140
  "@deepseek-ai/dsh-tools": "^0.1.0-rc.7",
136
141
  "@types/node": "^24.0.0",
137
142
  "@types/react": "^18.3.31",