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.
Files changed (4) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +11 -3
  3. package/index.ts +150 -47
  4. 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.18.0+
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
- import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
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<number, string>();
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: "success" | "error" | "info") => void
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
- pi.on("session", async (event, ctx) => {
117
- if (event.reason !== "start") return;
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 ctx.exec("git", ["rev-parse", "--is-inside-work-tree"]);
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(ctx.exec, checkpointId);
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(ctx.exec, checkpointId);
150
- if (success) {
151
- checkpoints.set(event.turnIndex, checkpointId);
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 turn ${event.turnIndex}`
194
+ `[rewind] Created checkpoint ${checkpointId} for entry ${currentEntryId}`
154
195
  );
155
- await pruneCheckpoints(ctx.exec);
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("session", async (event, ctx) => {
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
- let checkpointId = checkpoints.get(event.targetTurnIndex);
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(ctx.exec);
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
- ctx.exec,
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", "success");
271
+ ctx.ui.notify("Files restored to before last rewind", "info");
226
272
  }
227
- return { skipConversationRestore: true };
273
+ return { cancel: true };
228
274
  }
229
275
 
230
276
  const ref = `${REF_PREFIX}${checkpointId}`;
231
277
  const success = await restoreWithBackup(
232
- ctx.exec,
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
- "success"
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 function pruneCheckpoints(exec: ExecFn) {
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
- "for-each-ref",
254
- "--sort=creatordate",
255
- "--format=%(refname)",
256
- REF_PREFIX,
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
- const refs = result.stdout.trim().split("\n").filter(Boolean);
260
- const currentResumeRef = resumeCheckpoint ? `${REF_PREFIX}${resumeCheckpoint}` : null;
261
- const checkpointRefs = refs.filter(r =>
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
- if (checkpointRefs.length > MAX_CHECKPOINTS) {
266
- const toDelete = checkpointRefs.slice(0, checkpointRefs.length - MAX_CHECKPOINTS);
267
- for (const ref of toDelete) {
268
- await exec("git", ["update-ref", "-d", ref]);
269
- console.error(`[rewind] Pruned old checkpoint: ${ref}`);
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
- } catch (err) {
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-rewind-hook",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Rewind hook for Pi agent - automatic git checkpoints with file/conversation restore",
5
5
  "bin": {
6
6
  "pi-rewind-hook": "./install.js"