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 +11 -0
- package/README.md +0 -2
- package/index.test.ts +86 -12
- package/index.ts +19 -13
- package/install.js +0 -0
- package/package.json +1 -4
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:
|
|
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:
|
|
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:
|
|
103
|
-
stderr:
|
|
104
|
-
code:
|
|
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:
|
|
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:
|
|
208
|
+
on: (eventName: string, handler: (data: unknown) => void) => {
|
|
204
209
|
eventHandlers.set(eventName, handler);
|
|
205
210
|
},
|
|
206
211
|
},
|
|
207
|
-
}
|
|
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)
|
|
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:
|
|
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:
|
|
779
|
+
let entry: SessionLikeGenericEntry | null = null;
|
|
787
780
|
try {
|
|
788
|
-
|
|
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
|
|
1119
|
-
if (data
|
|
1120
|
-
if (
|
|
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
|
|
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.
|
|
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"
|