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 +14 -0
- package/README.md +5 -5
- package/index.test.ts +3 -2
- package/index.ts +67 -28
- package/install.js +12 -6
- package/package.json +1 -1
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.
|
|
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
|
-
|
|
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.
|
|
29
|
-
4.
|
|
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
|
-
|
|
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:
|
|
333
|
+
parentSession: previousSessionFile,
|
|
333
334
|
});
|
|
334
|
-
await harness.invoke("
|
|
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
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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 (
|
|
1130
|
+
pi.on("session_start", async (event, ctx) => {
|
|
1097
1131
|
await initializeForSession(ctx);
|
|
1098
|
-
});
|
|
1099
1132
|
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1133
|
+
if (event.reason !== "fork" || !event.previousSessionFile || !isGitRepo) {
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1103
1136
|
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
1115
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 /
|
|
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
|
-
|
|
104
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
105
|
+
console.error(`\nInstallation failed: ${message}`);
|
|
100
106
|
process.exit(1);
|
|
101
107
|
});
|