@davideasden/pi-undo 0.2.24 → 0.2.26

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,20 @@ recovery_required
138
161
 
139
162
  ### Performance Notes
140
163
 
164
+ 普通文件原生恢复路径保留 5 次完整拓扑扫描。macOS/Linux 的新版 helper 使用不跟随符号链接的目录句柄扫描,仍会发现 Git 忽略目录中的嵌套仓库。小工作区根据上次扫描规模选择 TypeScript 完整重扫,避免进程启动开销;没有使用 TTL 缓存或默认跳过 `node_modules`。
165
+
166
+ 原生恢复现支持已有父目录中的普通文件删除,以及覆盖、创建、删除混合计划。扩展能力通过 `restore-files-v2` 探测;旧 helper、缺少二进制、目录操作及超出句柄预算的计划继续走 TypeScript 路径。pack 持久化、内容校验、no-clobber 与恢复依据均保留。
167
+
168
+ 本机 macOS arm64 的三次 undo 中位数:3,000 包/1 文件从 1,976 ms 降至 924 ms,10,000 包/1 文件从 5,857 ms 降至 2,755 ms;100 文件的子目录撤销从 517 ms 降至 180 ms,混合撤销从 711 ms 降至 186 ms。测量方法和限制见 [原生性能验证记录](docs/native-performance.md)。
169
+
170
+ 可通过真实 Pi 0.86.1 SDK 和离线 faux provider 运行大型工作区基准;它覆盖 3,000/10,000 个依赖包、修改 1/100 个文件,每组撤回三次并验证重做:
171
+
172
+ ```bash
173
+ PI_UNDO_LARGE_WORKSPACE=1 npx vitest run test/large-workspace.test.ts
174
+ ```
175
+
176
+ 输出包含扫描次数、每次耗时、进程峰值 RSS 和事件循环延迟;常规测试以扫描次数作为性能门槛,避免使用易受机器负载影响的墙钟阈值。
177
+
141
178
  Real-world measurements from a 104-file undo operation before optimizations:
142
179
 
143
180
  ```text
@@ -0,0 +1,52 @@
1
+ # 原生目录扫描与普通文件恢复性能验证
2
+
3
+ 这批优化基于 `d7d80230`,保留 undo 的五个拓扑检查点,降低完整目录扫描成本,并扩大已有父目录中的普通文件原生恢复范围。
4
+
5
+ ## 实现与边界
6
+
7
+ - `scan-directories-v1` 在 macOS/Linux 上通过 `openat(O_DIRECTORY | O_NOFOLLOW)` 逐级打开目录,以目录流识别 Git 标记候选。扫描 ignored 目录,不进入名称恰为 `.git` 的元数据目录,不跟随符号链接;标记大小写、损坏仓库、失效 worktree 指针、Git index 与 submodule 仍由现有 TypeScript/Git 逻辑解释。
8
+ - 每次重新扫描。上次目录数量仅选择执行器:小于 64 个目录时使用 TypeScript 完整遍历;增长后可重新进入原生扫描。没有缓存拓扑结果,也没有省略任何检查点。
9
+ - 目录句柄、父目录绑定与扫描前后目录时间戳必须一致。扫描时并发增删目录项会使原生扫描失败,防止静默接受不完整结果;它不提供跨整个工作区的原子快照。能力确认后的路径错误向上传递。超过 128 层时,helper 明确返回 `depth_limit`,丢弃部分结果,由 TypeScript 从根重扫。
10
+ - 原生扫描使用操作剩余预算,并遵守 `PI_UNDO_OPERATION_TIMEOUT_MS`(默认 300 秒)。能力探测只在私有目录运行,缺失能力时回退;取消、超时及无法确认子进程终止均保留现有错误处理契约。探测取消不会永久污染能力缓存。
11
+ - `restore-files-v2` 支持普通文件覆盖、创建、删除的混合计划,以及子目录中的文件删除。父目录必须已经存在。读取、校验、no-clobber 安装及隔离均绑定同一组父目录句柄;最多保留 128 个非根父目录句柄。目录创建/删除、类型替换、过多父目录与不支持的平台继续使用 TypeScript。
12
+ - durable pack 格式与恢复协议保持不变。仍在修改前发布并持久化 pack,逐项校验内容和 mode,保留同 inode 的 ownership artifacts。失败时由现有 packed recovery 回滚;外来冲突文件不会被当成本次产物删除。
13
+
14
+ ## 实测结果
15
+
16
+ 环境为本机 macOS arm64、Node 23.11.1、真实 Pi 0.86.1 SDK 与离线 faux provider。每个场景执行三次 undo,中间执行 redo 并等待后台恢复/持久化清理结束;校验文件内容、撤回/重做历史状态和扫描次数。表内为中位数,单位毫秒。基线与优化版本在同一机器先后执行,未施加墙钟性能断言。
17
+
18
+ | 场景 | 优化前 | 优化后 | 耗时降低 |
19
+ | --- | ---: | ---: | ---: |
20
+ | 无依赖目录,修改 1 文件 | 146 | 148 | 约持平 |
21
+ | 3,000 包,修改 1 文件 | 1,976 | 924 | 53% |
22
+ | 10,000 包,修改 1 文件 | 5,857 | 2,755 | 53% |
23
+ | 100 文件全部覆盖 | 179 | 182 | 约持平 |
24
+ | 100 文件在根目录新增后撤销 | 177 | 174 | 约持平 |
25
+ | 100 文件在已有子目录新增后撤销 | 517 | 180 | 65% |
26
+ | 50 文件覆盖、50 文件新增后混合撤销 | 711 | 186 | 74% |
27
+
28
+ 分阶段计时中,3,000 包场景五次扫描合计从 1,759 ms 降至 716 ms;10,000 包从 5,470 ms 降至 2,398 ms。全部测量仍为五次扫描。新增目录的撤销测试保留父目录中的 `keep.txt`,因此测量的是文件删除,不含目录删除。混合与子目录场景原先未进入 native,优化后三次均调用原生恢复一次。
29
+
30
+ 另一个既有大型工作区基准覆盖 3,000/10,000 包 × 1/100 文件,四组均通过内容、redo 和 `[5, 5, 5]` 扫描次数断言。该次单独运行的中位数依次为 878、923、2,582、2,673 ms;与分阶段诊断是不同批次,不把两批耗时混作同一组数据。
31
+
32
+ 这些是三次采样的本机结果,主要证明已观察到的热路径得到改善。磁盘、目录结构、并发写入和机器负载会影响绝对耗时;没有从这些结果推断其他操作系统上的收益。
33
+
34
+ ## 复现与验证
35
+
36
+ 最终验证通过:默认原生路径 617 项、强制 TypeScript 回退 586 项、Rust 单测 8 项;TypeScript 类型检查、四组大型工作区基准及 npm 打包内容检查通过。跳过项为不适用平台/能力及按需基准,不计入通过数。
37
+
38
+ ```bash
39
+ npm run build:native
40
+ cp native/pi-undo-fs/target/release/pi-undo-fs native/bin/pi-undo-fs-darwin-arm64
41
+ npm run test:native
42
+ npm test
43
+ PI_UNDO_DISABLE_NATIVE=1 npm test
44
+ npm run typecheck
45
+ PI_UNDO_LARGE_WORKSPACE=1 npx vitest run test/large-workspace.test.ts
46
+ ```
47
+
48
+ 上面的二进制复制路径用于本次 macOS arm64 环境;其他平台须使用对应构建产物。此批已更新仓库现有的 macOS arm64 二进制。Linux、其他架构和 Windows 的源码构建/运行不在本机验证范围内;缺少二进制或扩展能力时会回退,发布时仍需现有六平台构建流程提供对应产物。
49
+
50
+ 补充测试覆盖:原生/TypeScript 拓扑一致性、ignored 嵌套仓库、大小写不同的 Git 标记、符号链接及目录删除/替换、深度回退、取消后重新探测、超时、混合计划回滚/前滚、外来文件冲突、旧 helper 门控,以及 128/129 个父目录的边界。
51
+
52
+ 独立审查运行器异常退出,正式审查结果未能落盘。已保留失败运行的状态与补丁证据,并人工核对日志中的具体意见;这不计为独立审查通过。已修复候选错误类型与扫描预算,补充句柄边界测试。旧 helper 在混合计划中仍可能先准备 pack 再回退,存在额外准备开销;兼容性分类与平台发布覆盖也仍需后续维护。
@@ -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;
Binary file
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.26",
4
4
  "description": "Persistent workspace undo and redo for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -34,27 +34,29 @@
34
34
  "extensions",
35
35
  "src",
36
36
  "native/bin",
37
- "README.md"
37
+ "README.md",
38
+ "docs/native-performance.md"
38
39
  ],
39
40
  "scripts": {
40
41
  "test": "vitest run",
41
42
  "test:watch": "vitest",
42
- "test:integration": "vitest run test/extension.integration.test.ts test/pi-runtime.test.ts",
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",
43
+ "test:integration": "vitest run test/extension.integration.test.ts test/pi-runtime.test.ts test/pi-sdk.integration.test.ts",
44
+ "test:native": "cargo test --manifest-path native/pi-undo-fs/Cargo.toml && vitest run test/native-restore.test.ts test/native-directory-scan.test.ts test/root-discovery.test.ts test/durable-pack.test.ts test/packed-recovery.test.ts",
44
45
  "build:native": "cargo build --release --manifest-path native/pi-undo-fs/Cargo.toml",
45
46
  "typecheck": "tsc --noEmit",
46
47
  "pack:dry-run": "npm pack --dry-run"
47
48
  },
48
49
  "peerDependencies": {
49
- "@earendil-works/pi-coding-agent": ">=0.80.10",
50
- "@earendil-works/pi-tui": "*"
50
+ "@earendil-works/pi-coding-agent": ">=0.86.1",
51
+ "@earendil-works/pi-tui": ">=0.86.1"
51
52
  },
52
53
  "dependencies": {
53
54
  "proper-lockfile": "4.1.2"
54
55
  },
55
56
  "devDependencies": {
56
- "@earendil-works/pi-coding-agent": "0.84.4",
57
- "@earendil-works/pi-tui": "0.84.4",
57
+ "@earendil-works/pi-ai": "0.86.1",
58
+ "@earendil-works/pi-coding-agent": "0.86.1",
59
+ "@earendil-works/pi-tui": "0.86.1",
58
60
  "@types/node": "24.12.4",
59
61
  "@types/proper-lockfile": "4.1.4",
60
62
  "typescript": "5.9.3",