@davideasden/pi-undo 0.2.24 → 0.2.25

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
@@ -19,7 +19,7 @@ Each completed agent run creates a checkpoint that captures both the Pi session
19
19
 
20
20
  ## Requirements
21
21
 
22
- - Pi `0.80.10` 或更高版本(已在 0.84.4 上验证)。
22
+ - Pi `0.86.1` 或更高版本(已在 0.86.1 上验证)。`0.84.4` 及更早版本不再受支持,也不在 CI 中测试:早期版本的会话树与扩展生命周期行为不同,不保证撤回语义正确。
23
23
  - Node.js `22.19.0` or later.
24
24
  - Git available on `PATH` (used internally for content-addressed snapshots).
25
25
 
@@ -104,6 +104,29 @@ Returns to the previous completed run on the current branch. Before restoring, i
104
104
 
105
105
  After a successful undo, text entered during the operation is replayed into the editor. RPC mode reports the refill request; print and JSON modes do not replay prompts.
106
106
 
107
+ 撤回会等待当前轮次的输入快照和检查点完成,再选择撤回目标;Pi 已空闲但检查点仍在生成时,不会提前撤回上一轮。检查点生成失败会明确暂停历史。
108
+
109
+ ### 取消与超时
110
+
111
+ 执行较慢时,状态栏会显示当前阶段、经过的秒数和操作 ID。可以输入:
112
+
113
+ ```text
114
+ /undo-cancel
115
+ ```
116
+
117
+ 此命令请求安全停止正在执行的撤回或重做。已开始的文件写入会先结束,未提交的事务随后按日志恢复。已经持久提交的操作会继续完成清理。取消或超时分别报告 `operation_cancelled`、`operation_timeout`;无法确认子进程停止或恢复一致性时,保留隔离并报告 `recovery_required`。
118
+
119
+ 默认单次 Git 调用预算为 120 秒,每次撤回、捕获及恢复预算为 300 秒。可在启动 Pi 前用正整数毫秒调整:
120
+
121
+ ```bash
122
+ export PI_UNDO_GIT_TIMEOUT_MS=180000
123
+ export PI_UNDO_OPERATION_TIMEOUT_MS=600000
124
+ ```
125
+
126
+ 补偿使用独立预算,避免继承前向操作的取消信号。磁盘 I/O 若无法中断,状态会保留到在途任务结束;超时不表示可以立即释放仍在写入的工作区锁。
127
+
128
+ 长操作或失败会在 `<sessionDir>/.pi-undo/diagnostics/<sessionId>-latest.json` 保存最近一次诊断,包含操作 ID、阶段、耗时、Git 子命令名和退出结果,不包含提示词、文件内容、完整参数或子进程输出。重新运行 `/undo-recover`、切换会话或退出时,会先等待旧 runtime 的任务和后台持久化结束。
129
+
107
130
  ### Redo
108
131
 
109
132
  ```text
@@ -138,6 +161,16 @@ recovery_required
138
161
 
139
162
  ### Performance Notes
140
163
 
164
+ 本次优化将普通文件原生恢复路径的完整拓扑扫描从 7 次减到 5 次,并以有界并发枚举目录。仍会发现 Git 忽略目录中的嵌套仓库,保留路径枚举后与文件恢复后的拓扑复核。没有使用 TTL 缓存或默认跳过 `node_modules`。
165
+
166
+ 可通过真实 Pi 0.86.1 SDK 和离线 faux provider 运行大型工作区基准;它覆盖 3,000/10,000 个依赖包、修改 1/100 个文件,每组撤回三次并验证重做:
167
+
168
+ ```bash
169
+ PI_UNDO_LARGE_WORKSPACE=1 npx vitest run test/large-workspace.test.ts
170
+ ```
171
+
172
+ 输出包含扫描次数、每次耗时、进程峰值 RSS 和事件循环延迟;常规测试以扫描次数作为性能门槛,避免使用易受机器负载影响的墙钟阈值。
173
+
141
174
  Real-world measurements from a 104-file undo operation before optimizations:
142
175
 
143
176
  ```text
@@ -9,7 +9,7 @@ import type {
9
9
  SessionTreeEvent as PiSessionTreeEvent,
10
10
  } from "@earendil-works/pi-coding-agent";
11
11
 
12
- import type { OperationResult, UndoController } from "../src/controller.ts";
12
+ import type { OperationResult, SessionTreeEvent, UndoController } from "../src/controller.ts";
13
13
  import { browseDiff } from "../src/diff-ui.ts";
14
14
  import { computeCheckpointDiff, type DiffSource, formatDiffSummary, sanitizeDisplayText } from "../src/diff-view.ts";
15
15
  import { createPiUndoRuntime } from "../src/pi-runtime.ts";
@@ -22,6 +22,8 @@ export interface PiUndoRuntime {
22
22
  readonly recovery?: { readonly reason?: string; readonly files?: number; readonly opId?: string };
23
23
  setCommandContext?(context: ExtensionCommandContext | undefined): void;
24
24
  isInternalNavigation?(): boolean;
25
+ normalizeTreeEvent?(event: PiSessionTreeEvent): SessionTreeEvent;
26
+ dispose?(): Promise<void>;
25
27
  }
26
28
 
27
29
  export type PiUndoRuntimeFactory = (
@@ -55,9 +57,24 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
55
57
  let activeAction: "undo" | "redo" | undefined;
56
58
  let captureFailureNotified = false;
57
59
  let recoveryHintNotified = false;
60
+ let clearTreeWatch: (() => void) | undefined;
61
+ let initializing = false;
58
62
 
59
63
  const initialize = async (context: ExtensionContext): Promise<void> => {
64
+ if (initializing) return;
65
+ initializing = true;
66
+ try {
67
+ clearTreeWatch?.();
68
+ if (runtime?.dispose !== undefined) await runtime.dispose();
69
+ else await runtime?.controller.dispose?.();
70
+ } catch (error) {
71
+ initializing = false;
72
+ runtime?.reporter.setRecoveryRequired(errorMessage(error));
73
+ context.ui.notify(`旧任务尚未安全结束:${errorMessage(error)}`, "error");
74
+ return;
75
+ }
60
76
  const currentGeneration = ++generation;
77
+ restoreDeferredPrompts(context);
61
78
  runtimeContext = context;
62
79
  deferredPrompts = [];
63
80
  replaying = undefined;
@@ -82,19 +99,21 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
82
99
  if (currentGeneration !== generation) return;
83
100
  runtime = undefined;
84
101
  new StatusReporter(context).setRecoveryRequired(errorMessage(error));
102
+ } finally {
103
+ initializing = false;
85
104
  }
86
105
  };
87
106
 
88
107
  const dispatchDeferredPrompt = (active: PiUndoRuntime, expectedGeneration: number): void => {
89
108
  if (
90
- expectedGeneration !== generation || runtime !== active || activeCommands.size > 0 ||
109
+ initializing || expectedGeneration !== generation || runtime !== active || activeCommands.size > 0 ||
91
110
  replaying !== undefined || deferredPrompts.length === 0 || active.controller.history().locked
92
111
  ) return;
93
112
  const prompt = deferredPrompts[0]!;
94
113
  replaying = prompt;
95
114
  acceptedReplay = undefined;
96
115
  queueMicrotask(() => {
97
- if (expectedGeneration !== generation || runtime !== active || replaying !== prompt) return;
116
+ if (initializing || expectedGeneration !== generation || runtime !== active || replaying !== prompt) return;
98
117
  try {
99
118
  pi.sendUserMessage(prompt.images === undefined || prompt.images.length === 0
100
119
  ? prompt.text
@@ -144,7 +163,7 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
144
163
  ): Promise<void> => {
145
164
  const active = runtime;
146
165
  const commandGeneration = generation;
147
- if (active === undefined) {
166
+ if (active === undefined || initializing) {
148
167
  context.ui.notify("pi-undo session unavailable", "warning");
149
168
  return;
150
169
  }
@@ -153,8 +172,8 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
153
172
  // 只有当前执行命令能安装/清理导航上下文;busy 的第二个命令不得覆盖或清空它。
154
173
  const ownsCommandContext = commandSet.size === 0;
155
174
  commandSet.add(commandToken);
156
- activeAction = action;
157
- active.reporter.setPhase(action === "undo" ? "undoing" : "redoing");
175
+ if (ownsCommandContext) activeAction = action;
176
+ if (ownsCommandContext) active.reporter.setPhase(action === "undo" ? "undoing" : "redoing");
158
177
  if (ownsCommandContext) active.setCommandContext?.(context);
159
178
  const commandStarted = performance.now();
160
179
  let result: OperationResult;
@@ -176,6 +195,10 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
176
195
  message: active.controller.recoveryReason?.() ?? "pending journal",
177
196
  };
178
197
  }
198
+ if (!ownsCommandContext) {
199
+ context.ui.notify(`${result.code} files:${result.changedFiles}`, "warning");
200
+ return;
201
+ }
179
202
  active.reporter.result(result, performance.now() - commandStarted);
180
203
  if (result.code === "recovery_required" && !recoveryHintNotified) {
181
204
  recoveryHintNotified = true;
@@ -240,6 +263,13 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
240
263
  description: "Review files changed by an Agent run (latest, or /diff N)",
241
264
  handler: async (args: string, context: ExtensionCommandContext) => runDiff(args, context),
242
265
  });
266
+ pi.registerCommand("undo-cancel", {
267
+ description: "安全停止正在执行的撤回或重做",
268
+ handler: async (_args: string, context: ExtensionCommandContext) => {
269
+ const requested = runtime?.controller.cancelOperation?.() ?? false;
270
+ context.ui.notify(requested ? "已请求停止,正在等待写入结束并恢复一致状态" : "当前没有可取消的撤回操作", "info");
271
+ },
272
+ });
243
273
  pi.registerCommand("undo-recover", {
244
274
  description: "Re-run pi-undo recovery and refresh undo history",
245
275
  handler: async (_args: string, context: ExtensionCommandContext) => {
@@ -266,6 +296,10 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
266
296
  pi.on("session_start", async (_event: unknown, context: ExtensionContext) => initialize(context));
267
297
  pi.on("input", async (event: InputEvent, context: ExtensionContext) => {
268
298
  const active = runtime;
299
+ if (initializing) {
300
+ restoreEditorText(context, event.text);
301
+ return { action: "handled" as const };
302
+ }
269
303
  if (active === undefined) return { action: "continue" as const };
270
304
  const inputContext = { streaming: event.streamingBehavior !== undefined };
271
305
  const result = active.controller.beginInput !== undefined
@@ -354,29 +388,52 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
354
388
  if (active === undefined) return;
355
389
  await active.controller.agentSettled();
356
390
  if (runtime !== active || generation !== settledGeneration) return;
357
- resumeDeferredPrompts(
391
+ if (activeCommands.size === 0) resumeDeferredPrompts(
358
392
  active,
359
393
  settledGeneration,
360
394
  active.controller.recoveryReason?.() ?? "session state ambiguous",
361
395
  );
362
396
  });
363
- pi.on("session_before_tree", async (event: PiSessionBeforeTreeEvent) => {
364
- if (runtime === undefined) return { cancel: true };
397
+ pi.on("session_before_tree", async (event: PiSessionBeforeTreeEvent, context: ExtensionContext) => {
398
+ if (runtime === undefined || initializing) return { cancel: true };
365
399
  if (runtime.isInternalNavigation?.()) return undefined;
366
400
  const active = runtime;
367
- const result = await active.controller.beforeTree({ targetLeafId: event.preparation.targetId });
368
- if (result === undefined) {
369
- event.signal?.addEventListener("abort", () => { void active.controller.cancelTree?.(); }, { once: true });
401
+ if (event.signal?.aborted) return { cancel: true };
402
+ clearTreeWatch?.();
403
+ let timer: ReturnType<typeof setInterval> | undefined;
404
+ const clear = (): void => {
405
+ if (timer !== undefined) clearInterval(timer);
406
+ event.signal?.removeEventListener("abort", cancel);
407
+ if (clearTreeWatch === clear) clearTreeWatch = undefined;
408
+ };
409
+ const cancel = (): void => { void active.controller.cancelTree?.().catch(() => {}); };
410
+ clearTreeWatch = clear;
411
+ event.signal?.addEventListener("abort", cancel, { once: true });
412
+ const result = await active.controller.beforeTree({ targetLeafId: event.preparation.targetId, signal: event.signal });
413
+ if (result !== undefined || event.signal?.aborted) {
414
+ await active.controller.cancelTree?.();
415
+ clear();
416
+ return { cancel: true };
370
417
  }
371
- return result;
418
+ // 摘要错误或其他扩展取消可能没有 session_tree;Pi 离开导航后才终结准备事务。
419
+ timer = setInterval(() => {
420
+ if (!context.isIdle()) return;
421
+ clear();
422
+ void active.controller.cancelTree?.().then(() => {
423
+ resumeDeferredPrompts(active, generation, "tree_navigation_incomplete");
424
+ });
425
+ }, 100);
426
+ timer.unref();
427
+ return undefined;
372
428
  });
373
429
  pi.on("session_tree", async (event: PiSessionTreeEvent) => {
374
430
  const active = runtime;
375
431
  if (active?.isInternalNavigation?.()) return;
376
432
  const treeGeneration = generation;
377
- await active?.controller.afterTree({
433
+ clearTreeWatch?.();
434
+ await active?.controller.afterTree(active.normalizeTreeEvent?.(event) ?? {
378
435
  newLeafId: event.newLeafId,
379
- navigationTargetLeafId: event.summaryEntry?.parentId ?? event.newLeafId,
436
+ navigationTargetLeafId: event.summaryEntry === undefined ? event.newLeafId : event.summaryEntry.parentId,
380
437
  });
381
438
  if (active !== undefined && runtime === active && generation === treeGeneration) {
382
439
  resumeDeferredPrompts(
@@ -387,6 +444,7 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
387
444
  }
388
445
  });
389
446
  pi.on("session_shutdown", async () => {
447
+ clearTreeWatch?.();
390
448
  generation += 1;
391
449
  deferredPrompts = [];
392
450
  replaying = undefined;
@@ -394,6 +452,8 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
394
452
  activeCommands = new Set<symbol>();
395
453
  activeAction = undefined;
396
454
  runtimeContext = undefined;
455
+ if (runtime?.dispose !== undefined) await runtime.dispose();
456
+ else await runtime?.controller.dispose?.();
397
457
  await runtime?.controller.cancelTree?.();
398
458
  runtime?.reporter.clear();
399
459
  runtime = undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davideasden/pi-undo",
3
- "version": "0.2.24",
3
+ "version": "0.2.25",
4
4
  "description": "Persistent workspace undo and redo for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -39,22 +39,23 @@
39
39
  "scripts": {
40
40
  "test": "vitest run",
41
41
  "test:watch": "vitest",
42
- "test:integration": "vitest run test/extension.integration.test.ts test/pi-runtime.test.ts",
42
+ "test:integration": "vitest run test/extension.integration.test.ts test/pi-runtime.test.ts test/pi-sdk.integration.test.ts",
43
43
  "test:native": "cargo test --manifest-path native/pi-undo-fs/Cargo.toml && vitest run test/native-restore.test.ts test/durable-pack.test.ts test/packed-recovery.test.ts",
44
44
  "build:native": "cargo build --release --manifest-path native/pi-undo-fs/Cargo.toml",
45
45
  "typecheck": "tsc --noEmit",
46
46
  "pack:dry-run": "npm pack --dry-run"
47
47
  },
48
48
  "peerDependencies": {
49
- "@earendil-works/pi-coding-agent": ">=0.80.10",
50
- "@earendil-works/pi-tui": "*"
49
+ "@earendil-works/pi-coding-agent": ">=0.86.1",
50
+ "@earendil-works/pi-tui": ">=0.86.1"
51
51
  },
52
52
  "dependencies": {
53
53
  "proper-lockfile": "4.1.2"
54
54
  },
55
55
  "devDependencies": {
56
- "@earendil-works/pi-coding-agent": "0.84.4",
57
- "@earendil-works/pi-tui": "0.84.4",
56
+ "@earendil-works/pi-ai": "0.86.1",
57
+ "@earendil-works/pi-coding-agent": "0.86.1",
58
+ "@earendil-works/pi-tui": "0.86.1",
58
59
  "@types/node": "24.12.4",
59
60
  "@types/proper-lockfile": "4.1.4",
60
61
  "typescript": "5.9.3",