dsh-rewind-plugin 0.1.1 → 0.1.3

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
@@ -2,9 +2,9 @@
2
2
 
3
3
  DeepSeek Harness 插件:**同一会话窗口的 in-place 对话回退**(Claude Code `/rewind` 语义)。主交互为**用户消息旁的「回退」按钮**,点击后选择回退模式;命令仅作辅助。
4
4
 
5
- > 状态:v0.1.0 已实现(host + client 双面插件)。交互以 Claude Code 行为为参考,并贴合 dsh Web 实际 UI(利用现有 DOM 锚点与运行时快照,纯插件、不改仓库核心)。
5
+ > 状态:v0.1.3 已实现并发布(`dsh-rewind-plugin`,npm + GitHub Actions Trusted Publishing)。交互以 Claude Code 行为为参考,并贴合 dsh Web 实际 UI(利用现有 DOM 锚点与运行时快照,纯插件、不改仓库核心)。
6
6
 
7
- ## 实现状态(v0.1.0
7
+ ## 实现状态(v0.1.3
8
8
 
9
9
  - ✅ host 端 `/rewind` 命令(两步文本引导 + 直接执行 + `preview` 影响清单)
10
10
  - ✅ host 端变更台账(`tools/execute` 捕获 before、`tools/post-execute` 提交),按会话隔离
@@ -39,7 +39,8 @@ dsh plugin --profile web add dsh-rewind-plugin
39
39
 
40
40
  ## 发布(维护者,CI + Trusted Publishing)
41
41
 
42
- 发布走 GitHub Actions + npm **Trusted Publishing**(OIDC,无 npm token):
42
+ 已发布版本:`0.1.0`(本地 2FA 首发)、`0.1.1`(CI OIDC + Sigstore provenance)。
43
+ 后续发版走 CI:
43
44
 
44
45
  ```sh
45
46
  npm version patch # 或 minor / major;同步更新 README 版本说明
@@ -60,24 +61,32 @@ git push --tags # push v<version> tag → 触发 .github/workflows/publish.
60
61
  (本 workflow 无 `environment` 块,OIDC subject 携带 ref,此为已验证组合)·
61
62
  **Allowed actions:`npm publish`**(2026-05-20 起必选)。
62
63
  - 质量门禁(PR / push main):`.github/workflows/ci.yml` 跑 typecheck + 测试
63
- + 构建 + `verify-host`。
64
+ + 构建 + `verify-host` + tarball 完整性检查(`lib/` 与 `LICENSE` 必须在包内)。
65
+ - 包内附带 `.d.ts` 类型声明(`exports` 已声明 `types`,源码级 `./src/*` 亦可导入)。
64
66
 
65
67
  ## 使用
66
68
 
67
69
  - 每条用户消息 hover 出现「↶ 回退」按钮:点击 → 选择「仅回退对话」或
68
70
  「回退对话和代码」(后者先展示影响清单再确认)。
71
+ - **撤回最近消息**:对**最后一条**用户消息回退(按钮或 `/rewind <目标>`),效果是
72
+ **撤回该消息本身**(发错了立即重发的常见场景)——上下文回到它之前,命令结果
73
+ 提示"已撤回 seq N,可重新发送"。
69
74
  - 键盘流:`/rewind` → 选消息 → `/rewind <序号> chat|both`;`/rewind preview <目标>`
70
75
  只输出影响清单不执行。
71
- - 回退后:模型上下文从目标消息重新开始;会话日志与可见对话完整保留
72
- (append-only);标记节点不渲染为气泡(非 append surface 事件),结果以命令节点呈现。
76
+ - **回退后前端与 Agent 一致**:回退/撤回后,client 端自动隐藏回退标记、`/rewind`
77
+ 命令结果以及被回退范围内的消息(含 agent 回复),可见对话回到回退点之前的样子;
78
+ 之后新发的消息正常显示。会话日志(append-only 审计)不受影响。
73
79
 
74
80
  ## 已知限制(v0.1)
75
81
 
76
82
  - 台账只覆盖插件运行期间、经 `write` / `edit` / `str_replace_editor` 的变更;
77
- bash 或外部程序的修改无法还原(二期可加 git-first 快照层)。
83
+ bash 或外部程序的修改无法还原(二期可加 git-first 快照层)。台账按会话有界
84
+ (每会话保留最近 2000 条,最旧先丢弃)。
78
85
  - 文件删除走 `processPath` 直删(本地 backend);sandbox/远程 backend 下还原
79
86
  可能受限。
80
87
  - 回退本身可再回退(标记进入日志),但文件还原动作不再重新入台账。
88
+ - 回退按钮只出现在**当前会话**渲染的用户消息行上(DOM 注入范围即当前视图);
89
+ subagent/分屏等非当前会话的对话需要先切到该会话再回退。
81
90
 
82
91
  ## 背景与定位
83
92
 
package/lib/client.js CHANGED
@@ -298,7 +298,7 @@ function renderImpactStep(root, opts, back) {
298
298
  session,
299
299
  (node) => {
300
300
  const args = node.args ?? "";
301
- return node.name === "rewind" && args.includes("preview") && args.includes(`@${seq}`);
301
+ return node.name === "rewind" && args.includes("preview") && new RegExp(`(?:^|\\s)@${seq}(?=\\s|$)`).test(args);
302
302
  }
303
303
  );
304
304
  if (outcome === null) {
@@ -385,10 +385,43 @@ var name = "dsh-rewind";
385
385
  var inject = ["sessions", "locale"];
386
386
  var NS = "rewind";
387
387
  var USER_SEAT_SELECTOR = '[data-chat-flow-kind="user"]';
388
+ var CHAT_SEAT_SELECTOR = "[data-chat-anchor-key]";
388
389
  var ACTIONS_ROOT_SELECTOR = "[data-time-hover-root]";
390
+ var REWIND_MARKER_RE = /__DSH_REWIND__:\{"target":(\d+)\}/;
389
391
  function messagePreviewOf(node) {
390
392
  return node.content.map((block) => block.type === "text" && typeof block.text === "string" ? block.text : "").join("").replace(/\s+/g, " ").trim().slice(0, 80);
391
393
  }
394
+ function hiddenSeqsOf(session) {
395
+ const hidden = /* @__PURE__ */ new Set();
396
+ const snap = session.getSnapshot();
397
+ let latest = null;
398
+ for (const key of snap.chat.order) {
399
+ const node = snap.chat.nodes.get(key);
400
+ if (node === void 0) continue;
401
+ if (node.kind === "command") {
402
+ const command = node.data;
403
+ if (command.name === "rewind") hidden.add(command.seq);
404
+ } else if (node.kind === "user") {
405
+ const user = node.data;
406
+ const text = user.content.map((block) => block.type === "text" && typeof block.text === "string" ? block.text : "").join("");
407
+ const match = text.match(REWIND_MARKER_RE);
408
+ if (match !== null) {
409
+ hidden.add(user.seq);
410
+ const target = Number(match[1]);
411
+ if (latest === null || user.seq > latest.marker) latest = { marker: user.seq, target };
412
+ }
413
+ }
414
+ }
415
+ if (latest !== null) {
416
+ for (const key of snap.chat.order) {
417
+ const node = snap.chat.nodes.get(key);
418
+ if (node === void 0) continue;
419
+ const anchor = node.anchorSeq;
420
+ if (anchor > latest.target && anchor <= latest.marker) hidden.add(anchor);
421
+ }
422
+ }
423
+ return hidden;
424
+ }
392
425
  function apply(ctx) {
393
426
  ctx.effect(function* () {
394
427
  yield ctx.locale.register(NS, { zh, en });
@@ -398,6 +431,7 @@ function apply(ctx) {
398
431
  style.textContent = STYLE;
399
432
  document.head.appendChild(style);
400
433
  const attached = /* @__PURE__ */ new WeakSet();
434
+ const hidden = /* @__PURE__ */ new WeakSet();
401
435
  const buttons = /* @__PURE__ */ new Map();
402
436
  let observer = null;
403
437
  const sessionFor = () => {
@@ -449,7 +483,22 @@ function apply(ctx) {
449
483
  for (const [key, button] of buttons) {
450
484
  if (!button.isConnected) buttons.delete(key);
451
485
  }
452
- for (const seat of document.querySelectorAll(USER_SEAT_SELECTOR)) attach(seat);
486
+ const session = sessionFor();
487
+ const hiddenSeqs = session !== void 0 ? hiddenSeqsOf(session) : /* @__PURE__ */ new Set();
488
+ for (const seat of document.querySelectorAll(CHAT_SEAT_SELECTOR)) {
489
+ const key = seat.dataset.chatAnchorKey;
490
+ const anchor = key !== void 0 && session !== void 0 ? session.getSnapshot().chat.nodes.get(key)?.anchorSeq : void 0;
491
+ if (anchor !== void 0 && hiddenSeqs.has(anchor)) {
492
+ seat.style.display = "none";
493
+ hidden.add(seat);
494
+ } else if (hidden.has(seat)) {
495
+ seat.style.display = "";
496
+ hidden.delete(seat);
497
+ }
498
+ }
499
+ for (const seat of document.querySelectorAll(USER_SEAT_SELECTOR)) {
500
+ if (!hidden.has(seat)) attach(seat);
501
+ }
453
502
  };
454
503
  observer = new MutationObserver(scan);
455
504
  observer.observe(document.body, { childList: true, subtree: true });
package/lib/index.js CHANGED
@@ -14,11 +14,13 @@ function execSessionCwd(exec, requestedPath) {
14
14
  }
15
15
 
16
16
  // src/ledger.ts
17
+ var MAX_LEDGER_ENTRIES = 2e3;
17
18
  var RewindLedger = class {
18
19
  entries = [];
19
- /** Record one committed mutation. */
20
+ /** Record one committed mutation, dropping the oldest entry when over the cap. */
20
21
  record(entry) {
21
22
  this.entries.push(entry);
23
+ if (this.entries.length > MAX_LEDGER_ENTRIES) this.entries.shift();
22
24
  }
23
25
  /**
24
26
  * All entries anchored at or after `targetSeq`, newest first. The boundary
@@ -168,10 +170,13 @@ function planRewind(events, surface, target) {
168
170
  );
169
171
  }
170
172
  if (targetIndex === surface.length - 1) {
171
- throw new RewindError(
172
- "nothing-after",
173
- `user message at seq ${targetSeq} is already the last context item; nothing to rewind`
174
- );
173
+ return {
174
+ targetSeq,
175
+ targetIndex,
176
+ shadowedSeqs: [targetSeq],
177
+ surfaceStart: targetSeq,
178
+ surfaceEnd: targetSeq
179
+ };
175
180
  }
176
181
  const shadowedSeqs = surface.slice(targetIndex + 1);
177
182
  return {
@@ -279,7 +284,8 @@ function buildMarker(targetSeq) {
279
284
  return createUserMessage({
280
285
  content: [{
281
286
  type: "text",
282
- text: `[\u56DE\u9000\u6807\u8BB0 / rewind marker] \u5BF9\u8BDD\u5DF2\u56DE\u9000\u5230 seq ${targetSeq}\uFF0C\u6B64\u6807\u8BB0\u4E4B\u540E\u7684\u5386\u53F2\u5DF2\u4ECE\u6A21\u578B\u4E0A\u4E0B\u6587\u4E2D\u79FB\u9664\u3002\u8BF7\u5FFD\u7565\u6B64\u6807\u8BB0\u672C\u8EAB\uFF0C\u7B49\u5F85\u7528\u6237\u7684\u4E0B\u4E00\u6761\u6D88\u606F\u3002`
287
+ text: `__DSH_REWIND__:{"target":${targetSeq}}
288
+ [\u56DE\u9000\u6807\u8BB0 / rewind marker] \u5BF9\u8BDD\u5DF2\u56DE\u9000\u5230 seq ${targetSeq}\uFF0C\u6B64\u6807\u8BB0\u4E4B\u540E\u7684\u5386\u53F2\u5DF2\u4ECE\u6A21\u578B\u4E0A\u4E0B\u6587\u4E2D\u79FB\u9664\u3002\u8BF7\u5FFD\u7565\u6B64\u6807\u8BB0\u672C\u8EAB\uFF0C\u7B49\u5F85\u7528\u6237\u7684\u4E0B\u4E00\u6761\u6D88\u606F\u3002`
283
289
  }],
284
290
  source: { kind: "user" }
285
291
  });
@@ -351,9 +357,10 @@ async function executeRewind(ctx, ledger, invocation, rawTarget, mode) {
351
357
  restore = `\uFF1B\u8FD8\u539F ${outcome.restored.length} \u4E2A\u6587\u4EF6\u3001\u5220\u9664 ${outcome.deleted.length} \u4E2A\u6587\u4EF6${renderFailures(outcome.failed)}`;
352
358
  }
353
359
  }
360
+ const withdrewLatest = plan.shadowedSeqs[0] === plan.targetSeq;
354
361
  return {
355
362
  kind: "success",
356
- text: `\u5DF2\u56DE\u9000\u5230 seq ${plan.targetSeq}\uFF0C\u79FB\u9664 ${plan.shadowedSeqs.length} \u6761\u4E0A\u4E0B\u6587\uFF08\u65E5\u5FD7\u4FDD\u7559\uFF09${restore}\u3002`,
363
+ text: withdrewLatest ? `\u5DF2\u64A4\u56DE seq ${plan.targetSeq}\uFF08\u6700\u8FD1\u4E00\u6761\u6D88\u606F\uFF09\uFF0C\u53EF\u91CD\u65B0\u53D1\u9001${restore}\u3002` : `\u5DF2\u56DE\u9000\u5230 seq ${plan.targetSeq}\uFF0C\u79FB\u9664 ${plan.shadowedSeqs.length} \u6761\u4E0A\u4E0B\u6587\uFF08\u65E5\u5FD7\u4FDD\u7559\uFF09${restore}\u3002`,
357
364
  sourceEventSeq: event.seq
358
365
  };
359
366
  }
@@ -363,8 +370,7 @@ function rewindErrorResult(error) {
363
370
  "no-user-messages": "\u5F53\u524D\u4F1A\u8BDD\u8FD8\u6CA1\u6709\u53EF\u56DE\u9000\u7684\u7528\u6237\u6D88\u606F\u3002",
364
371
  "invalid-index": error.message,
365
372
  "not-a-user-message": error.message,
366
- "not-on-surface": error.message,
367
- "nothing-after": error.message
373
+ "not-on-surface": error.message
368
374
  }[error.code];
369
375
  return { kind: "error", text };
370
376
  }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * dsh-rewind client half: the per-user-message ↶ rewind button and the
3
+ * mode-selection popover, injected into the conversation DOM (pure plugin —
4
+ * no harness source patches).
5
+ *
6
+ * Anchoring: each chat node seat renders `[data-chat-flow-kind]` with
7
+ * `data-chat-anchor-key`; a `MutationObserver` tracks newly rendered user
8
+ * seats and appends the rewind button into the message's IconActions row.
9
+ * The seq is never parsed from DOM text — the key is looked up in the runtime
10
+ * snapshot (`session.getSnapshot().chat.nodes.get(key)`) to get the durable
11
+ * `UserMessageNode.seq`.
12
+ *
13
+ * Interaction: clicking a message's button fixes the target (step one), the
14
+ * popover offers the two modes (step two); "rewind conversation and code"
15
+ * first fetches the impact list via `/rewind preview @seq both` and confirms
16
+ * before executing. Execution always goes through `session.command(...)`, the
17
+ * same host path the `/rewind` command uses.
18
+ *
19
+ * @module dsh-rewind/client
20
+ */
21
+ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client';
22
+ export declare const name = "dsh-rewind";
23
+ export declare const inject: string[];
24
+ /**
25
+ * Client plugin body: button injection + popover wiring.
26
+ * @param ctx - client root context carrying `sessions` and `locale`.
27
+ */
28
+ export declare function apply(ctx: ClientContext): void;
@@ -0,0 +1,43 @@
1
+ /** `rewind` namespace dictionaries for the client plugin. */
2
+ /** Simplified Chinese dictionary (the key-set source of truth). */
3
+ export declare const zh: {
4
+ 'button.aria': string;
5
+ 'button.title': string;
6
+ 'popover.title': string;
7
+ 'popover.target': string;
8
+ 'popover.chat': string;
9
+ 'popover.chat.hint': string;
10
+ 'popover.both': string;
11
+ 'popover.both.hint': string;
12
+ 'popover.cancel': string;
13
+ 'popover.impact.loading': string;
14
+ 'popover.impact.failed': string;
15
+ 'popover.impact.none': string;
16
+ 'popover.confirm': string;
17
+ 'popover.back': string;
18
+ };
19
+ /** The rewind namespace key union. */
20
+ export type RewindKey = keyof typeof zh;
21
+ declare module '@deepseek-ai/dsh-client-ui-slots' {
22
+ interface LocaleNamespaceMap {
23
+ /** The in-place rewind controls' copy. */
24
+ rewind: RewindKey;
25
+ }
26
+ }
27
+ /** English dictionary, checked complete against the zh key set. */
28
+ export declare const en: {
29
+ 'button.aria': string;
30
+ 'button.title': string;
31
+ 'popover.title': string;
32
+ 'popover.target': string;
33
+ 'popover.chat': string;
34
+ 'popover.chat.hint': string;
35
+ 'popover.both': string;
36
+ 'popover.both.hint': string;
37
+ 'popover.cancel': string;
38
+ 'popover.impact.loading': string;
39
+ 'popover.impact.failed': string;
40
+ 'popover.impact.none': string;
41
+ 'popover.confirm': string;
42
+ 'popover.back': string;
43
+ };
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The rewind mode-selection popover (plain DOM, no React). Step two of the
3
+ * interaction: the target is already fixed (the clicked message); the popover
4
+ * offers the two modes. Choosing "both" first fetches the impact list through
5
+ * the `/rewind preview @seq both` command and shows it before confirming.
6
+ *
7
+ * @module dsh-rewind/client/popover
8
+ */
9
+ import type { SessionFace } from '@deepseek-ai/dsh-client-runtime/client';
10
+ import type { RewindKey } from './locales.ts';
11
+ type Translate = (key: RewindKey, params?: Record<string, unknown>) => string;
12
+ export interface PopoverOptions {
13
+ readonly session: SessionFace;
14
+ readonly seq: number;
15
+ readonly time: number;
16
+ readonly preview: string;
17
+ /** The button that opened the popover (outside-click ignore target). */
18
+ readonly anchor: HTMLElement;
19
+ readonly t: Translate;
20
+ }
21
+ /** Close the current popover, if any. */
22
+ export declare function closePopover(): void;
23
+ /** Open the mode-selection popover anchored near the given button. */
24
+ export declare function openPopover(opts: PopoverOptions): void;
25
+ export {};
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Client plugin styling: one injected `<style>` tag (scoped class names),
3
+ * following the dsh design tokens (`--dsw-*`) so the button and popover blend
4
+ * with the conversation chrome.
5
+ *
6
+ * @module dsh-rewind/client/styles
7
+ */
8
+ /** Marker attribute set on a seat row once its rewind button is attached. */
9
+ export declare const REWIND_ATTACHED = "data-dsh-rewind-attached";
10
+ /** Class names shared between the injected DOM and the stylesheet. */
11
+ export declare const CLASS: {
12
+ readonly button: 'dsh-rewind-btn';
13
+ readonly popover: 'dsh-rewind-popover';
14
+ readonly popoverTitle: 'dsh-rewind-popover-title';
15
+ readonly popoverTarget: 'dsh-rewind-popover-target';
16
+ readonly popoverOption: 'dsh-rewind-popover-option';
17
+ readonly popoverOptionLabel: 'dsh-rewind-popover-option-label';
18
+ readonly popoverOptionHint: 'dsh-rewind-popover-option-hint';
19
+ readonly popoverImpact: 'dsh-rewind-popover-impact';
20
+ readonly popoverActions: 'dsh-rewind-popover-actions';
21
+ readonly popoverPrimary: 'dsh-rewind-popover-primary';
22
+ readonly popoverGhost: 'dsh-rewind-popover-ghost';
23
+ };
24
+ /** The ↶ glyph, drawn inline so the bundle stays dependency-free. */
25
+ export declare const REWIND_ICON_SVG: string;
26
+ /** One injected stylesheet (scoped under `.dsh-rewind-*`). */
27
+ export declare const STYLE = "\n.dsh-rewind-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 28px;\n height: 28px;\n padding: 6px;\n border: none;\n border-radius: 28px;\n background: transparent;\n color: var(--dsw-alias-label-tertiary);\n cursor: pointer;\n}\n.dsh-rewind-btn:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n color: var(--dsw-alias-label-secondary);\n}\n\n.dsh-rewind-popover {\n position: fixed;\n z-index: 1000;\n width: 288px;\n padding: 12px;\n border-radius: 12px;\n background: var(--dsw-specific-surface-1, var(--dsw-alias-surface-1, #1f2127));\n box-shadow: 0 8px 28px rgba(0, 0, 0, 0.32);\n font-size: 14px;\n line-height: 20px;\n color: var(--dsw-alias-label-primary);\n}\n.dsh-rewind-popover-title {\n font-size: 14px;\n font-weight: 600;\n line-height: 20px;\n}\n.dsh-rewind-popover-target {\n margin: 4px 0 10px;\n font-size: 12px;\n line-height: 16px;\n color: var(--dsw-alias-label-tertiary);\n word-break: break-all;\n}\n.dsh-rewind-popover-option {\n display: flex;\n flex-direction: column;\n gap: 2px;\n width: 100%;\n margin: 0 0 6px;\n padding: 8px 10px;\n border: 1px solid transparent;\n border-radius: 8px;\n background: transparent;\n color: inherit;\n font: inherit;\n text-align: left;\n cursor: pointer;\n}\n.dsh-rewind-popover-option:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n.dsh-rewind-popover-option-label {\n font-weight: 500;\n}\n.dsh-rewind-popover-option-hint {\n font-size: 12px;\n line-height: 16px;\n color: var(--dsw-alias-label-tertiary);\n}\n.dsh-rewind-popover-impact {\n margin: 4px 0 10px;\n padding: 8px 10px;\n border-radius: 8px;\n background: var(--dsw-alias-interactive-bg-hover);\n font-size: 12px;\n line-height: 16px;\n color: var(--dsw-alias-label-secondary);\n white-space: pre-wrap;\n max-height: 160px;\n overflow: auto;\n}\n.dsh-rewind-popover-actions {\n display: flex;\n justify-content: flex-end;\n gap: 8px;\n}\n.dsh-rewind-popover-primary,\n.dsh-rewind-popover-ghost {\n padding: 5px 12px;\n border: none;\n border-radius: 8px;\n font: inherit;\n font-size: 13px;\n line-height: 18px;\n cursor: pointer;\n}\n.dsh-rewind-popover-primary {\n background: var(--dsw-alias-accent, var(--dsw-accent, #5b8cff));\n color: var(--dsw-alias-on-accent, #fff);\n}\n.dsh-rewind-popover-primary:disabled {\n opacity: 0.5;\n cursor: default;\n}\n.dsh-rewind-popover-ghost {\n background: transparent;\n color: var(--dsw-alias-label-secondary);\n}\n.dsh-rewind-popover-ghost:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n";
@@ -0,0 +1,35 @@
1
+ /**
2
+ * dsh-rewind host half: the `/rewind` command and the write-class change
3
+ * ledger, composed as one dual-face bundle row (the browser half lives in
4
+ * `src/client/`).
5
+ *
6
+ * Rewind mechanism: planning is pure (`src/rewind.ts`); execution appends a
7
+ * marker `user/message` into the session log whose `surfaceOp` replaces every
8
+ * surface node after the target message with the marker. The append-only log
9
+ * (and the rendered transcript) is untouched — only the model-visible surface
10
+ * is cut, so the next request derives its context from the target onward.
11
+ * Mode `both` additionally reverses every ledger-recorded file change that
12
+ * followed the target.
13
+ *
14
+ * @module dsh-rewind
15
+ */
16
+ import type { Context } from '@deepseek-ai/cordis';
17
+ export declare const name = "dsh-rewind";
18
+ export declare const inject: string[];
19
+ /**
20
+ * Register the `/rewind` command and the tools-pipeline ledger hooks.
21
+ *
22
+ * The command is fs-independent and registers immediately. The ledger needs
23
+ * `fs`, so its hooks mount through a dynamic `ctx.inject(['fs'])` — they take
24
+ * effect whenever the fs service becomes available (and never fail the
25
+ * plugin's load when a deployment has no fs).
26
+ *
27
+ * Capture runs in `tools/execute` (the around-dispatch stage), NOT in
28
+ * `tools/pre-execute`: a pre-execute `{ kind: 'ask' }` short-circuit from
29
+ * another plugin (e.g. dsh-edit-approval) skips later pre-execute listeners,
30
+ * and a denied call never dispatches — so approved calls are still captured,
31
+ * denied calls never leave a pending entry behind.
32
+ *
33
+ * @param ctx - context carrying `commands`, `tools`, and an optional `fs`.
34
+ */
35
+ export declare function apply(ctx: Context): void;
@@ -0,0 +1,88 @@
1
+ /**
2
+ * In-memory change ledger: records every write-class tool mutation that
3
+ * happened while the plugin was running, so a "rewind conversation and code"
4
+ * can reverse the changes that followed a target message.
5
+ *
6
+ * Scope (v0.1): the ledger covers only `write` / `edit` / `str_replace_editor`
7
+ * mutations observed through the tools pipeline while the plugin is loaded.
8
+ * Changes made by bash or external programs are not recorded and cannot be
9
+ * restored; a git-first snapshot layer is a v2 option.
10
+ *
11
+ * @module dsh-rewind/ledger
12
+ */
13
+ import type { FileSystem } from '@deepseek-ai/dsh-fs';
14
+ /** One recorded write-class mutation. */
15
+ export interface LedgerEntry {
16
+ /** Tool that made the change: `write` | `edit` | `str_replace_editor`. */
17
+ readonly toolName: string;
18
+ /** Seq of the user message anchoring the turn in which the change happened. */
19
+ readonly anchorSeq: number;
20
+ /** Display path (model/UI-facing), as resolved at record time. */
21
+ readonly path: string;
22
+ /** Full file content before the change; undefined when the file was created. */
23
+ readonly before: string | undefined;
24
+ /** Full file content after the change. */
25
+ readonly after: string;
26
+ }
27
+ /** Unique per-file impact of rewinding past a target message. */
28
+ export interface FileImpact {
29
+ readonly path: string;
30
+ /** `restore` = the file existed before the target; `delete` = created after it. */
31
+ readonly action: 'restore' | 'delete';
32
+ }
33
+ /** Result of one reverse restore pass. */
34
+ export interface RestoreOutcome {
35
+ readonly restored: readonly string[];
36
+ readonly deleted: readonly string[];
37
+ readonly failed: readonly {
38
+ path: string;
39
+ message: string;
40
+ }[];
41
+ }
42
+ /** Deletes one file by its process path (the host supplies the backend-appropriate delete). */
43
+ export type DeleteFile = (processPath: string) => Promise<void>;
44
+ /**
45
+ * Per-session cap on recorded entries. The ledger is intentionally bounded so
46
+ * an extremely long session cannot grow one entry list without limit; the
47
+ * oldest entries are dropped first, so rewinds to very early messages in a
48
+ * pathological session may lose the earliest file history (a declared
49
+ * tradeoff, see README).
50
+ */
51
+ export declare const MAX_LEDGER_ENTRIES = 2000;
52
+ /**
53
+ * Append-only change ledger. Entries are recorded in commit order; a rewind
54
+ * replays them in reverse for the affected range. Bounded per session to
55
+ * {@link MAX_LEDGER_ENTRIES} (oldest dropped first).
56
+ */
57
+ export declare class RewindLedger {
58
+ private readonly entries;
59
+ /** Record one committed mutation, dropping the oldest entry when over the cap. */
60
+ record(entry: LedgerEntry): void;
61
+ /**
62
+ * All entries anchored at or after `targetSeq`, newest first. The boundary
63
+ * is inclusive: rewinding to a message also reverts the changes its own
64
+ * turn caused (the rewind cut removes that turn's assistant response and
65
+ * tool calls), so only changes anchored at earlier messages survive.
66
+ */
67
+ changesAfter(targetSeq: number): readonly LedgerEntry[];
68
+ /**
69
+ * Unique per-file impact for preview. A file whose earliest affected change
70
+ * created it (`before === undefined`) is deleted on restore; any other file
71
+ * is written back to its pre-target content.
72
+ */
73
+ impactsAfter(targetSeq: number): readonly FileImpact[];
74
+ /**
75
+ * Reverse every change anchored at or after `targetSeq`. Each entry writes
76
+ * its pre-change content back; a file that did not exist before the target
77
+ * is deleted instead. Failures are collected per file and never abort the pass.
78
+ * @param fs - the filesystem service (resolve/readText/writeText/processPath).
79
+ * @param deleteFile - backend-appropriate file deletion by process path.
80
+ * @param targetSeq - the rewind target; only later changes are reverted.
81
+ * @param options - session workspace cwd (relative ledger paths resolve
82
+ * against it, mirroring the fs tools) and an optional abort signal.
83
+ */
84
+ restoreAfter(fs: FileSystem, deleteFile: DeleteFile, targetSeq: number, options?: {
85
+ cwd?: string;
86
+ signal?: AbortSignal;
87
+ }): Promise<RestoreOutcome>;
88
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Pure rewind planning: target resolution and surface-range computation.
3
+ * No I/O and no `Session` dependency — everything derives from the event log
4
+ * and the ordered surface, so this module stays unit-testable.
5
+ *
6
+ * Rewind semantics (see README): rewinding to a user message appends a marker
7
+ * node into the session log whose `surfaceOp` replaces every surface node
8
+ * AFTER the target with itself. The log (the audit trail and the rendered
9
+ * transcript) is untouched; only the model-visible surface is cut, so the
10
+ * next request derives its context from the target message onward.
11
+ *
12
+ * @module dsh-rewind/rewind
13
+ */
14
+ import type { SessionEvent, UserMessage } from '@deepseek-ai/dsh-session';
15
+ /** Which of the two rewind modes a rewind executes. */
16
+ export type RewindMode = 'chat' | 'both';
17
+ /**
18
+ * Parse-level rewind target. The command line accepts both forms:
19
+ * - `@<seq>` — an absolute log seq (what the UI button always sends);
20
+ * - `<index>` — a 1-based recency index into the listed candidates
21
+ * (1 = most recent user message; the step-by-step command flow uses this).
22
+ */
23
+ export type RewindTarget = {
24
+ kind: 'seq';
25
+ seq: number;
26
+ } | {
27
+ kind: 'index';
28
+ index: number;
29
+ };
30
+ /** Expected failure codes; each maps to a concise human outcome. */
31
+ export type RewindErrorCode = 'no-user-messages' | 'invalid-index' | 'not-a-user-message' | 'not-on-surface';
32
+ /** A typed rewind failure. The host renders `code` into user-facing copy. */
33
+ export declare class RewindError extends Error {
34
+ readonly code: RewindErrorCode;
35
+ constructor(code: RewindErrorCode, message: string);
36
+ }
37
+ /** One selectable rewind candidate: a user message currently on the surface. */
38
+ export interface RewindCandidate {
39
+ /** Absolute log seq of the `user/message` event. */
40
+ readonly seq: number;
41
+ /** Unix epoch ms of the event. */
42
+ readonly time: number;
43
+ /** Truncated plain-text preview of the message content. */
44
+ readonly preview: string;
45
+ /** 1-based recency index in the candidate list (1 = most recent). */
46
+ readonly index: number;
47
+ }
48
+ /** A validated rewind: the target plus the exact surface range to shadow. */
49
+ export interface RewindPlan {
50
+ /** Target user message seq (stays on the surface). */
51
+ readonly targetSeq: number;
52
+ /** The target's ordered position in the surface. */
53
+ readonly targetIndex: number;
54
+ /** Ordered surface node seqs the rewind shadows (everything after the target). */
55
+ readonly shadowedSeqs: readonly number[];
56
+ /** First surface node after the target — the replace range start (inclusive). */
57
+ readonly surfaceStart: number;
58
+ /** Last surface node — the replace range end (inclusive). */
59
+ readonly surfaceEnd: number;
60
+ }
61
+ /** Preview length cap for candidate listings. */
62
+ export declare const CANDIDATE_PREVIEW_CHARS = 80;
63
+ /** Narrow an event to a user message. */
64
+ export declare function isUserMessageEvent(event: SessionEvent): event is SessionEvent<'user/message'>;
65
+ /** Join the text blocks of a message into one plain string. */
66
+ export declare function messagePreview(message: UserMessage): string;
67
+ /**
68
+ * Parse a raw command token into a rewind target.
69
+ * @param raw - one token: `@123` (absolute seq) or `12` (recency index).
70
+ * @returns the parsed target, or undefined when the token is malformed.
71
+ */
72
+ export declare function parseRewindTarget(raw: string): RewindTarget | undefined;
73
+ /**
74
+ * List the selectable rewind candidates: user messages currently on the
75
+ * surface, most recent first. Shadowed (compacted-away) user messages are
76
+ * intentionally excluded — the rewind boundary cannot be placed where the
77
+ * model context no longer reaches.
78
+ * @param events - the full session event log.
79
+ * @param surface - the ordered surface node seqs (`session.surface.nodes`).
80
+ * @param limit - maximum number of candidates to return.
81
+ * @returns candidates numbered 1..N by recency.
82
+ */
83
+ export declare function listRewindCandidates(events: readonly SessionEvent[], surface: readonly number[], limit?: number): RewindCandidate[];
84
+ /**
85
+ * Resolve a target against the session log and surface into a validated plan.
86
+ * @param events - the full session event log.
87
+ * @param surface - the ordered surface node seqs.
88
+ * @param target - the parsed target.
89
+ * @returns the validated rewind plan.
90
+ * @throws {RewindError} with a typed code when the target is unusable.
91
+ */
92
+ export declare function planRewind(events: readonly SessionEvent[], surface: readonly number[], target: RewindTarget): RewindPlan;
93
+ /** Human rendering of a candidate list line (`/rewind` step 1). */
94
+ export declare function formatCandidate(candidate: RewindCandidate): string;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Session-cwd resolution for ledger reads/restores, mirroring the fs tools'
3
+ * own rule (`@deepseek-ai/dsh-tool-fs/session-cwd.ts`): relative paths
4
+ * resolve against the calling agent's session workspace
5
+ * (`exec.agent.session.header.cwd`), not the server's launch dir.
6
+ *
7
+ * Pure functions: the only runtime dependency is `canonicalPath`, applied
8
+ * when either the cwd or the requested path contains a parent traversal so a
9
+ * symlinked cwd's filesystem identity stays observable — identical to the
10
+ * tool boundary behavior.
11
+ *
12
+ * @module dsh-rewind/session-cwd
13
+ */
14
+ import type { ToolExecution } from '@deepseek-ai/dsh-tools';
15
+ /**
16
+ * The session workspace cwd to resolve `requestedPath` against, or undefined
17
+ * when no session cwd applies (the filesystem backend then uses its own
18
+ * default base).
19
+ * @param cwd - the session's `header.cwd`, if any.
20
+ * @param requestedPath - the path the provider will resolve.
21
+ * @returns the cwd, canonicalized when traversal could expose a symlink.
22
+ */
23
+ export declare function sessionCwd(cwd: string | undefined, requestedPath: string): string | undefined;
24
+ /**
25
+ * Session cwd for one tool execution (same rule as the fs tools).
26
+ * @param exec - the tool-execution context; only its optional `agent` is read.
27
+ * @param requestedPath - the path the provider will resolve.
28
+ */
29
+ export declare function execSessionCwd(exec: ToolExecution, requestedPath: string): string | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-rewind-plugin",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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",
@@ -22,9 +22,17 @@
22
22
  },
23
23
  "type": "module",
24
24
  "main": "lib/index.js",
25
+ "types": "lib/types/index.d.ts",
25
26
  "exports": {
26
- ".": "./lib/index.js",
27
- "./client": "./lib/client.js",
27
+ ".": {
28
+ "types": "./lib/types/index.d.ts",
29
+ "default": "./lib/index.js"
30
+ },
31
+ "./client": {
32
+ "types": "./lib/types/client/index.d.ts",
33
+ "default": "./lib/client.js"
34
+ },
35
+ "./src/*": "./src/*",
28
36
  "./package.json": "./package.json"
29
37
  },
30
38
  "files": [
package/scripts/build.mjs CHANGED
@@ -18,6 +18,7 @@
18
18
  * parse, and the host half must import with the expected plugin shape.
19
19
  */
20
20
  import { build } from 'esbuild'
21
+ import { execSync } from 'node:child_process'
21
22
  import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
22
23
  import { dirname, join } from 'node:path'
23
24
  import { fileURLToPath } from 'node:url'
@@ -27,6 +28,10 @@ const pkg = JSON.parse(await readFile(join(ROOT, 'package.json'), 'utf8'))
27
28
 
28
29
  await mkdir(join(ROOT, 'lib'), { recursive: true })
29
30
 
31
+ // ---- type declarations: host (lib/types) + client (lib/types/client) ----
32
+ execSync('npx tsc -p tsconfig.build.json', { cwd: ROOT, stdio: 'inherit' })
33
+ execSync('npx tsc -p tsconfig.client.json', { cwd: ROOT, stdio: 'inherit' })
34
+
30
35
  // ---- host half: bundled TS -> ESM (@deepseek-ai/* stays external) ----
31
36
  await build({
32
37
  entryPoints: [join(ROOT, 'src', 'index.ts')],
@@ -77,4 +82,8 @@ for (const needle of ['name', 'inject', 'apply']) {
77
82
  for (const needle of ['window.__ModuleLoader__.load', `id: ${JSON.stringify(pkg.name)}`]) {
78
83
  if (!bundle.includes(needle)) throw new Error(`client bundle missing ${needle}`)
79
84
  }
80
- console.log('build ok: lib/index.js (host), lib/client.js (client)')
85
+ // Declarations must exist (published tarball carries them).
86
+ for (const dts of ['lib/types/index.d.ts', 'lib/types/client/index.d.ts']) {
87
+ await readFile(join(ROOT, dts), 'utf8')
88
+ }
89
+ console.log('build ok: lib/index.js (host), lib/client.js (client), lib/types/ (declarations)')