pi-rewind-hook 1.8.1 → 1.8.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/CHANGELOG.md CHANGED
@@ -4,6 +4,17 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.8.3] - 2026-04-16
8
+
9
+ ### Fixed
10
+ - `session_before_tree` now auto-keeps current files during [pi-boomerang](https://github.com/nicobailon/pi-boomerang) collapses instead of prompting with restore options.
11
+ - Headless boomerang-triggered tree collapses now preserve rewind metadata instead of skipping the keep-current-files snapshot path.
12
+
13
+ ## [1.8.2] - 2026-04-05
14
+
15
+ ### Changed
16
+ - Dropped `npx pi-rewind-hook` installer support; package install path is now `pi install npm:pi-rewind-hook`
17
+
7
18
  ## [1.8.1] - 2026-04-05
8
19
 
9
20
  ### Fixed
package/README.md CHANGED
@@ -29,8 +29,6 @@ This will:
29
29
  4. Remove legacy hooks/explicit rewind extension entries from `settings.json` if present
30
30
  5. Clean up old `hooks/rewind` directory (if present)
31
31
 
32
- You can also install with `npx pi-rewind-hook`.
33
-
34
32
  ## Configuration
35
33
 
36
34
  Optionally add settings to `~/.pi/agent/settings.json`. For example:
package/index.test.ts CHANGED
@@ -7,13 +7,15 @@ import os from "node:os";
7
7
  import path from "node:path";
8
8
  import { promisify } from "node:util";
9
9
 
10
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
11
+
10
12
  import rewindExtension from "./index.ts";
11
13
 
12
14
  const execFileAsync = promisify(execFile);
13
15
  const STORE_REF = "refs/pi-rewind/store";
14
16
 
15
17
  type RewindEntry = Record<string, unknown>;
16
- type EventHandler = (event: any, ctx: any) => Promise<any> | any;
18
+ type EventHandler = (event: unknown, ctx: unknown) => Promise<unknown> | unknown;
17
19
 
18
20
  class SessionManagerStub {
19
21
  private readonly header: { type: "session"; version: number; id: string; timestamp: string; cwd: string; parentSession?: string };
@@ -97,11 +99,14 @@ async function runGit(repoRoot: string, args: string[]): Promise<{ stdout: strin
97
99
  try {
98
100
  const { stdout, stderr } = await execFileAsync("git", args, { cwd: repoRoot });
99
101
  return { stdout, stderr, code: 0 };
100
- } catch (error: any) {
102
+ } catch (error: unknown) {
103
+ const execError = error && typeof error === "object"
104
+ ? error as Partial<{ stdout: string; stderr: string; message: string; code: number }>
105
+ : undefined;
101
106
  return {
102
- stdout: error.stdout ?? "",
103
- stderr: error.stderr ?? error.message ?? "",
104
- code: error.code ?? 1,
107
+ stdout: execError?.stdout ?? "",
108
+ stderr: execError?.stderr ?? execError?.message ?? "",
109
+ code: execError?.code ?? 1,
105
110
  };
106
111
  }
107
112
  }
@@ -161,7 +166,7 @@ async function createHarness(options: {
161
166
  await runGitChecked(repoRoot, ["config", "user.email", "rewind@example.com"]);
162
167
 
163
168
  const handlers = new Map<string, EventHandler>();
164
- const eventHandlers = new Map<string, (data: any) => void>();
169
+ const eventHandlers = new Map<string, (data: unknown) => void>();
165
170
  const execCalls: string[][] = [];
166
171
  const notifications: Array<{ message: string; level: string }> = [];
167
172
  const statusUpdates: Array<{ key: string; value: string | undefined }> = [];
@@ -200,15 +205,15 @@ async function createHarness(options: {
200
205
  handlers.set(eventName, handler);
201
206
  },
202
207
  events: {
203
- on: (eventName: string, handler: (data: any) => void) => {
208
+ on: (eventName: string, handler: (data: unknown) => void) => {
204
209
  eventHandlers.set(eventName, handler);
205
210
  },
206
211
  },
207
- } as any;
212
+ } satisfies Pick<ExtensionAPI, "exec" | "appendEntry" | "on" | "events">;
208
213
 
209
- rewindExtension(api);
214
+ rewindExtension(api as ExtensionAPI);
210
215
 
211
- function createContext(sessionManager: SessionManagerStub, hasUI = true): any {
216
+ function createContext(sessionManager: SessionManagerStub, hasUI = true) {
212
217
  return {
213
218
  cwd: repoRoot,
214
219
  hasUI,
@@ -259,7 +264,7 @@ async function createHarness(options: {
259
264
  entries: options.entries,
260
265
  });
261
266
  },
262
- async invoke(eventName: string, event: any, sessionManager = activeSession, hasUI = true) {
267
+ async invoke(eventName: string, event: unknown, sessionManager = activeSession, hasUI = true) {
263
268
  const handler = handlers.get(eventName);
264
269
  assert.ok(handler, `missing handler for ${eventName}`);
265
270
  activeSession = sessionManager;
@@ -433,6 +438,76 @@ test("session_before_tree gracefully cancels when restore fails", async () => {
433
438
  }
434
439
  });
435
440
 
441
+ test("session_before_tree auto-keeps current files during boomerang collapse", async () => {
442
+ const harness = await createHarness({
443
+ settings: { rewind: { silentCheckpoints: true } },
444
+ });
445
+
446
+ try {
447
+ await harness.writeRepoFile("notes.txt", "current state\n");
448
+
449
+ harness.currentSession.replaceEntries([
450
+ {
451
+ type: "message",
452
+ id: "user-1",
453
+ parentId: null,
454
+ timestamp: new Date().toISOString(),
455
+ message: { role: "user", content: [{ type: "text", text: "Tree target" }] },
456
+ },
457
+ ]);
458
+
459
+ await harness.invoke("session_start", {});
460
+ (globalThis as typeof globalThis & { __boomerangCollapseInProgress?: boolean }).__boomerangCollapseInProgress = true;
461
+
462
+ const result = await harness.invoke("session_before_tree", { preparation: { targetId: "user-1" } });
463
+ assert.equal(result, undefined);
464
+ assert.equal(harness.selectCalls.length, 0);
465
+
466
+ await harness.invoke("session_tree", { summaryEntry: { id: "summary-1" } });
467
+
468
+ const rewindOps = harness.currentSession.getEntries().filter((entry) => entry.type === "custom" && entry.customType === "rewind-op");
469
+ assert.equal(rewindOps.length, 1);
470
+ } finally {
471
+ delete (globalThis as typeof globalThis & { __boomerangCollapseInProgress?: boolean }).__boomerangCollapseInProgress;
472
+ await harness.cleanup();
473
+ }
474
+ });
475
+
476
+ test("session_before_tree auto-keeps current files during headless boomerang collapse", async () => {
477
+ const harness = await createHarness({
478
+ settings: { rewind: { silentCheckpoints: true } },
479
+ });
480
+
481
+ try {
482
+ await harness.writeRepoFile("notes.txt", "current state\n");
483
+
484
+ harness.currentSession.replaceEntries([
485
+ {
486
+ type: "message",
487
+ id: "user-1",
488
+ parentId: null,
489
+ timestamp: new Date().toISOString(),
490
+ message: { role: "user", content: [{ type: "text", text: "Tree target" }] },
491
+ },
492
+ ]);
493
+
494
+ await harness.invoke("session_start", {});
495
+ (globalThis as typeof globalThis & { __boomerangCollapseInProgress?: boolean }).__boomerangCollapseInProgress = true;
496
+
497
+ const result = await harness.invoke("session_before_tree", { preparation: { targetId: "user-1" } }, harness.currentSession, false);
498
+ assert.equal(result, undefined);
499
+ assert.equal(harness.selectCalls.length, 0);
500
+
501
+ await harness.invoke("session_tree", { summaryEntry: { id: "summary-1" } }, harness.currentSession, false);
502
+
503
+ const rewindOps = harness.currentSession.getEntries().filter((entry) => entry.type === "custom" && entry.customType === "rewind-op");
504
+ assert.equal(rewindOps.length, 1);
505
+ } finally {
506
+ delete (globalThis as typeof globalThis & { __boomerangCollapseInProgress?: boolean }).__boomerangCollapseInProgress;
507
+ await harness.cleanup();
508
+ }
509
+ });
510
+
436
511
  test("first mutating turn creates a reachable store ref even when retention is omitted", async () => {
437
512
  const harness = await createHarness({
438
513
  settings: { rewind: { silentCheckpoints: true } },
@@ -600,4 +675,3 @@ test("retention rewrites the keepalive ref when a live snapshot exists", async (
600
675
  await harness.cleanup();
601
676
  }
602
677
  });
603
-
package/index.ts CHANGED
@@ -1,10 +1,3 @@
1
- /**
2
- * Rewind Extension - session-ledger based exact file restoration for pi branching
3
- *
4
- * Rewind v2 stores exact rewind metadata in hidden session custom entries and keeps
5
- * snapshot commits reachable through a single repo-local store ref.
6
- */
7
-
8
1
  import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@mariozechner/pi-coding-agent";
9
2
  import { exec as execCb } from "child_process";
10
3
  import { existsSync, readFileSync, realpathSync } from "fs";
@@ -783,14 +776,19 @@ export default function rewindExtension(pi: ExtensionAPI) {
783
776
  const lines = content.split("\n").filter(Boolean);
784
777
 
785
778
  for (const line of lines) {
786
- let entry: any;
779
+ let entry: SessionLikeGenericEntry | null = null;
787
780
  try {
788
- entry = JSON.parse(line);
781
+ const parsed = JSON.parse(line);
782
+ if (parsed && typeof parsed === "object") {
783
+ entry = parsed as SessionLikeGenericEntry;
784
+ }
789
785
  } catch {
790
786
  // Ignore malformed JSONL lines; retention discovery is best-effort.
791
787
  continue;
792
788
  }
793
789
 
790
+ if (!entry) continue;
791
+
794
792
  if (entry?.type === "session") {
795
793
  ledger.sessionId = entry.id;
796
794
  ledger.cwd = entry.cwd;
@@ -1115,9 +1113,10 @@ export default function rewindExtension(pi: ExtensionAPI) {
1115
1113
  });
1116
1114
  }
1117
1115
 
1118
- pi.events.on("rewind:fork-preference", (data: any) => {
1119
- if (data?.mode !== "conversation-only") return;
1120
- if (typeof data?.source !== "string") return;
1116
+ pi.events.on("rewind:fork-preference", (data) => {
1117
+ if (!data || typeof data !== "object") return;
1118
+ if (!("mode" in data) || data.mode !== "conversation-only") return;
1119
+ if (!("source" in data) || typeof data.source !== "string") return;
1121
1120
  if (!FORK_PREFERENCE_SOURCE_ALLOWLIST.has(data.source)) return;
1122
1121
  forceConversationOnlyOnNextFork = true;
1123
1122
  forceConversationOnlySource = data.source;
@@ -1342,9 +1341,16 @@ export default function rewindExtension(pi: ExtensionAPI) {
1342
1341
  });
1343
1342
 
1344
1343
  pi.on("session_before_tree", async (event, ctx) => {
1345
- if (!isGitRepo || !ctx.hasUI) return;
1344
+ if (!isGitRepo) return;
1346
1345
 
1347
1346
  try {
1347
+ if ((globalThis as typeof globalThis & { __boomerangCollapseInProgress?: boolean }).__boomerangCollapseInProgress === true) {
1348
+ pendingTreeState = { currentCommitSha: await ensureSnapshotForCurrentWorktree() };
1349
+ return;
1350
+ }
1351
+
1352
+ if (!ctx.hasUI) return;
1353
+
1348
1354
  const targetEntry = ctx.sessionManager.getEntry(event.preparation.targetId) as SessionLikeEntry | undefined;
1349
1355
  const targetCommitSha = isRestorableTreeEntry(targetEntry)
1350
1356
  ? await resolveEntrySnapshotWithLineage(event.preparation.targetId, currentSessionFile)
package/install.js CHANGED
File without changes
package/package.json CHANGED
@@ -1,10 +1,7 @@
1
1
  {
2
2
  "name": "pi-rewind-hook",
3
- "version": "1.8.1",
3
+ "version": "1.8.3",
4
4
  "description": "Rewind extension for Pi agent - automatic git checkpoints with file/conversation restore",
5
- "bin": {
6
- "pi-rewind-hook": "./install.js"
7
- },
8
5
  "repository": {
9
6
  "type": "git",
10
7
  "url": "https://github.com/nicobailon/pi-rewind-hook"