pi-rewind-hook 1.0.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/README.md +172 -0
- package/index.ts +141 -0
- package/install.js +84 -0
- package/package.json +22 -0
package/README.md
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# Rewind Hook
|
|
2
|
+
|
|
3
|
+
A Pi agent hook that enables rewinding file changes during coding sessions. Creates automatic checkpoints using git refs, allowing you to restore files to previous states while optionally preserving conversation history.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- Pi agent v0.18.0+
|
|
8
|
+
- Node.js (for installation)
|
|
9
|
+
- Git repository (checkpoints are stored as git refs)
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx pi-rewind-hook
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
This will:
|
|
18
|
+
1. Create `~/.pi/agent/hooks/rewind/`
|
|
19
|
+
2. Download the hook files
|
|
20
|
+
3. Add the hook to your `~/.pi/agent/settings.json`
|
|
21
|
+
|
|
22
|
+
### Alternative Installation
|
|
23
|
+
|
|
24
|
+
Using curl:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
curl -fsSL https://raw.githubusercontent.com/nicobailon/pi-rewind-hook/main/install.js | node
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Or clone the repo and configure manually:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
git clone https://github.com/nicobailon/pi-rewind-hook ~/.pi/agent/hooks/rewind
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Then add to `~/.pi/agent/settings.json`:
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{
|
|
40
|
+
"hooks": ["~/.pi/agent/hooks/rewind/index.ts"]
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Platform Notes
|
|
45
|
+
|
|
46
|
+
**Windows:** The `npx` command works in PowerShell, Command Prompt, and WSL. If you prefer curl on Windows without WSL:
|
|
47
|
+
|
|
48
|
+
```powershell
|
|
49
|
+
Invoke-WebRequest -Uri "https://raw.githubusercontent.com/nicobailon/pi-rewind-hook/main/install.js" -OutFile install.js; node install.js; Remove-Item install.js
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## How It Works
|
|
53
|
+
|
|
54
|
+
### Checkpoints
|
|
55
|
+
|
|
56
|
+
The hook creates git refs at two points:
|
|
57
|
+
|
|
58
|
+
1. **Session start** - When pi starts, creates a "resume checkpoint" of the current file state
|
|
59
|
+
2. **Each turn** - Before the agent processes each message, creates a checkpoint
|
|
60
|
+
|
|
61
|
+
Checkpoints are stored as git refs under `refs/pi-checkpoints/` and are pruned to keep the last 100.
|
|
62
|
+
|
|
63
|
+
### Rewinding
|
|
64
|
+
|
|
65
|
+
To rewind:
|
|
66
|
+
|
|
67
|
+
1. Type `/branch` in pi
|
|
68
|
+
2. Select a message to branch from
|
|
69
|
+
3. Choose a restore option:
|
|
70
|
+
|
|
71
|
+
**For messages from the current session:**
|
|
72
|
+
|
|
73
|
+
| Option | Files | Conversation |
|
|
74
|
+
|--------|-------|--------------|
|
|
75
|
+
| **Restore all (files + conversation)** | Restored | Reset to that point |
|
|
76
|
+
| **Conversation only (keep current files)** | Unchanged | Reset to that point |
|
|
77
|
+
| **Code only (restore files, keep conversation)** | Restored | Unchanged |
|
|
78
|
+
|
|
79
|
+
**For messages from before the current session (uses resume checkpoint):**
|
|
80
|
+
|
|
81
|
+
| Option | Files | Conversation |
|
|
82
|
+
|--------|-------|--------------|
|
|
83
|
+
| **Restore to session start (files + conversation)** | Restored to session start | Reset to that point |
|
|
84
|
+
| **Conversation only (keep current files)** | Unchanged | Reset to that point |
|
|
85
|
+
| **Restore to session start (files only, keep conversation)** | Restored to session start | Unchanged |
|
|
86
|
+
|
|
87
|
+
### Resumed Sessions
|
|
88
|
+
|
|
89
|
+
When you resume a session (`pi --resume`), the hook creates a resume checkpoint. If you branch to a message from before the current session, you can restore files to the state when you resumed (not per-message granularity, but a safety net).
|
|
90
|
+
|
|
91
|
+
## Examples
|
|
92
|
+
|
|
93
|
+
### Undo a bad refactor
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
You: refactor the auth module to use JWT
|
|
97
|
+
Agent: [makes changes you don't like]
|
|
98
|
+
|
|
99
|
+
You: /branch
|
|
100
|
+
→ Select "refactor the auth module to use JWT"
|
|
101
|
+
→ Select "Code only (restore files, keep conversation)"
|
|
102
|
+
|
|
103
|
+
Result: Files restored, conversation intact. Try a different approach.
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Start fresh from a checkpoint
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
You: /branch
|
|
110
|
+
→ Select an earlier message
|
|
111
|
+
→ Select "Restore all (files + conversation)"
|
|
112
|
+
|
|
113
|
+
Result: Both files and conversation reset to that point.
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Recover after resuming
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
pi --resume # resume old session
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
Agent: [immediately breaks something]
|
|
124
|
+
|
|
125
|
+
You: /branch
|
|
126
|
+
→ Select any old message
|
|
127
|
+
→ Select "Restore to session start (files only, keep conversation)"
|
|
128
|
+
|
|
129
|
+
Result: Files restored to state when you resumed.
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Viewing Checkpoints
|
|
133
|
+
|
|
134
|
+
List all checkpoint refs:
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
git for-each-ref refs/pi-checkpoints/
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Manually restore to a checkpoint:
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
git checkout refs/pi-checkpoints/checkpoint-1234567890 -- .
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Delete all checkpoints:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
git for-each-ref --format='%(refname)' refs/pi-checkpoints/ | xargs -n1 git update-ref -d
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Uninstalling
|
|
153
|
+
|
|
154
|
+
1. Remove the hook directory:
|
|
155
|
+
```bash
|
|
156
|
+
rm -rf ~/.pi/agent/hooks/rewind
|
|
157
|
+
```
|
|
158
|
+
On Windows (PowerShell): `Remove-Item -Recurse -Force ~/.pi/agent/hooks/rewind`
|
|
159
|
+
|
|
160
|
+
2. Remove the hook from `~/.pi/agent/settings.json` (delete the line with `rewind/index.ts` from the `hooks` array)
|
|
161
|
+
|
|
162
|
+
3. Optionally, clean up git refs in each repo where you used the hook:
|
|
163
|
+
```bash
|
|
164
|
+
git for-each-ref --format='%(refname)' refs/pi-checkpoints/ | xargs -n1 git update-ref -d
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Limitations
|
|
168
|
+
|
|
169
|
+
- Only works in git repositories
|
|
170
|
+
- Checkpoints are per-process (in-memory map), not persisted across restarts
|
|
171
|
+
- Resumed sessions only have a single resume checkpoint for pre-session messages
|
|
172
|
+
- Tracks working directory changes only (not staged/committed changes)
|
package/index.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
|
|
2
|
+
|
|
3
|
+
const REF_PREFIX = "refs/pi-checkpoints/";
|
|
4
|
+
const MAX_CHECKPOINTS = 100;
|
|
5
|
+
|
|
6
|
+
export default function (pi: HookAPI) {
|
|
7
|
+
const checkpoints = new Map<number, string>();
|
|
8
|
+
let resumeCheckpoint: string | null = null;
|
|
9
|
+
|
|
10
|
+
console.error(`[rewind] Hook loaded`);
|
|
11
|
+
|
|
12
|
+
pi.on("session_start", async (event, ctx) => {
|
|
13
|
+
if (!ctx.hasUI) return;
|
|
14
|
+
|
|
15
|
+
const checkpointId = `checkpoint-resume-${Date.now()}`;
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
await ctx.exec("git", [
|
|
19
|
+
"update-ref",
|
|
20
|
+
`${REF_PREFIX}${checkpointId}`,
|
|
21
|
+
"HEAD",
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
resumeCheckpoint = checkpointId;
|
|
25
|
+
console.error(`[rewind] Created resume checkpoint: ${checkpointId}`);
|
|
26
|
+
} catch (err) {
|
|
27
|
+
console.error(`[rewind] Failed to create resume checkpoint: ${err}`);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
pi.on("turn_start", async (event, ctx) => {
|
|
32
|
+
if (!ctx.hasUI) return;
|
|
33
|
+
|
|
34
|
+
const checkpointId = `checkpoint-${event.timestamp}`;
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
await ctx.exec("git", [
|
|
38
|
+
"update-ref",
|
|
39
|
+
`${REF_PREFIX}${checkpointId}`,
|
|
40
|
+
"HEAD",
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
checkpoints.set(event.turnIndex, checkpointId);
|
|
44
|
+
console.error(
|
|
45
|
+
`[rewind] Created checkpoint ${checkpointId} for turn ${event.turnIndex}`
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
await pruneCheckpoints(ctx);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
console.error(`[rewind] Failed to create checkpoint: ${err}`);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
pi.on("branch", async (event, ctx) => {
|
|
55
|
+
if (!ctx.hasUI) return;
|
|
56
|
+
|
|
57
|
+
let checkpointId = checkpoints.get(event.targetTurnIndex);
|
|
58
|
+
let usingResumeCheckpoint = false;
|
|
59
|
+
|
|
60
|
+
if (!checkpointId && resumeCheckpoint) {
|
|
61
|
+
checkpointId = resumeCheckpoint;
|
|
62
|
+
usingResumeCheckpoint = true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!checkpointId) {
|
|
66
|
+
ctx.ui.notify(
|
|
67
|
+
"No checkpoint available for this message",
|
|
68
|
+
"info"
|
|
69
|
+
);
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const options = usingResumeCheckpoint
|
|
74
|
+
? [
|
|
75
|
+
"Restore to session start (files + conversation)",
|
|
76
|
+
"Conversation only (keep current files)",
|
|
77
|
+
"Restore to session start (files only, keep conversation)",
|
|
78
|
+
]
|
|
79
|
+
: [
|
|
80
|
+
"Restore all (files + conversation)",
|
|
81
|
+
"Conversation only (keep current files)",
|
|
82
|
+
"Code only (restore files, keep conversation)",
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
const choice = await ctx.ui.select("Restore Options", options);
|
|
86
|
+
|
|
87
|
+
if (!choice) {
|
|
88
|
+
ctx.ui.notify("Rewind cancelled", "info");
|
|
89
|
+
return { skipConversationRestore: true };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (choice.startsWith("Conversation only")) {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const ref = `${REF_PREFIX}${checkpointId}`;
|
|
98
|
+
await ctx.exec("git", ["checkout", ref, "--", "."]);
|
|
99
|
+
console.error(`[rewind] Restored files from ${checkpointId}`);
|
|
100
|
+
ctx.ui.notify(
|
|
101
|
+
usingResumeCheckpoint
|
|
102
|
+
? "Files restored to session start"
|
|
103
|
+
: "Files restored from checkpoint",
|
|
104
|
+
"success"
|
|
105
|
+
);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
console.error(`[rewind] Failed to restore: ${err}`);
|
|
108
|
+
ctx.ui.notify(`Failed to restore files: ${err}`, "error");
|
|
109
|
+
return { skipConversationRestore: true };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (choice.includes("files only") || choice.startsWith("Code only")) {
|
|
113
|
+
return { skipConversationRestore: true };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return undefined;
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
async function pruneCheckpoints(ctx: { exec: (cmd: string, args: string[]) => Promise<{ stdout: string; stderr: string; code: number }> }) {
|
|
120
|
+
try {
|
|
121
|
+
const result = await ctx.exec("git", [
|
|
122
|
+
"for-each-ref",
|
|
123
|
+
"--sort=creatordate",
|
|
124
|
+
"--format=%(refname)",
|
|
125
|
+
REF_PREFIX,
|
|
126
|
+
]);
|
|
127
|
+
|
|
128
|
+
const refs = result.stdout.trim().split("\n").filter(Boolean);
|
|
129
|
+
|
|
130
|
+
if (refs.length > MAX_CHECKPOINTS) {
|
|
131
|
+
const toDelete = refs.slice(0, refs.length - MAX_CHECKPOINTS);
|
|
132
|
+
for (const ref of toDelete) {
|
|
133
|
+
await ctx.exec("git", ["update-ref", "-d", ref]);
|
|
134
|
+
console.error(`[rewind] Pruned old checkpoint: ${ref}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
} catch (err) {
|
|
138
|
+
console.error(`[rewind] Failed to prune checkpoints: ${err}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
package/install.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const https = require("https");
|
|
6
|
+
const os = require("os");
|
|
7
|
+
|
|
8
|
+
const REPO_URL = "https://raw.githubusercontent.com/nicobailon/pi-rewind-hook/main";
|
|
9
|
+
const HOOK_DIR = path.join(os.homedir(), ".pi", "agent", "hooks", "rewind");
|
|
10
|
+
const SETTINGS_FILE = path.join(os.homedir(), ".pi", "agent", "settings.json");
|
|
11
|
+
const HOOK_PATH = "~/.pi/agent/hooks/rewind/index.ts";
|
|
12
|
+
|
|
13
|
+
function download(url) {
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
https.get(url, (res) => {
|
|
16
|
+
if (res.statusCode === 301 || res.statusCode === 302) {
|
|
17
|
+
return download(res.headers.location).then(resolve).catch(reject);
|
|
18
|
+
}
|
|
19
|
+
if (res.statusCode !== 200) {
|
|
20
|
+
return reject(new Error(`Failed to download ${url}: ${res.statusCode}`));
|
|
21
|
+
}
|
|
22
|
+
let data = "";
|
|
23
|
+
res.on("data", (chunk) => (data += chunk));
|
|
24
|
+
res.on("end", () => resolve(data));
|
|
25
|
+
res.on("error", reject);
|
|
26
|
+
}).on("error", reject);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function main() {
|
|
31
|
+
console.log("Installing pi-rewind-hook...\n");
|
|
32
|
+
|
|
33
|
+
// Create hook directory
|
|
34
|
+
console.log(`Creating directory: ${HOOK_DIR}`);
|
|
35
|
+
fs.mkdirSync(HOOK_DIR, { recursive: true });
|
|
36
|
+
|
|
37
|
+
// Download hook files
|
|
38
|
+
console.log("Downloading index.ts...");
|
|
39
|
+
const hookContent = await download(`${REPO_URL}/index.ts`);
|
|
40
|
+
fs.writeFileSync(path.join(HOOK_DIR, "index.ts"), hookContent);
|
|
41
|
+
|
|
42
|
+
console.log("Downloading README.md...");
|
|
43
|
+
const readmeContent = await download(`${REPO_URL}/README.md`);
|
|
44
|
+
fs.writeFileSync(path.join(HOOK_DIR, "README.md"), readmeContent);
|
|
45
|
+
|
|
46
|
+
// Update settings.json
|
|
47
|
+
console.log(`\nUpdating settings: ${SETTINGS_FILE}`);
|
|
48
|
+
|
|
49
|
+
let settings = {};
|
|
50
|
+
if (fs.existsSync(SETTINGS_FILE)) {
|
|
51
|
+
try {
|
|
52
|
+
settings = JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf-8"));
|
|
53
|
+
} catch (err) {
|
|
54
|
+
console.error(`Warning: Could not parse existing settings.json: ${err.message}`);
|
|
55
|
+
console.error("Creating new settings file...");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Ensure hooks array exists
|
|
60
|
+
if (!Array.isArray(settings.hooks)) {
|
|
61
|
+
settings.hooks = [];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Add hook if not already present
|
|
65
|
+
if (!settings.hooks.includes(HOOK_PATH)) {
|
|
66
|
+
settings.hooks.push(HOOK_PATH);
|
|
67
|
+
|
|
68
|
+
// Ensure parent directory exists
|
|
69
|
+
fs.mkdirSync(path.dirname(SETTINGS_FILE), { recursive: true });
|
|
70
|
+
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2) + "\n");
|
|
71
|
+
console.log(`Added "${HOOK_PATH}" to hooks array`);
|
|
72
|
+
} else {
|
|
73
|
+
console.log("Hook already configured in settings.json");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
console.log("\nInstallation complete!");
|
|
77
|
+
console.log("\nThe rewind hook will load automatically when you start pi.");
|
|
78
|
+
console.log("Use /branch to rewind to a previous checkpoint.");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
main().catch((err) => {
|
|
82
|
+
console.error(`\nInstallation failed: ${err.message}`);
|
|
83
|
+
process.exit(1);
|
|
84
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-rewind-hook",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Rewind hook for Pi agent - automatic git checkpoints with file/conversation restore",
|
|
5
|
+
"bin": {
|
|
6
|
+
"pi-rewind-hook": "./install.js"
|
|
7
|
+
},
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/nicobailon/pi-rewind-hook"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"pi",
|
|
14
|
+
"pi-agent",
|
|
15
|
+
"hook",
|
|
16
|
+
"checkpoint",
|
|
17
|
+
"rewind",
|
|
18
|
+
"git"
|
|
19
|
+
],
|
|
20
|
+
"author": "nicobailon",
|
|
21
|
+
"license": "MIT"
|
|
22
|
+
}
|