@davideasden/pi-undo 0.1.1 → 0.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/README.md +170 -39
- package/extensions/pi-undo.ts +219 -14
- package/package.json +3 -2
- package/src/controller.ts +14 -3
- package/src/diff-ui.ts +116 -0
- package/src/diff-view.ts +169 -0
- package/src/pi-runtime.ts +1 -0
- package/src/restore-engine.ts +57 -9
- package/src/root-discovery.ts +28 -16
- package/src/snapshot-store.ts +291 -48
package/README.md
CHANGED
|
@@ -1,84 +1,215 @@
|
|
|
1
1
|
# pi-undo
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Persistent undo and redo for [Pi](https://github.com/badlogic/pi-mono) that restores both the agent session and workspace files without requiring users to manage Git.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Each history entry represents one completed agent run. When you undo or redo, `pi-undo` moves Pi's session tree to the matching point and restores the corresponding workspace state.
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## Features
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
- Persistent `/undo` and `/redo` across Pi restarts.
|
|
10
|
+
- Restores the Pi session and workspace files together.
|
|
11
|
+
- Preserves redo state with a safety snapshot taken immediately before undo.
|
|
12
|
+
- Integrates with Pi's native `/tree` navigation.
|
|
13
|
+
- Supports ordinary directories, Git repositories, nested repositories, and initialized submodules.
|
|
14
|
+
- Uses durable journals and restart recovery for interrupted operations.
|
|
15
|
+
- Detects conflicting external file changes and fails closed instead of overwriting unknown content.
|
|
16
|
+
- Does not require `git commit`, `git stash`, `git reset`, hidden branches, or any other user-managed Git workflow.
|
|
17
|
+
|
|
18
|
+
## Requirements
|
|
19
|
+
|
|
20
|
+
- Pi `0.80.10` or a compatible release.
|
|
21
|
+
- Node.js `22.19.0` or later.
|
|
22
|
+
- Git available on `PATH`.
|
|
23
|
+
|
|
24
|
+
Git is used internally to create private, content-addressed snapshots. You do not need to initialize a repository or run Git commands yourself.
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
Install the published Pi package from npm:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pi install npm:@davideasden/pi-undo
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Restart Pi after installation if the extension is not loaded in the current session.
|
|
35
|
+
|
|
36
|
+
To install a local checkout instead:
|
|
10
37
|
|
|
11
38
|
```bash
|
|
12
39
|
pi install ./path/to/pi-undo
|
|
13
40
|
```
|
|
14
41
|
|
|
15
|
-
|
|
42
|
+
During development, you can load the extension directly:
|
|
16
43
|
|
|
17
44
|
```bash
|
|
18
45
|
pi -e /absolute/path/to/pi-undo/extensions/pi-undo.ts
|
|
19
46
|
```
|
|
20
47
|
|
|
21
|
-
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
Work with Pi normally. `pi-undo` records a boundary after each completed agent run.
|
|
51
|
+
|
|
52
|
+
### Diff
|
|
53
|
+
|
|
54
|
+
```text
|
|
55
|
+
/diff
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`/diff` reviews the files changed by the most recent completed agent run. In TUI mode, select a file to open a colored, scrollable before-and-after comparison. Binary files are listed but do not receive a line-by-line diff.
|
|
59
|
+
|
|
60
|
+
Use a one-based history position to inspect an earlier applied run, where `1` is the most recent:
|
|
61
|
+
|
|
62
|
+
```text
|
|
63
|
+
/diff 2
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Print, JSON, and RPC modes report a one-line file and line-count summary instead of opening the interactive viewer.
|
|
67
|
+
|
|
68
|
+
### Undo
|
|
69
|
+
|
|
70
|
+
```text
|
|
71
|
+
/undo
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`/undo` returns to the previous completed agent run on the current branch. Before restoring that checkpoint, it captures the current workspace as a redo safety snapshot.
|
|
22
75
|
|
|
23
|
-
|
|
76
|
+
After a successful undo, `pi-undo` tries to put the original prompt back into an empty TUI editor. It never overwrites text already present in the editor. RPC mode only reports the refill request, while print and JSON modes do not promise editor refill behavior.
|
|
24
77
|
|
|
25
|
-
|
|
26
|
-
- `/redo`:恢复最近一次 undo 前保存的工作区状态,而不是简单覆盖成 checkpoint 的原始 after snapshot。
|
|
27
|
-
- `/tree`:仍使用 Pi 原生命令。package 在导航前预检目标 boundary 并保存 rescue snapshot,导航完成后恢复该 boundary 对应的文件状态。
|
|
78
|
+
### Redo
|
|
28
79
|
|
|
29
|
-
|
|
80
|
+
```text
|
|
81
|
+
/redo
|
|
82
|
+
```
|
|
30
83
|
|
|
31
|
-
undo
|
|
84
|
+
`/redo` restores the session and workspace captured immediately before the corresponding undo. It does not simply reapply the checkpoint's original after-snapshot, so edits preserved by the redo safety snapshot are restored correctly.
|
|
32
85
|
|
|
33
|
-
|
|
86
|
+
### Tree Navigation
|
|
34
87
|
|
|
35
|
-
|
|
88
|
+
Continue to use Pi's native command:
|
|
89
|
+
|
|
90
|
+
```text
|
|
91
|
+
/tree
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Before Pi moves to another session-tree boundary, `pi-undo` validates the target and records a rescue snapshot. After navigation, it restores the workspace associated with the selected boundary.
|
|
95
|
+
|
|
96
|
+
If the agent is streaming, `/undo` and `/redo` request an abort and wait for the agent to become idle. If the wait times out or tools are still running, no files are restored. Native `/tree` navigation is cancelled while streaming and can be retried when Pi is idle.
|
|
97
|
+
|
|
98
|
+
The footer shows the available history, for example:
|
|
99
|
+
|
|
100
|
+
```text
|
|
101
|
+
undo:2 redo:1
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## How It Works
|
|
105
|
+
|
|
106
|
+
Private state is stored alongside the Pi session:
|
|
36
107
|
|
|
37
108
|
```text
|
|
38
109
|
<sessionDir>/.pi-undo/
|
|
39
110
|
```
|
|
40
111
|
|
|
41
|
-
|
|
112
|
+
For every completed agent run, `pi-undo` captures the session boundary and a workspace manifest. Snapshots use a private Git object database and temporary index. They do not use the repository's normal history.
|
|
113
|
+
|
|
114
|
+
Undo, redo, and tree restoration follow the same high-level flow:
|
|
115
|
+
|
|
116
|
+
1. Validate the current session, workspace topology, and target manifest.
|
|
117
|
+
2. Write a durable write-ahead log (WAL) before changing files.
|
|
118
|
+
3. Capture a safety or rescue snapshot when required.
|
|
119
|
+
4. Move the Pi session to the target logical boundary.
|
|
120
|
+
5. Restore workspace paths with fingerprint checks and no-clobber installation.
|
|
121
|
+
6. Verify the resulting session and workspace before committing the journal.
|
|
42
122
|
|
|
43
|
-
|
|
123
|
+
Ordinary files and symbolic links are restored through same-directory, same-filesystem quarantine artifacts. For regular files, source capture uses a hard link and checks the path fingerprint and inode identity again immediately before removing the original path. This narrows the external-concurrency window, but cannot make the final check and unlink operation atomic in pure Node.js.
|
|
44
124
|
|
|
45
|
-
|
|
125
|
+
## Safety and Recovery
|
|
46
126
|
|
|
47
|
-
|
|
127
|
+
`pi-undo` does not modify the user's Git `HEAD`, index, refs, reflogs, stash, configuration, or other repository metadata. Nested repositories and initialized submodules are captured as independent roots, but only their working files are restored. The extension does not switch their real `HEAD`, run `git submodule update`, or recreate deleted `.git` metadata.
|
|
48
128
|
|
|
49
|
-
|
|
50
|
-
- 不运行 `git submodule update`;
|
|
51
|
-
- 不修改真实 index、refs、reflog 或 `.git`;
|
|
52
|
-
- 不重建被删除的真实 `.git`。
|
|
129
|
+
If Pi or the process stops during a restore, the next session startup reads the transaction journal:
|
|
53
130
|
|
|
54
|
-
|
|
131
|
+
- Without a trusted cursor marker, it restores the rollback manifest and marks the transaction aborted.
|
|
132
|
+
- With a trusted cursor marker, it restores the target manifest, completes cursor durability, and commits the transaction.
|
|
133
|
+
- If the journal, session identity, logical leaf, manifest, cursor, or workspace contents conflict, it enters `recovery required` and blocks further history mutation.
|
|
55
134
|
|
|
56
|
-
|
|
135
|
+
When the footer reports `recovery_required`, first back up the workspace and Pi session JSONL. Transaction diagnostics are stored in:
|
|
136
|
+
|
|
137
|
+
```text
|
|
138
|
+
<sessionDir>/.pi-undo/transactions/
|
|
139
|
+
```
|
|
57
140
|
|
|
58
|
-
-
|
|
59
|
-
- 有可信 cursor marker:重新恢复 target manifest、补齐 cursor durability,并把操作完成为 `COMMITTED`;
|
|
60
|
-
- journal、session identity、logical leaf、manifest 或 cursor payload 不一致:进入 `recovery required`,停止新的 undo/redo、tree mutation 和 Agent boundary。
|
|
141
|
+
The relevant transaction may contain `descriptor.json`, `restore-plan.json`, `state.json`, and mutation journal data. Do not delete the `.pi-undo` directory without a backup: unresolved quarantine artifacts may contain the only preserved copy of a file version.
|
|
61
142
|
|
|
62
|
-
|
|
143
|
+
## Limitations
|
|
63
144
|
|
|
64
|
-
|
|
145
|
+
- Git-ignored files are not included in snapshots and are not created or deleted during restore.
|
|
146
|
+
- Empty directories are not represented in snapshots.
|
|
147
|
+
- Real `.git` metadata is never restored.
|
|
148
|
+
- The workspace lock coordinates `pi-undo` instances, but cannot prevent editors, watchers, or other processes from writing files concurrently.
|
|
149
|
+
- Quarantine and repeated fingerprint checks reduce, but cannot eliminate, the final check-to-unlink race with arbitrary external processes.
|
|
150
|
+
- When ownership or contents cannot be proven, `pi-undo` preserves the available versions and enters `recovery required` instead of guessing, deleting, or overwriting.
|
|
151
|
+
- `--no-session` mode has no durable session cursor. Undo and redo can work in-process, but crash persistence is not guaranteed.
|
|
152
|
+
- Uninitialized gitlinks are not initialized automatically. A broken nested repository causes capture to fail instead of being silently skipped.
|
|
153
|
+
- Workspace files and Pi JSONL are not an operating-system-level ACID transaction. Snapshots, WAL records, cursor markers, verification, and idempotent recovery are used to converge to the old or new state.
|
|
65
154
|
|
|
66
|
-
|
|
67
|
-
- 空目录不属于 snapshot。
|
|
68
|
-
- package 不恢复真实 `.git` metadata。
|
|
69
|
-
- package 锁只能协调其他 `pi-undo` 实例,不能阻止外部编辑器、watcher 或其他进程同时写文件;无法证明安全时会 fail closed。
|
|
70
|
-
- quarantine 不能消除任意外部进程带来的最终 `check -> unlink` TOCTOU 窗口。无法证明 original、target 或 artifact 的内容与归属时,package 会保留现场并进入 `recovery required`,不会猜测、强制删除或覆盖未知内容。
|
|
71
|
-
- `--no-session` 模式没有可耐久验证的 session cursor,只提供进程内能力,不能承诺进程崩溃后的 undo/redo 持久化。
|
|
72
|
-
- uninitialized gitlink 不会被自动初始化;broken nested root 会使 capture 失败,而不是静默遗漏。
|
|
73
|
-
- package 不对工作区文件与 Pi JSONL 提供操作系统级 ACID 事务;它通过 snapshot、WAL、cursor marker 和幂等 set-state recovery 收敛到旧状态或新状态。
|
|
155
|
+
## Development
|
|
74
156
|
|
|
75
|
-
|
|
157
|
+
Clone the repository and install dependencies:
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
git clone https://github.com/DavidEasden/pi-undo.git
|
|
161
|
+
cd pi-undo
|
|
162
|
+
npm install
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
The development dependency for Pi points to:
|
|
166
|
+
|
|
167
|
+
```text
|
|
168
|
+
resources/pi-0.80.10/packages/coding-agent
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
For type checking and the real `AgentSession` integration tests, place the Pi `0.80.10` source tree at that path, install its workspace dependencies, and build the required workspace packages. The published package does not include `resources/`.
|
|
172
|
+
|
|
173
|
+
Useful project paths:
|
|
174
|
+
|
|
175
|
+
```text
|
|
176
|
+
extensions/pi-undo.ts Pi extension entry point
|
|
177
|
+
src/ Snapshot, journal, restore, and runtime implementation
|
|
178
|
+
test/ Unit, integration, recovery, and fault-injection tests
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## Testing
|
|
182
|
+
|
|
183
|
+
Run the complete test suite:
|
|
76
184
|
|
|
77
185
|
```bash
|
|
78
186
|
npm test
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Run tests in watch mode:
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
npm run test:watch
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Run the Pi runtime and extension integration tests:
|
|
196
|
+
|
|
197
|
+
```bash
|
|
79
198
|
npm run test:integration
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Run TypeScript type checking:
|
|
202
|
+
|
|
203
|
+
```bash
|
|
80
204
|
npm run typecheck
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Inspect the npm package contents before publishing:
|
|
208
|
+
|
|
209
|
+
```bash
|
|
81
210
|
npm run pack:dry-run
|
|
82
211
|
```
|
|
83
212
|
|
|
84
|
-
|
|
213
|
+
## License
|
|
214
|
+
|
|
215
|
+
[MIT](LICENSE)
|
package/extensions/pi-undo.ts
CHANGED
|
@@ -8,12 +8,15 @@ import type {
|
|
|
8
8
|
} from "@earendil-works/pi-coding-agent";
|
|
9
9
|
|
|
10
10
|
import type { UndoController } from "../src/controller.ts";
|
|
11
|
+
import { browseDiff } from "../src/diff-ui.ts";
|
|
12
|
+
import { computeCheckpointDiff, type DiffSource, formatDiffSummary, sanitizeDisplayText } from "../src/diff-view.ts";
|
|
11
13
|
import { createPiUndoRuntime } from "../src/pi-runtime.ts";
|
|
12
14
|
import { StatusReporter } from "../src/status-reporter.ts";
|
|
13
15
|
|
|
14
16
|
export interface PiUndoRuntime {
|
|
15
17
|
readonly controller: UndoController;
|
|
16
18
|
readonly reporter: StatusReporter;
|
|
19
|
+
readonly diffSource?: DiffSource;
|
|
17
20
|
readonly recovery?: { readonly files?: number; readonly opId?: string };
|
|
18
21
|
setCommandContext?(context: ExtensionCommandContext | undefined): void;
|
|
19
22
|
isInternalNavigation?(): boolean;
|
|
@@ -24,13 +27,32 @@ export type PiUndoRuntimeFactory = (
|
|
|
24
27
|
pi: ExtensionAPI,
|
|
25
28
|
) => Promise<PiUndoRuntime>;
|
|
26
29
|
|
|
30
|
+
type DeferredImage = NonNullable<InputEvent["images"]>[number];
|
|
31
|
+
|
|
32
|
+
interface DeferredPrompt {
|
|
33
|
+
readonly text: string;
|
|
34
|
+
readonly images?: readonly DeferredImage[];
|
|
35
|
+
}
|
|
36
|
+
|
|
27
37
|
export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi: ExtensionAPI) => void {
|
|
28
38
|
return (pi) => {
|
|
29
39
|
let runtime: PiUndoRuntime | undefined;
|
|
40
|
+
let runtimeContext: ExtensionContext | undefined;
|
|
30
41
|
let generation = 0;
|
|
42
|
+
let deferredPrompts: DeferredPrompt[] = [];
|
|
43
|
+
let replaying: DeferredPrompt | undefined;
|
|
44
|
+
let acceptedReplay: DeferredPrompt | undefined;
|
|
45
|
+
let activeCommands = new Set<symbol>();
|
|
46
|
+
let activeAction: "undo" | "redo" | undefined;
|
|
31
47
|
|
|
32
48
|
const initialize = async (context: ExtensionContext): Promise<void> => {
|
|
33
49
|
const currentGeneration = ++generation;
|
|
50
|
+
runtimeContext = context;
|
|
51
|
+
deferredPrompts = [];
|
|
52
|
+
replaying = undefined;
|
|
53
|
+
acceptedReplay = undefined;
|
|
54
|
+
activeCommands = new Set<symbol>();
|
|
55
|
+
activeAction = undefined;
|
|
34
56
|
try {
|
|
35
57
|
const next = await runtimeFactory(context, pi);
|
|
36
58
|
if (currentGeneration !== generation) return;
|
|
@@ -46,6 +68,57 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
|
|
|
46
68
|
}
|
|
47
69
|
};
|
|
48
70
|
|
|
71
|
+
const dispatchDeferredPrompt = (active: PiUndoRuntime, expectedGeneration: number): void => {
|
|
72
|
+
if (
|
|
73
|
+
expectedGeneration !== generation || runtime !== active || activeCommands.size > 0 ||
|
|
74
|
+
replaying !== undefined || deferredPrompts.length === 0 || active.controller.history().locked
|
|
75
|
+
) return;
|
|
76
|
+
const prompt = deferredPrompts[0]!;
|
|
77
|
+
replaying = prompt;
|
|
78
|
+
acceptedReplay = undefined;
|
|
79
|
+
queueMicrotask(() => {
|
|
80
|
+
if (expectedGeneration !== generation || runtime !== active || replaying !== prompt) return;
|
|
81
|
+
try {
|
|
82
|
+
pi.sendUserMessage(prompt.images === undefined || prompt.images.length === 0
|
|
83
|
+
? prompt.text
|
|
84
|
+
: [{ type: "text" as const, text: prompt.text }, ...prompt.images]);
|
|
85
|
+
} catch (error) {
|
|
86
|
+
replaying = undefined;
|
|
87
|
+
acceptedReplay = undefined;
|
|
88
|
+
deferredPrompts.shift();
|
|
89
|
+
restoreEditorText(runtimeContext, prompt.text);
|
|
90
|
+
runtimeContext?.ui.notify(`Unable to replay queued prompt: ${errorMessage(error)}`, "warning");
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const restoreDeferredPrompts = (context: ExtensionContext | undefined): void => {
|
|
96
|
+
if (deferredPrompts.length === 0) return;
|
|
97
|
+
const prompts = deferredPrompts.splice(0);
|
|
98
|
+
replaying = undefined;
|
|
99
|
+
acceptedReplay = undefined;
|
|
100
|
+
restoreEditorText(context, prompts.map((prompt) => prompt.text).join("\n\n"));
|
|
101
|
+
if (prompts.some((prompt) => (prompt.images?.length ?? 0) > 0)) {
|
|
102
|
+
context?.ui.notify("Queued prompt text restored; image attachments must be reattached", "warning");
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const resumeDeferredPrompts = (
|
|
107
|
+
active: PiUndoRuntime,
|
|
108
|
+
expectedGeneration: number,
|
|
109
|
+
lockedReason: string,
|
|
110
|
+
): void => {
|
|
111
|
+
if (expectedGeneration !== generation || runtime !== active) return;
|
|
112
|
+
const history = active.controller.history();
|
|
113
|
+
if (history.locked) {
|
|
114
|
+
active.reporter.setRecoveryRequired(lockedReason);
|
|
115
|
+
restoreDeferredPrompts(runtimeContext);
|
|
116
|
+
} else {
|
|
117
|
+
active.reporter.setReady(history.undoCount, history.redoCount);
|
|
118
|
+
dispatchDeferredPrompt(active, expectedGeneration);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
49
122
|
const runCommand = async (
|
|
50
123
|
action: "undo" | "redo",
|
|
51
124
|
context: ExtensionCommandContext,
|
|
@@ -56,6 +129,10 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
|
|
|
56
129
|
context.ui.notify("pi-undo session unavailable", "warning");
|
|
57
130
|
return;
|
|
58
131
|
}
|
|
132
|
+
const commandToken = Symbol(action);
|
|
133
|
+
const commandSet = activeCommands;
|
|
134
|
+
commandSet.add(commandToken);
|
|
135
|
+
activeAction = action;
|
|
59
136
|
active.reporter.setPhase(action === "undo" ? "undoing" : "redoing");
|
|
60
137
|
active.setCommandContext?.(context);
|
|
61
138
|
let result;
|
|
@@ -65,15 +142,56 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
|
|
|
65
142
|
: await active.controller.redo();
|
|
66
143
|
} finally {
|
|
67
144
|
active.setCommandContext?.(undefined);
|
|
145
|
+
commandSet.delete(commandToken);
|
|
146
|
+
if (commandSet === activeCommands && commandSet.size === 0) activeAction = undefined;
|
|
68
147
|
}
|
|
69
148
|
if (commandGeneration !== generation || runtime !== active) return;
|
|
70
149
|
active.reporter.result(result);
|
|
71
|
-
|
|
72
|
-
|
|
150
|
+
const hasDeferredPrompt = deferredPrompts.length > 0 || replaying !== undefined;
|
|
151
|
+
if (
|
|
152
|
+
action === "undo" && result.code === "ok" && result.refillPrompt !== undefined &&
|
|
153
|
+
!hasDeferredPrompt
|
|
154
|
+
) active.reporter.refillPrompt(result.refillPrompt);
|
|
155
|
+
resumeDeferredPrompts(active, commandGeneration, result.message ?? result.code);
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const runDiff = async (args: string, context: ExtensionCommandContext): Promise<void> => {
|
|
159
|
+
const active = runtime;
|
|
160
|
+
if (active === undefined || active.diffSource === undefined) {
|
|
161
|
+
context.ui.notify("pi-undo session unavailable", "warning");
|
|
162
|
+
return;
|
|
73
163
|
}
|
|
74
|
-
const
|
|
75
|
-
if (
|
|
76
|
-
|
|
164
|
+
const checkpoints = active.controller.listCheckpoints();
|
|
165
|
+
if (checkpoints.length === 0) {
|
|
166
|
+
context.ui.notify("No recorded Agent runs to diff", "info");
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const trimmed = args.trim();
|
|
170
|
+
const position = trimmed === "" ? 1 : Number(trimmed);
|
|
171
|
+
if (!Number.isInteger(position) || position < 1 || position > checkpoints.length) {
|
|
172
|
+
context.ui.notify(`Invalid run number; recorded runs: ${checkpoints.length}`, "warning");
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
// 1 = 最近一次 run(栈顶)。
|
|
176
|
+
const checkpoint = checkpoints[checkpoints.length - position]!;
|
|
177
|
+
let diffs;
|
|
178
|
+
try {
|
|
179
|
+
diffs = await computeCheckpointDiff(active.diffSource, checkpoint);
|
|
180
|
+
} catch (error) {
|
|
181
|
+
context.ui.notify(`Unable to load diff: ${sanitizeDisplayText(errorMessage(error), 120)}`, "error");
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (runtime !== active) return;
|
|
185
|
+
if (diffs.length === 0) {
|
|
186
|
+
context.ui.notify("This run changed no files", "info");
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const label = runLabel(checkpoint.rawPrompt, position);
|
|
190
|
+
if (context.mode !== "tui") {
|
|
191
|
+
context.ui.notify(`${label} — ${formatDiffSummary(diffs)}`, "info");
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
await browseDiff(context, label, diffs);
|
|
77
195
|
};
|
|
78
196
|
|
|
79
197
|
pi.registerCommand("undo", {
|
|
@@ -84,21 +202,75 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
|
|
|
84
202
|
description: "Redo the last undone Agent run",
|
|
85
203
|
handler: async (_args: string, context: ExtensionCommandContext) => runCommand("redo", context),
|
|
86
204
|
});
|
|
205
|
+
pi.registerCommand("diff", {
|
|
206
|
+
description: "Review files changed by an Agent run (latest, or /diff N)",
|
|
207
|
+
handler: async (args: string, context: ExtensionCommandContext) => runDiff(args, context),
|
|
208
|
+
});
|
|
87
209
|
|
|
88
210
|
pi.on("session_start", async (_event: unknown, context: ExtensionContext) => initialize(context));
|
|
89
|
-
pi.on("input", async (event: InputEvent) => {
|
|
90
|
-
|
|
91
|
-
|
|
211
|
+
pi.on("input", async (event: InputEvent, context: ExtensionContext) => {
|
|
212
|
+
const active = runtime;
|
|
213
|
+
if (active === undefined) return { action: "handled" as const };
|
|
214
|
+
const result = await active.controller.prepareInput(event.text, {
|
|
215
|
+
streaming: event.streamingBehavior !== undefined,
|
|
216
|
+
});
|
|
217
|
+
const replay = replaying;
|
|
218
|
+
if (result.action === "defer") {
|
|
219
|
+
if (replay !== undefined && event.source === "extension" && samePrompt(event.text, event.images, replay)) {
|
|
220
|
+
replaying = undefined;
|
|
221
|
+
acceptedReplay = undefined;
|
|
222
|
+
active.reporter.setPhase(`${activeAction ?? "operation"} queued:${deferredPrompts.length}`);
|
|
223
|
+
return { action: "handled" as const };
|
|
224
|
+
}
|
|
225
|
+
if (event.text.trimStart().startsWith("/")) {
|
|
226
|
+
restoreEditorText(context, event.text);
|
|
227
|
+
context.ui.notify("Command input preserved until undo/redo completes", "info");
|
|
228
|
+
return { action: "handled" as const };
|
|
229
|
+
}
|
|
230
|
+
deferredPrompts.push({
|
|
231
|
+
text: event.text,
|
|
232
|
+
...(event.images === undefined ? {} : { images: event.images.map((image) => ({ ...image })) }),
|
|
233
|
+
});
|
|
234
|
+
active.reporter.setPhase(`${activeAction ?? "operation"} queued:${deferredPrompts.length}`);
|
|
235
|
+
return { action: "handled" as const };
|
|
236
|
+
}
|
|
237
|
+
if (replay !== undefined && event.source === "extension" && samePrompt(event.text, event.images, replay)) {
|
|
238
|
+
if (result.action === "continue") {
|
|
239
|
+
acceptedReplay = replay;
|
|
240
|
+
} else {
|
|
241
|
+
removeDeferredPrompt(deferredPrompts, replay);
|
|
242
|
+
replaying = undefined;
|
|
243
|
+
acceptedReplay = undefined;
|
|
244
|
+
restoreEditorText(context, replay.text);
|
|
245
|
+
if ((replay.images?.length ?? 0) > 0) {
|
|
246
|
+
context.ui.notify("Queued prompt text restored; image attachments must be reattached", "warning");
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return result;
|
|
251
|
+
});
|
|
252
|
+
pi.on("before_agent_start", async () => {
|
|
253
|
+
const active = runtime;
|
|
254
|
+
const startGeneration = generation;
|
|
255
|
+
if (active === undefined) return;
|
|
256
|
+
await active.controller.beforeAgentStart();
|
|
257
|
+
const replay = replaying;
|
|
258
|
+
if (
|
|
259
|
+
runtime === active && generation === startGeneration && replay !== undefined &&
|
|
260
|
+
acceptedReplay === replay
|
|
261
|
+
) {
|
|
262
|
+
removeDeferredPrompt(deferredPrompts, replay);
|
|
263
|
+
replaying = undefined;
|
|
264
|
+
acceptedReplay = undefined;
|
|
265
|
+
}
|
|
92
266
|
});
|
|
93
|
-
pi.on("before_agent_start", async () => { await runtime?.controller.beforeAgentStart(); });
|
|
94
267
|
pi.on("agent_settled", async () => {
|
|
95
268
|
const active = runtime;
|
|
269
|
+
const settledGeneration = generation;
|
|
96
270
|
if (active === undefined) return;
|
|
97
271
|
await active.controller.agentSettled();
|
|
98
|
-
if (runtime !== active) return;
|
|
99
|
-
|
|
100
|
-
if (history.locked) active.reporter.setRecoveryRequired("session state ambiguous");
|
|
101
|
-
else active.reporter.setReady(history.undoCount, history.redoCount);
|
|
272
|
+
if (runtime !== active || generation !== settledGeneration) return;
|
|
273
|
+
resumeDeferredPrompts(active, settledGeneration, "session state ambiguous");
|
|
102
274
|
});
|
|
103
275
|
pi.on("session_before_tree", async (event: PiSessionBeforeTreeEvent) => {
|
|
104
276
|
if (runtime === undefined) return { cancel: true };
|
|
@@ -106,7 +278,7 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
|
|
|
106
278
|
const active = runtime;
|
|
107
279
|
const result = await active.controller.beforeTree({ targetLeafId: event.preparation.targetId });
|
|
108
280
|
if (result === undefined) {
|
|
109
|
-
|
|
281
|
+
event.signal?.addEventListener("abort", () => { void active.controller.cancelTree?.(); }, { once: true });
|
|
110
282
|
}
|
|
111
283
|
return result;
|
|
112
284
|
});
|
|
@@ -119,6 +291,12 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
|
|
|
119
291
|
});
|
|
120
292
|
pi.on("session_shutdown", async () => {
|
|
121
293
|
generation += 1;
|
|
294
|
+
deferredPrompts = [];
|
|
295
|
+
replaying = undefined;
|
|
296
|
+
acceptedReplay = undefined;
|
|
297
|
+
activeCommands = new Set<symbol>();
|
|
298
|
+
activeAction = undefined;
|
|
299
|
+
runtimeContext = undefined;
|
|
122
300
|
await runtime?.controller.cancelTree?.();
|
|
123
301
|
runtime?.reporter.clear();
|
|
124
302
|
runtime = undefined;
|
|
@@ -126,8 +304,35 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
|
|
|
126
304
|
};
|
|
127
305
|
}
|
|
128
306
|
|
|
307
|
+
function samePrompt(
|
|
308
|
+
text: string,
|
|
309
|
+
images: readonly DeferredImage[] | undefined,
|
|
310
|
+
prompt: DeferredPrompt,
|
|
311
|
+
): boolean {
|
|
312
|
+
if (text !== prompt.text || (images?.length ?? 0) !== (prompt.images?.length ?? 0)) return false;
|
|
313
|
+
return (images ?? []).every((image, index) => JSON.stringify(image) === JSON.stringify(prompt.images?.[index]));
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function removeDeferredPrompt(prompts: DeferredPrompt[], prompt: DeferredPrompt): void {
|
|
317
|
+
const index = prompts.indexOf(prompt);
|
|
318
|
+
if (index >= 0) prompts.splice(index, 1);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function restoreEditorText(context: ExtensionContext | undefined, text: string): void {
|
|
322
|
+
if (context === undefined || text.length === 0) return;
|
|
323
|
+
const current = context.ui.getEditorText();
|
|
324
|
+
if (current === text || current.startsWith(`${text}\n\n`)) return;
|
|
325
|
+
context.ui.setEditorText(current.length === 0 ? text : `${text}\n\n${current}`);
|
|
326
|
+
}
|
|
327
|
+
|
|
129
328
|
function errorMessage(error: unknown): string {
|
|
130
329
|
return error instanceof Error ? error.message : String(error);
|
|
131
330
|
}
|
|
132
331
|
|
|
332
|
+
function runLabel(rawPrompt: string, position: number): string {
|
|
333
|
+
const singleLine = sanitizeDisplayText(rawPrompt, 1_000);
|
|
334
|
+
const preview = singleLine.length > 48 ? `${singleLine.slice(0, 47)}…` : singleLine;
|
|
335
|
+
return preview === "" ? `Run #${position}` : `Run #${position}: ${preview}`;
|
|
336
|
+
}
|
|
337
|
+
|
|
133
338
|
export default createPiUndoExtension(createPiUndoRuntime);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davideasden/pi-undo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Persistent workspace undo and redo for Pi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -42,7 +42,8 @@
|
|
|
42
42
|
"pack:dry-run": "npm pack --dry-run"
|
|
43
43
|
},
|
|
44
44
|
"peerDependencies": {
|
|
45
|
-
"@earendil-works/pi-coding-agent": "^0.80.10"
|
|
45
|
+
"@earendil-works/pi-coding-agent": "^0.80.10",
|
|
46
|
+
"@earendil-works/pi-tui": "*"
|
|
46
47
|
},
|
|
47
48
|
"dependencies": {
|
|
48
49
|
"proper-lockfile": "4.1.2"
|
package/src/controller.ts
CHANGED
|
@@ -80,9 +80,10 @@ export interface OperationResult {
|
|
|
80
80
|
readonly refillPrompt?: string;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
export
|
|
84
|
-
readonly action: "continue"
|
|
85
|
-
}
|
|
83
|
+
export type InputEventResult =
|
|
84
|
+
| { readonly action: "continue" }
|
|
85
|
+
| { readonly action: "handled" }
|
|
86
|
+
| { readonly action: "defer" };
|
|
86
87
|
|
|
87
88
|
export interface InputContext {
|
|
88
89
|
readonly streaming: boolean;
|
|
@@ -108,6 +109,8 @@ export interface HistoryState {
|
|
|
108
109
|
}
|
|
109
110
|
|
|
110
111
|
export interface UndoController {
|
|
112
|
+
/** 只读的 undo 栈视图(栈底在前);仅供 /diff 等展示使用。 */
|
|
113
|
+
listCheckpoints(): readonly CheckpointRecord[];
|
|
111
114
|
prepareInput(text: string, context: InputContext): Promise<InputEventResult>;
|
|
112
115
|
beforeAgentStart(): Promise<void>;
|
|
113
116
|
agentSettled(): Promise<void>;
|
|
@@ -161,6 +164,7 @@ export class UndoControllerImpl implements UndoController {
|
|
|
161
164
|
private locked = false;
|
|
162
165
|
private historyPaused = false;
|
|
163
166
|
private operationInFlight = false;
|
|
167
|
+
private promptDeferralInFlight = false;
|
|
164
168
|
private lastSafetyManifestId: ManifestId | null = null;
|
|
165
169
|
|
|
166
170
|
constructor(dependencies: ControllerDependencies, initialState: ControllerInitialState = {}) {
|
|
@@ -175,7 +179,12 @@ export class UndoControllerImpl implements UndoController {
|
|
|
175
179
|
return { undoCount: this.undoStack.length, redoCount: this.redoStack.length, locked: this.locked };
|
|
176
180
|
}
|
|
177
181
|
|
|
182
|
+
listCheckpoints(): readonly CheckpointRecord[] {
|
|
183
|
+
return [...this.undoStack];
|
|
184
|
+
}
|
|
185
|
+
|
|
178
186
|
async prepareInput(text: string, context: InputContext): Promise<InputEventResult> {
|
|
187
|
+
if (this.promptDeferralInFlight) return { action: "defer" };
|
|
179
188
|
if (this.locked || this.operationInFlight) return { action: "handled" };
|
|
180
189
|
if (context.streaming || text.length === 0) return { action: "continue" };
|
|
181
190
|
try {
|
|
@@ -376,6 +385,7 @@ export class UndoControllerImpl implements UndoController {
|
|
|
376
385
|
): Promise<OperationResult> {
|
|
377
386
|
if (this.locked || this.operationInFlight) return { code: "busy", changedFiles: 0 };
|
|
378
387
|
this.operationInFlight = true;
|
|
388
|
+
this.promptDeferralInFlight = true;
|
|
379
389
|
this.lastSafetyManifestId = null;
|
|
380
390
|
let lease: { release(): Promise<void> } | undefined;
|
|
381
391
|
try {
|
|
@@ -448,6 +458,7 @@ export class UndoControllerImpl implements UndoController {
|
|
|
448
458
|
if (lease !== undefined) {
|
|
449
459
|
await lease.release().catch(() => { this.locked = true; });
|
|
450
460
|
}
|
|
461
|
+
this.promptDeferralInFlight = false;
|
|
451
462
|
this.operationInFlight = false;
|
|
452
463
|
}
|
|
453
464
|
}
|