pi-rewind-hook 1.8.2 → 1.8.4

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,20 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.8.4] - 2026-04-25
8
+
9
+ ### Added
10
+ - Added `pi-custom-compaction` compatibility: custom message nodes can now receive exact Rewind checkpoints via the `rewind:checkpoint-entry` extension event.
11
+
12
+ ### Documentation
13
+ - Documented seamless `pi-custom-compaction` compatibility and the `pi install npm:pi-custom-compaction` install command.
14
+
15
+ ## [1.8.3] - 2026-04-16
16
+
17
+ ### Fixed
18
+ - `session_before_tree` now auto-keeps current files during [pi-boomerang](https://github.com/nicobailon/pi-boomerang) collapses instead of prompting with restore options.
19
+ - Headless boomerang-triggered tree collapses now preserve rewind metadata instead of skipping the keep-current-files snapshot path.
20
+
7
21
  ## [1.8.2] - 2026-04-05
8
22
 
9
23
  ### Changed
package/README.md CHANGED
@@ -29,6 +29,18 @@ 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
+ ## Works with pi-custom-compaction
33
+
34
+ Rewind works seamlessly with [`pi-custom-compaction`](https://github.com/nicobailon/pi-custom-compaction). When `pi-custom-compaction` writes visible background-summary markers, it can ask Rewind to bind an exact file checkpoint to that marker’s own session-tree node. That means `/tree` can offer the normal `Restore files to that point` option directly on the custom compaction marker instead of falling back to a nearby parent node.
35
+
36
+ Install the companion extension with:
37
+
38
+ ```bash
39
+ pi install npm:pi-custom-compaction
40
+ ```
41
+
42
+ This is optional. Pi’s default compaction and Rewind’s native compaction checkpoints continue to work without `pi-custom-compaction`.
43
+
32
44
  ## Configuration
33
45
 
34
46
  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,151 @@ test("session_before_tree gracefully cancels when restore fails", async () => {
433
438
  }
434
439
  });
435
440
 
441
+ test("session_before_tree restores directly from a custom message with a checkpoint", async () => {
442
+ const harness = await createHarness({
443
+ settings: { rewind: { silentCheckpoints: true } },
444
+ });
445
+
446
+ try {
447
+ await harness.writeRepoFile("notes.txt", "target state\n");
448
+ const targetCommit = await harness.captureSnapshot();
449
+ await harness.writeRepoFile("notes.txt", "current state\n");
450
+
451
+ harness.currentSession.replaceEntries([
452
+ {
453
+ type: "custom_message",
454
+ id: "marker-1",
455
+ parentId: null,
456
+ timestamp: new Date().toISOString(),
457
+ customType: "pi-custom-compaction.virtual-summary-marker",
458
+ content: "Older context was summarized in the background.",
459
+ display: true,
460
+ },
461
+ {
462
+ type: "custom",
463
+ id: "rewind-op-1",
464
+ parentId: "marker-1",
465
+ timestamp: new Date().toISOString(),
466
+ customType: "rewind-op",
467
+ data: { v: 2, snapshots: [targetCommit], bindings: [["marker-1", 0]] },
468
+ },
469
+ ]);
470
+
471
+ await harness.invoke("session_start", {});
472
+ harness.enqueueSelection("Restore files to that point");
473
+
474
+ const result = await harness.invoke("session_before_tree", { preparation: { targetId: "marker-1" } });
475
+ assert.equal(result, undefined);
476
+ assert.equal(harness.readRepoFile("notes.txt"), "target state\n");
477
+ assert.deepEqual(harness.selectCalls[0]?.options, ["Keep current files", "Restore files to that point", "Cancel navigation"]);
478
+ } finally {
479
+ await harness.cleanup();
480
+ }
481
+ });
482
+
483
+ test("rewind:checkpoint-entry binds the current tree to a custom message", async () => {
484
+ const harness = await createHarness({
485
+ settings: { rewind: { silentCheckpoints: true } },
486
+ });
487
+
488
+ try {
489
+ await harness.writeRepoFile("notes.txt", "target state\n");
490
+ harness.currentSession.replaceEntries([
491
+ {
492
+ type: "custom_message",
493
+ id: "marker-1",
494
+ parentId: null,
495
+ timestamp: new Date().toISOString(),
496
+ customType: "pi-custom-compaction.virtual-summary-marker",
497
+ content: "Older context was summarized in the background.",
498
+ display: true,
499
+ },
500
+ ]);
501
+
502
+ await harness.invoke("session_start", {});
503
+ harness.eventHandlers.get("rewind:checkpoint-entry")?.({ source: "pi-custom-compaction", entryId: "marker-1" });
504
+ await new Promise((resolve) => setTimeout(resolve, 50));
505
+ await harness.writeRepoFile("notes.txt", "current state\n");
506
+
507
+ harness.enqueueSelection("Restore files to that point");
508
+ const result = await harness.invoke("session_before_tree", { preparation: { targetId: "marker-1" } });
509
+ assert.equal(result, undefined);
510
+ assert.equal(harness.readRepoFile("notes.txt"), "target state\n");
511
+ } finally {
512
+ await harness.cleanup();
513
+ }
514
+ });
515
+
516
+ test("session_before_tree auto-keeps current files during boomerang collapse", async () => {
517
+ const harness = await createHarness({
518
+ settings: { rewind: { silentCheckpoints: true } },
519
+ });
520
+
521
+ try {
522
+ await harness.writeRepoFile("notes.txt", "current state\n");
523
+
524
+ harness.currentSession.replaceEntries([
525
+ {
526
+ type: "message",
527
+ id: "user-1",
528
+ parentId: null,
529
+ timestamp: new Date().toISOString(),
530
+ message: { role: "user", content: [{ type: "text", text: "Tree target" }] },
531
+ },
532
+ ]);
533
+
534
+ await harness.invoke("session_start", {});
535
+ (globalThis as typeof globalThis & { __boomerangCollapseInProgress?: boolean }).__boomerangCollapseInProgress = true;
536
+
537
+ const result = await harness.invoke("session_before_tree", { preparation: { targetId: "user-1" } });
538
+ assert.equal(result, undefined);
539
+ assert.equal(harness.selectCalls.length, 0);
540
+
541
+ await harness.invoke("session_tree", { summaryEntry: { id: "summary-1" } });
542
+
543
+ const rewindOps = harness.currentSession.getEntries().filter((entry) => entry.type === "custom" && entry.customType === "rewind-op");
544
+ assert.equal(rewindOps.length, 1);
545
+ } finally {
546
+ delete (globalThis as typeof globalThis & { __boomerangCollapseInProgress?: boolean }).__boomerangCollapseInProgress;
547
+ await harness.cleanup();
548
+ }
549
+ });
550
+
551
+ test("session_before_tree auto-keeps current files during headless boomerang collapse", async () => {
552
+ const harness = await createHarness({
553
+ settings: { rewind: { silentCheckpoints: true } },
554
+ });
555
+
556
+ try {
557
+ await harness.writeRepoFile("notes.txt", "current state\n");
558
+
559
+ harness.currentSession.replaceEntries([
560
+ {
561
+ type: "message",
562
+ id: "user-1",
563
+ parentId: null,
564
+ timestamp: new Date().toISOString(),
565
+ message: { role: "user", content: [{ type: "text", text: "Tree target" }] },
566
+ },
567
+ ]);
568
+
569
+ await harness.invoke("session_start", {});
570
+ (globalThis as typeof globalThis & { __boomerangCollapseInProgress?: boolean }).__boomerangCollapseInProgress = true;
571
+
572
+ const result = await harness.invoke("session_before_tree", { preparation: { targetId: "user-1" } }, harness.currentSession, false);
573
+ assert.equal(result, undefined);
574
+ assert.equal(harness.selectCalls.length, 0);
575
+
576
+ await harness.invoke("session_tree", { summaryEntry: { id: "summary-1" } }, harness.currentSession, false);
577
+
578
+ const rewindOps = harness.currentSession.getEntries().filter((entry) => entry.type === "custom" && entry.customType === "rewind-op");
579
+ assert.equal(rewindOps.length, 1);
580
+ } finally {
581
+ delete (globalThis as typeof globalThis & { __boomerangCollapseInProgress?: boolean }).__boomerangCollapseInProgress;
582
+ await harness.cleanup();
583
+ }
584
+ });
585
+
436
586
  test("first mutating turn creates a reachable store ref even when retention is omitted", async () => {
437
587
  const harness = await createHarness({
438
588
  settings: { rewind: { silentCheckpoints: true } },
@@ -600,4 +750,3 @@ test("retention rewrites the keepalive ref when a live snapshot exists", async (
600
750
  await harness.cleanup();
601
751
  }
602
752
  });
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";
@@ -18,6 +11,7 @@ const execAsync = promisify(execCb);
18
11
  const STORE_REF = "refs/pi-rewind/store";
19
12
  const STATUS_KEY = "rewind";
20
13
  const FORK_PREFERENCE_SOURCE_ALLOWLIST = new Set(["fork-from-first"]);
14
+ const CHECKPOINT_SOURCE_ALLOWLIST = new Set(["pi-custom-compaction"]);
21
15
  const LEGACY_ZERO_SHA = "0000000000000000000000000000000000000000";
22
16
  const RETENTION_SWEEP_THRESHOLD = 50;
23
17
  const RETENTION_VERSION = 2;
@@ -323,7 +317,7 @@ function isRestorableTreeEntry(entry: SessionLikeEntry | undefined): boolean {
323
317
  if (entry.type === "message") {
324
318
  return entry.message.role === "user" || entry.message.role === "assistant";
325
319
  }
326
- return entry.type === "branch_summary" || entry.type === "compaction";
320
+ return entry.type === "branch_summary" || entry.type === "compaction" || entry.type === "custom_message";
327
321
  }
328
322
 
329
323
  function isAssistantMessageEntry(entry: SessionLikeEntry): entry is SessionLikeMessageEntry {
@@ -783,14 +777,19 @@ export default function rewindExtension(pi: ExtensionAPI) {
783
777
  const lines = content.split("\n").filter(Boolean);
784
778
 
785
779
  for (const line of lines) {
786
- let entry: any;
780
+ let entry: SessionLikeGenericEntry | null = null;
787
781
  try {
788
- entry = JSON.parse(line);
782
+ const parsed = JSON.parse(line);
783
+ if (parsed && typeof parsed === "object") {
784
+ entry = parsed as SessionLikeGenericEntry;
785
+ }
789
786
  } catch {
790
787
  // Ignore malformed JSONL lines; retention discovery is best-effort.
791
788
  continue;
792
789
  }
793
790
 
791
+ if (!entry) continue;
792
+
794
793
  if (entry?.type === "session") {
795
794
  ledger.sessionId = entry.id;
796
795
  ledger.cwd = entry.cwd;
@@ -1090,7 +1089,27 @@ export default function rewindExtension(pi: ExtensionAPI) {
1090
1089
  updateStatus(ctx);
1091
1090
  }
1092
1091
 
1092
+ let activeContext: ExtensionContext | undefined;
1093
+
1094
+ async function checkpointEntry(ctx: ExtensionContext, entryId: string) {
1095
+ syncSessionIdentity(ctx);
1096
+ if (!isGitRepo) return;
1097
+ const entry = ctx.sessionManager.getEntry(entryId) as SessionLikeEntry | undefined;
1098
+ if (!isRestorableTreeEntry(entry)) return;
1099
+ if (entryToCommit.has(entryId)) return;
1100
+
1101
+ const currentCommitSha = await ensureSnapshotForCurrentWorktree();
1102
+ appendRewindOp(ctx, {
1103
+ v: RETENTION_VERSION,
1104
+ snapshots: [currentCommitSha],
1105
+ bindings: [[entryId, 0]],
1106
+ });
1107
+ await reconstructState(ctx);
1108
+ updateStatus(ctx);
1109
+ }
1110
+
1093
1111
  async function initializeForSession(ctx: ExtensionContext) {
1112
+ activeContext = ctx;
1094
1113
  resetState();
1095
1114
  syncSessionIdentity(ctx);
1096
1115
 
@@ -1115,14 +1134,28 @@ export default function rewindExtension(pi: ExtensionAPI) {
1115
1134
  });
1116
1135
  }
1117
1136
 
1118
- pi.events.on("rewind:fork-preference", (data: any) => {
1119
- if (data?.mode !== "conversation-only") return;
1120
- if (typeof data?.source !== "string") return;
1137
+ pi.events.on("rewind:fork-preference", (data) => {
1138
+ if (!data || typeof data !== "object") return;
1139
+ if (!("mode" in data) || data.mode !== "conversation-only") return;
1140
+ if (!("source" in data) || typeof data.source !== "string") return;
1121
1141
  if (!FORK_PREFERENCE_SOURCE_ALLOWLIST.has(data.source)) return;
1122
1142
  forceConversationOnlyOnNextFork = true;
1123
1143
  forceConversationOnlySource = data.source;
1124
1144
  });
1125
1145
 
1146
+ pi.events.on("rewind:checkpoint-entry", (data) => {
1147
+ if (!data || typeof data !== "object") return;
1148
+ if (!("source" in data) || typeof data.source !== "string") return;
1149
+ if (!CHECKPOINT_SOURCE_ALLOWLIST.has(data.source)) return;
1150
+ if (!("entryId" in data) || typeof data.entryId !== "string") return;
1151
+ const ctx = activeContext;
1152
+ if (!ctx) return;
1153
+
1154
+ checkpointEntry(ctx, data.entryId).catch((error) => {
1155
+ notify(ctx, `Rewind: failed to checkpoint ${data.entryId} (${error instanceof Error ? error.message : String(error)})`, "warning");
1156
+ });
1157
+ });
1158
+
1126
1159
  pi.on("before_agent_start", async (event) => {
1127
1160
  activePromptText = event.prompt;
1128
1161
  });
@@ -1342,9 +1375,16 @@ export default function rewindExtension(pi: ExtensionAPI) {
1342
1375
  });
1343
1376
 
1344
1377
  pi.on("session_before_tree", async (event, ctx) => {
1345
- if (!isGitRepo || !ctx.hasUI) return;
1378
+ if (!isGitRepo) return;
1346
1379
 
1347
1380
  try {
1381
+ if ((globalThis as typeof globalThis & { __boomerangCollapseInProgress?: boolean }).__boomerangCollapseInProgress === true) {
1382
+ pendingTreeState = { currentCommitSha: await ensureSnapshotForCurrentWorktree() };
1383
+ return;
1384
+ }
1385
+
1386
+ if (!ctx.hasUI) return;
1387
+
1348
1388
  const targetEntry = ctx.sessionManager.getEntry(event.preparation.targetId) as SessionLikeEntry | undefined;
1349
1389
  const targetCommitSha = isRestorableTreeEntry(targetEntry)
1350
1390
  ? await resolveEntrySnapshotWithLineage(event.preparation.targetId, currentSessionFile)
@@ -1386,7 +1426,7 @@ export default function rewindExtension(pi: ExtensionAPI) {
1386
1426
  }
1387
1427
 
1388
1428
  if (!targetCommitSha) {
1389
- notify(ctx, "Exact file rewind is only available for user, assistant, compaction, and summary nodes", "error");
1429
+ notify(ctx, "Exact file rewind is only available for user, assistant, custom message, compaction, and summary nodes", "error");
1390
1430
  return { cancel: true };
1391
1431
  }
1392
1432
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-rewind-hook",
3
- "version": "1.8.2",
3
+ "version": "1.8.4",
4
4
  "description": "Rewind extension for Pi agent - automatic git checkpoints with file/conversation restore",
5
5
  "repository": {
6
6
  "type": "git",