pi-rewind-hook 1.1.0 → 1.2.0
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 +20 -0
- package/README.md +11 -3
- package/index.ts +150 -47
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [1.2.0] - 2025-01-03
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- Tree navigation support (`session_before_tree`) - restore files when navigating session tree
|
|
9
|
+
- Entry-based checkpoint mapping (uses entry IDs instead of turn indices)
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
- Migrated to granular session events API (pi-coding-agent v0.31+)
|
|
13
|
+
- Use `pi.exec` instead of `ctx.exec` per updated hooks API
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- Removed `agent_end` handler that was clearing checkpoints after each turn
|
|
17
|
+
- "Undo last file rewind" now cancels branch instead of creating unwanted branch
|
|
18
|
+
|
|
19
|
+
## [1.1.1] - 2024-12-27
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
- Use `before_branch` event instead of `branch` for proper hook timing (thanks @badlogic)
|
|
23
|
+
- Cancel branch when user dismisses restore options menu
|
|
24
|
+
|
|
5
25
|
## [1.1.0] - 2024-12-27
|
|
6
26
|
|
|
7
27
|
### Added
|
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ A Pi agent hook that enables rewinding file changes during coding sessions. Crea
|
|
|
10
10
|
|
|
11
11
|
## Requirements
|
|
12
12
|
|
|
13
|
-
- Pi agent v0.
|
|
13
|
+
- Pi agent v0.31+
|
|
14
14
|
- Node.js (for installation)
|
|
15
15
|
- Git repository (checkpoints are stored as git refs)
|
|
16
16
|
|
|
@@ -68,11 +68,17 @@ Checkpoints are stored as git refs under `refs/pi-checkpoints/` and are pruned t
|
|
|
68
68
|
|
|
69
69
|
### Rewinding
|
|
70
70
|
|
|
71
|
-
To rewind
|
|
71
|
+
To rewind via `/branch`:
|
|
72
72
|
|
|
73
73
|
1. Type `/branch` in pi
|
|
74
74
|
2. Select a message to branch from
|
|
75
|
-
3. Choose a restore option
|
|
75
|
+
3. Choose a restore option
|
|
76
|
+
|
|
77
|
+
To rewind via tree navigation:
|
|
78
|
+
|
|
79
|
+
1. Press `Tab` to open the session tree
|
|
80
|
+
2. Navigate to a different node
|
|
81
|
+
3. Choose a restore option
|
|
76
82
|
|
|
77
83
|
**For messages from the current session:**
|
|
78
84
|
|
|
@@ -81,6 +87,7 @@ To rewind:
|
|
|
81
87
|
| **Restore all (files + conversation)** | Restored | Reset to that point |
|
|
82
88
|
| **Conversation only (keep current files)** | Unchanged | Reset to that point |
|
|
83
89
|
| **Code only (restore files, keep conversation)** | Restored | Unchanged |
|
|
90
|
+
| **Undo last file rewind** | Restored to before last rewind | Unchanged |
|
|
84
91
|
|
|
85
92
|
**For messages from before the current session (uses resume checkpoint):**
|
|
86
93
|
|
|
@@ -89,6 +96,7 @@ To rewind:
|
|
|
89
96
|
| **Restore to session start (files + conversation)** | Restored to session start | Reset to that point |
|
|
90
97
|
| **Conversation only (keep current files)** | Unchanged | Reset to that point |
|
|
91
98
|
| **Restore to session start (files only, keep conversation)** | Restored to session start | Unchanged |
|
|
99
|
+
| **Undo last file rewind** | Restored to before last rewind | Unchanged |
|
|
92
100
|
|
|
93
101
|
### Resumed Sessions
|
|
94
102
|
|
package/index.ts
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Rewind Hook - Git-based file restoration for pi branching
|
|
3
|
+
*
|
|
4
|
+
* Creates worktree snapshots at each turn so /branch can restore code state.
|
|
5
|
+
* Supports: restore files + conversation, files only, conversation only, undo last restore.
|
|
6
|
+
*
|
|
7
|
+
* Updated for pi-coding-agent v0.31+ (granular session events API)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { HookAPI } from "@mariozechner/pi-coding-agent";
|
|
2
11
|
import { exec as execCb } from "child_process";
|
|
3
12
|
import { mkdtemp, rm } from "fs/promises";
|
|
4
13
|
import { tmpdir } from "os";
|
|
@@ -14,7 +23,8 @@ const MAX_CHECKPOINTS = 100;
|
|
|
14
23
|
type ExecFn = (cmd: string, args: string[]) => Promise<{ stdout: string; stderr: string; code: number }>;
|
|
15
24
|
|
|
16
25
|
export default function (pi: HookAPI) {
|
|
17
|
-
const checkpoints = new Map<
|
|
26
|
+
const checkpoints = new Map<string, string>();
|
|
27
|
+
let currentEntryId: string | undefined;
|
|
18
28
|
let resumeCheckpoint: string | null = null;
|
|
19
29
|
let repoRoot: string | null = null;
|
|
20
30
|
let isGitRepo = false;
|
|
@@ -71,7 +81,7 @@ export default function (pi: HookAPI) {
|
|
|
71
81
|
async function restoreWithBackup(
|
|
72
82
|
exec: ExecFn,
|
|
73
83
|
targetRef: string,
|
|
74
|
-
notify: (msg: string, level: "
|
|
84
|
+
notify: (msg: string, level: "info" | "warning" | "error") => void
|
|
75
85
|
): Promise<boolean> {
|
|
76
86
|
try {
|
|
77
87
|
const existingBackup = await findBeforeRestoreRef(exec);
|
|
@@ -113,12 +123,38 @@ export default function (pi: HookAPI) {
|
|
|
113
123
|
}
|
|
114
124
|
}
|
|
115
125
|
|
|
116
|
-
|
|
117
|
-
|
|
126
|
+
async function pruneCheckpoints(exec: ExecFn) {
|
|
127
|
+
try {
|
|
128
|
+
const result = await exec("git", [
|
|
129
|
+
"for-each-ref",
|
|
130
|
+
"--sort=creatordate",
|
|
131
|
+
"--format=%(refname)",
|
|
132
|
+
REF_PREFIX,
|
|
133
|
+
]);
|
|
134
|
+
|
|
135
|
+
const refs = result.stdout.trim().split("\n").filter(Boolean);
|
|
136
|
+
const currentResumeRef = resumeCheckpoint ? `${REF_PREFIX}${resumeCheckpoint}` : null;
|
|
137
|
+
const checkpointRefs = refs.filter(r =>
|
|
138
|
+
!r.includes(BEFORE_RESTORE_PREFIX) && r !== currentResumeRef
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
if (checkpointRefs.length > MAX_CHECKPOINTS) {
|
|
142
|
+
const toDelete = checkpointRefs.slice(0, checkpointRefs.length - MAX_CHECKPOINTS);
|
|
143
|
+
for (const ref of toDelete) {
|
|
144
|
+
await exec("git", ["update-ref", "-d", ref]);
|
|
145
|
+
console.error(`[rewind] Pruned old checkpoint: ${ref}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
} catch (err) {
|
|
149
|
+
console.error(`[rewind] Failed to prune checkpoints: ${err}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
118
154
|
if (!ctx.hasUI) return;
|
|
119
155
|
|
|
120
156
|
try {
|
|
121
|
-
const result = await
|
|
157
|
+
const result = await pi.exec("git", ["rev-parse", "--is-inside-work-tree"]);
|
|
122
158
|
isGitRepo = result.stdout.trim() === "true";
|
|
123
159
|
} catch {
|
|
124
160
|
isGitRepo = false;
|
|
@@ -129,7 +165,7 @@ export default function (pi: HookAPI) {
|
|
|
129
165
|
const checkpointId = `checkpoint-resume-${Date.now()}`;
|
|
130
166
|
|
|
131
167
|
try {
|
|
132
|
-
const success = await createCheckpointFromWorktree(
|
|
168
|
+
const success = await createCheckpointFromWorktree(pi.exec, checkpointId);
|
|
133
169
|
if (success) {
|
|
134
170
|
resumeCheckpoint = checkpointId;
|
|
135
171
|
console.error(`[rewind] Created resume checkpoint: ${checkpointId}`);
|
|
@@ -139,6 +175,11 @@ export default function (pi: HookAPI) {
|
|
|
139
175
|
}
|
|
140
176
|
});
|
|
141
177
|
|
|
178
|
+
pi.on("tool_result", async (_event, ctx) => {
|
|
179
|
+
const leaf = ctx.sessionManager.getLeafEntry();
|
|
180
|
+
if (leaf) currentEntryId = leaf.id;
|
|
181
|
+
});
|
|
182
|
+
|
|
142
183
|
pi.on("turn_start", async (event, ctx) => {
|
|
143
184
|
if (!ctx.hasUI) return;
|
|
144
185
|
if (!isGitRepo) return;
|
|
@@ -146,25 +187,30 @@ export default function (pi: HookAPI) {
|
|
|
146
187
|
const checkpointId = `checkpoint-${event.timestamp}`;
|
|
147
188
|
|
|
148
189
|
try {
|
|
149
|
-
const success = await createCheckpointFromWorktree(
|
|
150
|
-
if (success) {
|
|
151
|
-
checkpoints.set(
|
|
190
|
+
const success = await createCheckpointFromWorktree(pi.exec, checkpointId);
|
|
191
|
+
if (success && currentEntryId) {
|
|
192
|
+
checkpoints.set(currentEntryId, checkpointId);
|
|
152
193
|
console.error(
|
|
153
|
-
`[rewind] Created checkpoint ${checkpointId} for
|
|
194
|
+
`[rewind] Created checkpoint ${checkpointId} for entry ${currentEntryId}`
|
|
154
195
|
);
|
|
155
|
-
await pruneCheckpoints(
|
|
196
|
+
await pruneCheckpoints(pi.exec);
|
|
156
197
|
}
|
|
157
198
|
} catch (err) {
|
|
158
199
|
console.error(`[rewind] Failed to create checkpoint: ${err}`);
|
|
159
200
|
}
|
|
160
201
|
});
|
|
161
202
|
|
|
162
|
-
pi.on("
|
|
163
|
-
if (event.reason !== "branch") return;
|
|
203
|
+
pi.on("session_before_branch", async (event, ctx) => {
|
|
164
204
|
if (!ctx.hasUI) return;
|
|
165
|
-
if (!isGitRepo) return;
|
|
166
205
|
|
|
167
|
-
|
|
206
|
+
try {
|
|
207
|
+
const result = await pi.exec("git", ["rev-parse", "--is-inside-work-tree"]);
|
|
208
|
+
if (result.stdout.trim() !== "true") return;
|
|
209
|
+
} catch {
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
let checkpointId = checkpoints.get(event.entryId);
|
|
168
214
|
let usingResumeCheckpoint = false;
|
|
169
215
|
|
|
170
216
|
if (!checkpointId && resumeCheckpoint) {
|
|
@@ -172,7 +218,7 @@ export default function (pi: HookAPI) {
|
|
|
172
218
|
usingResumeCheckpoint = true;
|
|
173
219
|
}
|
|
174
220
|
|
|
175
|
-
const beforeRestoreRef = await findBeforeRestoreRef(
|
|
221
|
+
const beforeRestoreRef = await findBeforeRestoreRef(pi.exec);
|
|
176
222
|
const hasUndo = !!beforeRestoreRef;
|
|
177
223
|
|
|
178
224
|
if (!checkpointId && !hasUndo) {
|
|
@@ -184,7 +230,7 @@ export default function (pi: HookAPI) {
|
|
|
184
230
|
}
|
|
185
231
|
|
|
186
232
|
const options: string[] = [];
|
|
187
|
-
|
|
233
|
+
|
|
188
234
|
if (checkpointId) {
|
|
189
235
|
if (usingResumeCheckpoint) {
|
|
190
236
|
options.push("Restore to session start (files + conversation)");
|
|
@@ -205,7 +251,7 @@ export default function (pi: HookAPI) {
|
|
|
205
251
|
|
|
206
252
|
if (!choice) {
|
|
207
253
|
ctx.ui.notify("Rewind cancelled", "info");
|
|
208
|
-
return;
|
|
254
|
+
return { cancel: true };
|
|
209
255
|
}
|
|
210
256
|
|
|
211
257
|
if (choice.startsWith("Conversation only")) {
|
|
@@ -217,28 +263,28 @@ export default function (pi: HookAPI) {
|
|
|
217
263
|
|
|
218
264
|
if (choice === "Undo last file rewind") {
|
|
219
265
|
const success = await restoreWithBackup(
|
|
220
|
-
|
|
266
|
+
pi.exec,
|
|
221
267
|
beforeRestoreRef!.commitSha,
|
|
222
268
|
ctx.ui.notify.bind(ctx.ui)
|
|
223
269
|
);
|
|
224
270
|
if (success) {
|
|
225
|
-
ctx.ui.notify("Files restored to before last rewind", "
|
|
271
|
+
ctx.ui.notify("Files restored to before last rewind", "info");
|
|
226
272
|
}
|
|
227
|
-
return {
|
|
273
|
+
return { cancel: true };
|
|
228
274
|
}
|
|
229
275
|
|
|
230
276
|
const ref = `${REF_PREFIX}${checkpointId}`;
|
|
231
277
|
const success = await restoreWithBackup(
|
|
232
|
-
|
|
278
|
+
pi.exec,
|
|
233
279
|
ref,
|
|
234
280
|
ctx.ui.notify.bind(ctx.ui)
|
|
235
281
|
);
|
|
236
282
|
if (success) {
|
|
237
283
|
ctx.ui.notify(
|
|
238
|
-
usingResumeCheckpoint
|
|
239
|
-
? "Files restored to session start"
|
|
284
|
+
usingResumeCheckpoint
|
|
285
|
+
? "Files restored to session start"
|
|
240
286
|
: "Files restored from checkpoint",
|
|
241
|
-
"
|
|
287
|
+
"info"
|
|
242
288
|
);
|
|
243
289
|
}
|
|
244
290
|
|
|
@@ -247,30 +293,87 @@ export default function (pi: HookAPI) {
|
|
|
247
293
|
}
|
|
248
294
|
});
|
|
249
295
|
|
|
250
|
-
async
|
|
296
|
+
pi.on("session_before_tree", async (event, ctx) => {
|
|
297
|
+
if (!ctx.hasUI) return;
|
|
298
|
+
|
|
251
299
|
try {
|
|
252
|
-
const result = await exec("git", [
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
]);
|
|
300
|
+
const result = await pi.exec("git", ["rev-parse", "--is-inside-work-tree"]);
|
|
301
|
+
if (result.stdout.trim() !== "true") return;
|
|
302
|
+
} catch {
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
258
305
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
!r.includes(BEFORE_RESTORE_PREFIX) && r !== currentResumeRef
|
|
263
|
-
);
|
|
306
|
+
const targetId = event.preparation.targetId;
|
|
307
|
+
let checkpointId = checkpoints.get(targetId);
|
|
308
|
+
let usingResumeCheckpoint = false;
|
|
264
309
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
310
|
+
if (!checkpointId && resumeCheckpoint) {
|
|
311
|
+
checkpointId = resumeCheckpoint;
|
|
312
|
+
usingResumeCheckpoint = true;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const beforeRestoreRef = await findBeforeRestoreRef(pi.exec);
|
|
316
|
+
const hasUndo = !!beforeRestoreRef;
|
|
317
|
+
|
|
318
|
+
if (!checkpointId && !hasUndo) {
|
|
319
|
+
ctx.ui.notify("No checkpoint available for this message", "info");
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const options: string[] = [];
|
|
324
|
+
|
|
325
|
+
if (checkpointId) {
|
|
326
|
+
if (usingResumeCheckpoint) {
|
|
327
|
+
options.push("Restore files to session start");
|
|
328
|
+
} else {
|
|
329
|
+
options.push("Restore files to that point");
|
|
271
330
|
}
|
|
272
|
-
|
|
273
|
-
console.error(`[rewind] Failed to prune checkpoints: ${err}`);
|
|
331
|
+
options.push("Keep current files");
|
|
274
332
|
}
|
|
275
|
-
|
|
333
|
+
|
|
334
|
+
if (hasUndo) {
|
|
335
|
+
options.push("Undo last file rewind");
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
options.push("Cancel navigation");
|
|
339
|
+
|
|
340
|
+
const choice = await ctx.ui.select("Restore Options", options);
|
|
341
|
+
|
|
342
|
+
if (!choice || choice === "Cancel navigation") {
|
|
343
|
+
ctx.ui.notify("Navigation cancelled", "info");
|
|
344
|
+
return { cancel: true };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (choice === "Keep current files") {
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (choice === "Undo last file rewind") {
|
|
352
|
+
const success = await restoreWithBackup(
|
|
353
|
+
pi.exec,
|
|
354
|
+
beforeRestoreRef!.commitSha,
|
|
355
|
+
ctx.ui.notify.bind(ctx.ui)
|
|
356
|
+
);
|
|
357
|
+
if (success) {
|
|
358
|
+
ctx.ui.notify("Files restored to before last rewind", "info");
|
|
359
|
+
}
|
|
360
|
+
return { cancel: true };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const ref = `${REF_PREFIX}${checkpointId}`;
|
|
364
|
+
const success = await restoreWithBackup(
|
|
365
|
+
pi.exec,
|
|
366
|
+
ref,
|
|
367
|
+
ctx.ui.notify.bind(ctx.ui)
|
|
368
|
+
);
|
|
369
|
+
if (success) {
|
|
370
|
+
ctx.ui.notify(
|
|
371
|
+
usingResumeCheckpoint
|
|
372
|
+
? "Files restored to session start"
|
|
373
|
+
: "Files restored to checkpoint",
|
|
374
|
+
"info"
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
|
|
276
379
|
}
|