pi-rewind-hook 1.8.0 → 1.8.1

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.1] - 2026-04-05
8
+
9
+ ### Fixed
10
+ - Migrated lifecycle handling to pi v0.65+ by replacing removed `session_switch`/`session_fork` usage with `session_start` reason-based handling
11
+ - Persisted fork rewind state through `session_start` (`reason: "fork"`) using hidden `rewind-fork-pending` entries so undo/current state is restored in the child session after extension reload
12
+ - Fixed install completion hint to use `/fork` (not deprecated `/branch`)
13
+ - Fixed installer cleanup for Windows-style extension paths when removing explicit rewind entries from `settings.json`
14
+ - Added explicit installer error for redirect responses missing a `location` header
15
+ - Preserved non-`Error` failure messages in installer warning/fatal error output
16
+
17
+ ### Changed
18
+ - Updated README minimum supported pi version to v0.65.0+
19
+ - Clarified README installation flow around auto-discovery and optional legacy settings cleanup
20
+
7
21
  ## [1.8.0] - 2026-04-03
8
22
 
9
23
  ### Changed
package/README.md CHANGED
@@ -12,24 +12,24 @@ Rewind metadata lives in the session itself as hidden entries, so rewind history
12
12
 
13
13
  ## Requirements
14
14
 
15
- - Pi agent v0.35.0+
15
+ - Pi agent v0.65.0+
16
16
  - Node.js (for installation)
17
17
  - Git repository
18
18
 
19
19
  ## Installation
20
20
 
21
21
  ```bash
22
- npx pi-rewind-hook
22
+ pi install npm:pi-rewind-hook
23
23
  ```
24
24
 
25
25
  This will:
26
26
  1. Create `~/.pi/agent/extensions/rewind/`
27
27
  2. Download the extension files
28
- 3. Add the extension to your `~/.pi/agent/settings.json`
29
- 4. Migrate any existing hooks config to extensions (if upgrading from an older version)
28
+ 3. Auto-discover the extension from the extensions directory (no `settings.json` extension entry required)
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
- Or clone the repo into `~/.pi/agent/extensions/rewind/` — Pi discovers extensions in that directory automatically.
32
+ You can also install with `npx pi-rewind-hook`.
33
33
 
34
34
  ## Configuration
35
35
 
package/index.test.ts CHANGED
@@ -327,11 +327,12 @@ test("/fork undo restores files into a child session instead of cancelling the f
327
327
  const currentSessionRewindOps = harness.currentSession.getEntries().filter((entry) => entry.type === "custom" && entry.customType === "rewind-op");
328
328
  assert.equal(currentSessionRewindOps.length, 1);
329
329
 
330
+ const previousSessionFile = harness.currentSession.getSessionFile();
330
331
  const childSession = harness.createSession({
331
332
  id: "session-2",
332
- parentSession: harness.currentSession.getSessionFile(),
333
+ parentSession: previousSessionFile,
333
334
  });
334
- await harness.invoke("session_fork", {}, childSession);
335
+ await harness.invoke("session_start", { reason: "fork", previousSessionFile }, childSession);
335
336
 
336
337
  const childRewindOps = childSession.getEntries().filter((entry) => entry.type === "custom" && entry.customType === "rewind-op");
337
338
  assert.equal(childRewindOps.length, 1);
package/index.ts CHANGED
@@ -58,6 +58,12 @@ interface RewindOpData {
58
58
  undo?: number;
59
59
  }
60
60
 
61
+ interface RewindForkPendingData {
62
+ v: 2;
63
+ current: string;
64
+ undo?: string;
65
+ }
66
+
61
67
  interface ActivePromptCollector {
62
68
  snapshots: string[];
63
69
  bindings: BindingTuple[];
@@ -98,6 +104,7 @@ interface ParsedSessionLedger {
98
104
  references: ParsedLedgerReference[];
99
105
  latestCurrentCommitSha?: string;
100
106
  latestUndoCommitSha?: string;
107
+ latestForkPending?: RewindForkPendingData;
101
108
  }
102
109
 
103
110
  interface SessionLikeMessageEntry {
@@ -212,6 +219,12 @@ function isRewindOpData(value: unknown): value is RewindOpData {
212
219
  return data.v === 2 && Array.isArray(data.snapshots);
213
220
  }
214
221
 
222
+ function isRewindForkPendingData(value: unknown): value is RewindForkPendingData {
223
+ if (!value || typeof value !== "object") return false;
224
+ const data = value as Partial<RewindForkPendingData>;
225
+ return data.v === 2 && typeof data.current === "string" && data.current.length > 0;
226
+ }
227
+
215
228
  function canonicalizePath(value: string): string {
216
229
  const resolvedValue = resolve(value);
217
230
  try {
@@ -377,12 +390,11 @@ export default function rewindExtension(pi: ExtensionAPI) {
377
390
  let lastExact: ExactState | null = null;
378
391
  let activeBranchState: ActiveBranchState = {};
379
392
  let promptCollector: ActivePromptCollector | null = null;
380
- let pendingForkState: PendingResultingState | null = null;
381
393
  let pendingTreeState: PendingResultingState | null = null;
382
394
  let activePromptText: string | null = null;
383
- let newSnapshotsSinceSweep = 0;
384
- let sweepRunning = false;
385
- let sweepCompletedThisSession = false;
395
+ let newSnapshotsSinceSweep = 0;
396
+ let sweepRunning = false;
397
+ let sweepCompletedThisSession = false;
386
398
  let forceConversationOnlyOnNextFork = false;
387
399
  let forceConversationOnlySource: string | null = null;
388
400
 
@@ -419,11 +431,10 @@ export default function rewindExtension(pi: ExtensionAPI) {
419
431
  lastExact = null;
420
432
  activeBranchState = {};
421
433
  promptCollector = null;
422
- pendingForkState = null;
423
434
  pendingTreeState = null;
424
- activePromptText = null;
425
- newSnapshotsSinceSweep = 0;
426
- sweepCompletedThisSession = false;
435
+ activePromptText = null;
436
+ newSnapshotsSinceSweep = 0;
437
+ sweepCompletedThisSession = false;
427
438
  forceConversationOnlyOnNextFork = false;
428
439
  forceConversationOnlySource = null;
429
440
  cachedSettings = null;
@@ -672,6 +683,19 @@ export default function rewindExtension(pi: ExtensionAPI) {
672
683
  updateStatus(ctx);
673
684
  }
674
685
 
686
+ function appendForkPendingState(data: PendingResultingState) {
687
+ const forkPending: RewindForkPendingData = {
688
+ v: RETENTION_VERSION,
689
+ current: data.currentCommitSha,
690
+ };
691
+
692
+ if (data.undoCommitSha) {
693
+ forkPending.undo = data.undoCommitSha;
694
+ }
695
+
696
+ pi.appendEntry("rewind-fork-pending", forkPending);
697
+ }
698
+
675
699
  function buildCurrentSessionLedger(ctx: ExtensionContext): ParsedSessionLedger {
676
700
  const ledger: ParsedSessionLedger = {
677
701
  sessionFile: currentSessionFile ?? "",
@@ -700,6 +724,11 @@ export default function rewindExtension(pi: ExtensionAPI) {
700
724
  continue;
701
725
  }
702
726
 
727
+ if (rawEntry.type === "custom" && rawEntry.customType === "rewind-fork-pending" && isRewindForkPendingData(rawEntry.data)) {
728
+ ledger.latestForkPending = rawEntry.data;
729
+ continue;
730
+ }
731
+
703
732
  if (rawEntry.type === "label") {
704
733
  updateLabelSet(ledger.labeledEntryIds, rawEntry);
705
734
  }
@@ -785,6 +814,11 @@ export default function rewindExtension(pi: ExtensionAPI) {
785
814
  continue;
786
815
  }
787
816
 
817
+ if (entry?.type === "custom" && entry?.customType === "rewind-fork-pending" && isRewindForkPendingData(entry.data)) {
818
+ ledger.latestForkPending = entry.data;
819
+ continue;
820
+ }
821
+
788
822
  if (entry?.type === "label") {
789
823
  updateLabelSet(ledger.labeledEntryIds, entry);
790
824
  }
@@ -1093,31 +1127,32 @@ export default function rewindExtension(pi: ExtensionAPI) {
1093
1127
  activePromptText = event.prompt;
1094
1128
  });
1095
1129
 
1096
- pi.on("session_start", async (_event, ctx) => {
1130
+ pi.on("session_start", async (event, ctx) => {
1097
1131
  await initializeForSession(ctx);
1098
- });
1099
1132
 
1100
- pi.on("session_switch", async (_event, ctx) => {
1101
- await initializeForSession(ctx);
1102
- });
1133
+ if (event.reason !== "fork" || !event.previousSessionFile || !isGitRepo) {
1134
+ return;
1135
+ }
1103
1136
 
1104
- pi.on("session_fork", async (_event, ctx) => {
1105
- syncSessionIdentity(ctx);
1106
- if (!isGitRepo || !pendingForkState) {
1107
- await reconstructState(ctx);
1108
- updateStatus(ctx);
1137
+ const previousLedger = await parseSessionLedgerFile(event.previousSessionFile);
1138
+ const pendingFork = previousLedger?.latestForkPending;
1139
+ if (!pendingFork) {
1109
1140
  return;
1110
1141
  }
1111
1142
 
1112
- const snapshots = [pendingForkState.currentCommitSha];
1143
+ if (!(await commitExists(pendingFork.current))) {
1144
+ return;
1145
+ }
1146
+
1147
+ const snapshots = [pendingFork.current];
1113
1148
  const data: RewindOpData = { v: RETENTION_VERSION, snapshots, current: 0 };
1114
- if (pendingForkState.undoCommitSha) {
1115
- data.snapshots.push(pendingForkState.undoCommitSha);
1149
+
1150
+ if (pendingFork.undo && (await commitExists(pendingFork.undo))) {
1151
+ data.snapshots.push(pendingFork.undo);
1116
1152
  data.undo = 1;
1117
1153
  }
1118
1154
 
1119
1155
  appendRewindOp(ctx, data);
1120
- pendingForkState = null;
1121
1156
  await reconstructState(ctx);
1122
1157
  updateStatus(ctx);
1123
1158
  });
@@ -1238,7 +1273,8 @@ export default function rewindExtension(pi: ExtensionAPI) {
1238
1273
 
1239
1274
  try {
1240
1275
  if (!ctx.hasUI) {
1241
- pendingForkState = { currentCommitSha: await ensureSnapshotForCurrentWorktree() };
1276
+ const nextState = { currentCommitSha: await ensureSnapshotForCurrentWorktree() };
1277
+ appendForkPendingState(nextState);
1242
1278
  return;
1243
1279
  }
1244
1280
 
@@ -1246,7 +1282,8 @@ export default function rewindExtension(pi: ExtensionAPI) {
1246
1282
  const hasUndo = Boolean(activeBranchState.undoCommitSha && (await commitExists(activeBranchState.undoCommitSha)));
1247
1283
 
1248
1284
  if (shouldForceConversationOnly) {
1249
- pendingForkState = { currentCommitSha: await ensureSnapshotForCurrentWorktree() };
1285
+ const nextState = { currentCommitSha: await ensureSnapshotForCurrentWorktree() };
1286
+ appendForkPendingState(nextState);
1250
1287
  notify(ctx, `Rewind: using conversation-only fork (keep current files)${forcedBySource ? ` (${forcedBySource})` : ""}`);
1251
1288
  return;
1252
1289
  }
@@ -1267,16 +1304,18 @@ export default function rewindExtension(pi: ExtensionAPI) {
1267
1304
 
1268
1305
  if (choice === "Undo last file rewind") {
1269
1306
  const restore = await restoreCommitExactly(activeBranchState.undoCommitSha!);
1270
- pendingForkState = {
1307
+ const nextState = {
1271
1308
  currentCommitSha: activeBranchState.undoCommitSha!,
1272
1309
  undoCommitSha: restore.undoCommitSha,
1273
1310
  };
1311
+ appendForkPendingState(nextState);
1274
1312
  notify(ctx, "Files restored to before last rewind");
1275
1313
  return;
1276
1314
  }
1277
1315
 
1278
1316
  if (choice === "Conversation only (keep current files)") {
1279
- pendingForkState = { currentCommitSha: await ensureSnapshotForCurrentWorktree() };
1317
+ const nextState = { currentCommitSha: await ensureSnapshotForCurrentWorktree() };
1318
+ appendForkPendingState(nextState);
1280
1319
  return;
1281
1320
  }
1282
1321
 
@@ -1286,17 +1325,17 @@ export default function rewindExtension(pi: ExtensionAPI) {
1286
1325
  }
1287
1326
 
1288
1327
  const restore = await restoreCommitExactly(targetCommitSha);
1289
- pendingForkState = {
1328
+ const nextState = {
1290
1329
  currentCommitSha: targetCommitSha,
1291
1330
  undoCommitSha: restore.undoCommitSha,
1292
1331
  };
1332
+ appendForkPendingState(nextState);
1293
1333
  notify(ctx, "Files restored from rewind point");
1294
1334
 
1295
1335
  if (choice === "Code only (restore files, keep conversation)") {
1296
1336
  return { skipConversationRestore: true };
1297
1337
  }
1298
1338
  } catch (error) {
1299
- pendingForkState = null;
1300
1339
  notify(ctx, `Rewind failed before fork: ${error instanceof Error ? error.message : String(error)}`, "error");
1301
1340
  return { cancel: true };
1302
1341
  }
package/install.js CHANGED
@@ -14,6 +14,9 @@ function download(url) {
14
14
  return new Promise((resolve, reject) => {
15
15
  https.get(url, (res) => {
16
16
  if (res.statusCode === 301 || res.statusCode === 302) {
17
+ if (!res.headers.location) {
18
+ return reject(new Error(`Redirect response missing location header for ${url}`));
19
+ }
17
20
  return download(res.headers.location).then(resolve).catch(reject);
18
21
  }
19
22
  if (res.statusCode !== 200) {
@@ -61,9 +64,10 @@ async function main() {
61
64
  // Remove rewind from explicit extensions (auto-discovery handles it now)
62
65
  if (Array.isArray(settings.extensions)) {
63
66
  const before = settings.extensions.length;
64
- settings.extensions = settings.extensions.filter(p =>
65
- !p.includes("/extensions/rewind")
66
- );
67
+ settings.extensions = settings.extensions.filter((p) => {
68
+ const normalizedPath = String(p).replace(/\\/g, "/");
69
+ return !normalizedPath.includes("/extensions/rewind");
70
+ });
67
71
  if (settings.extensions.length < before) {
68
72
  console.log("Removed rewind from explicit extensions (auto-discovery handles it)");
69
73
  modified = true;
@@ -79,7 +83,8 @@ async function main() {
79
83
  fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2) + "\n");
80
84
  }
81
85
  } catch (err) {
82
- console.error(`Warning: Could not update settings.json: ${err.message}`);
86
+ const message = err instanceof Error ? err.message : String(err);
87
+ console.error(`Warning: Could not update settings.json: ${message}`);
83
88
  }
84
89
  }
85
90
 
@@ -92,10 +97,11 @@ async function main() {
92
97
 
93
98
  console.log("\nInstallation complete!");
94
99
  console.log("\nThe extension is auto-discovered from ~/.pi/agent/extensions/rewind/");
95
- console.log("Restart pi to load the extension. Use /branch to rewind to a checkpoint.");
100
+ console.log("Restart pi to load the extension. Use /fork to rewind to a checkpoint.");
96
101
  }
97
102
 
98
103
  main().catch((err) => {
99
- console.error(`\nInstallation failed: ${err.message}`);
104
+ const message = err instanceof Error ? err.message : String(err);
105
+ console.error(`\nInstallation failed: ${message}`);
100
106
  process.exit(1);
101
107
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-rewind-hook",
3
- "version": "1.8.0",
3
+ "version": "1.8.1",
4
4
  "description": "Rewind extension for Pi agent - automatic git checkpoints with file/conversation restore",
5
5
  "bin": {
6
6
  "pi-rewind-hook": "./install.js"