backstep 0.1.28 → 0.1.30
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 +1 -0
- package/dist/adapters/fs.js +17 -1
- package/dist/adapters/tree.js +27 -0
- package/dist/cli.js +34 -6
- package/dist/manual.js +127 -0
- package/dist/setup.js +12 -8
- package/dist/undo.js +55 -9
- package/dist/update.js +1 -1
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.test.tsx +187 -8
- package/mod/hooks/register.tsx +208 -26
- package/mod/types/index.d.ts +4 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -19,6 +19,7 @@ Undo for everything your agents touch. Backstep sits between your agent (Claude
|
|
|
19
19
|
- **Things that can't be undone** (sending email, payments, deploys, `ALTER TABLE`…) run as normal and are marked in the journal, so you know what an undo didn't cover.
|
|
20
20
|
- **Anything else, by rule:** a rules file maps a tool to its opposite (`create_issue` → close the issue, `send_message` → retract it), filled in from the call and its result. See [Undo rules](#undo-rules).
|
|
21
21
|
- **Conflicts:** undoing an action first undoes any later action that touched the same file or table, so nothing gets silently thrown away.
|
|
22
|
+
- **Your own edits:** Backstep notes how Claude left each file. If a file changed after that (you typed in it, or a formatter or `git pull` touched it), undo names it and asks first: **Keep my changes** undoes Claude's change and keeps yours (or leaves the file as you had it, where both changed the same lines), and **Undo them too** puts it back as it was before Claude's turn.
|
|
22
23
|
|
|
23
24
|
## Install
|
|
24
25
|
|
package/dist/adapters/fs.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// File undo: copy every path a tool is about to touch, then put it all back.
|
|
2
2
|
// Restoring "the state each path was in" handles writes, edits, deletes,
|
|
3
3
|
// creates and moves with one rule.
|
|
4
|
-
import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, rmSync } from "node:fs";
|
|
4
|
+
import { copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, readdirSync, rmSync } from "node:fs";
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
6
|
import { snapshotDir } from "../store.js";
|
|
7
7
|
import { insidePath, resolveIn } from "../paths.js";
|
|
@@ -52,6 +52,22 @@ export function snapshotPaths(actionId, paths, cwd) {
|
|
|
52
52
|
}
|
|
53
53
|
return { adapter: "fs", entries };
|
|
54
54
|
}
|
|
55
|
+
// Once the tool has run: a copy of each file as it left it.
|
|
56
|
+
export function recordAfter(actionId, undo) {
|
|
57
|
+
const dir = join(snapshotDir(actionId), "after");
|
|
58
|
+
const entries = undo.entries.map((e, i) => {
|
|
59
|
+
if (!existsSync(e.path))
|
|
60
|
+
return { ...e, after: { copy: null } };
|
|
61
|
+
const st = lstatSync(e.path);
|
|
62
|
+
if (!st.isFile() || st.size > MAX_BYTES)
|
|
63
|
+
return e;
|
|
64
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
65
|
+
const copy = join(dir, String(i));
|
|
66
|
+
copyFileSync(e.path, copy);
|
|
67
|
+
return { ...e, after: { copy } };
|
|
68
|
+
});
|
|
69
|
+
return { ...undo, entries };
|
|
70
|
+
}
|
|
55
71
|
export function undoPaths(undo) {
|
|
56
72
|
const restored = [];
|
|
57
73
|
for (const e of undo.entries) {
|
package/dist/adapters/tree.js
CHANGED
|
@@ -210,6 +210,31 @@ export function snapshotTree(actionId, cwd, opts = {}) {
|
|
|
210
210
|
return null;
|
|
211
211
|
}
|
|
212
212
|
}
|
|
213
|
+
// Once the command has run: the folder as it left it, kept like the snapshot before it.
|
|
214
|
+
export function recordTreeAfter(u, actionId) {
|
|
215
|
+
const tree = stage(u.gitDir, u.root);
|
|
216
|
+
const after = git(["commit-tree", tree, "-m", `${actionId} after`], { gitDir: u.gitDir, workTree: u.root });
|
|
217
|
+
git(["update-ref", `refs/backstep/${actionId}-after`, after], { gitDir: u.gitDir, workTree: u.root });
|
|
218
|
+
return { ...u, after };
|
|
219
|
+
}
|
|
220
|
+
// Files that differ now from how the command left them: [status, path from the root],
|
|
221
|
+
// A made since, D deleted since, M changed since.
|
|
222
|
+
export function changedSinceAfter(u) {
|
|
223
|
+
if (!u.after)
|
|
224
|
+
return [];
|
|
225
|
+
const now = stage(u.gitDir, u.root);
|
|
226
|
+
const out = git(["diff-tree", "-r", "-z", "--no-renames", "--name-status", u.after, now], { gitDir: u.gitDir, workTree: u.root });
|
|
227
|
+
const parts = out.split("\0").filter(Boolean);
|
|
228
|
+
const rows = [];
|
|
229
|
+
for (let i = 0; i + 1 < parts.length; i += 2)
|
|
230
|
+
rows.push([parts[i], parts[i + 1]]);
|
|
231
|
+
return rows;
|
|
232
|
+
}
|
|
233
|
+
// A file's bytes as the command left it.
|
|
234
|
+
export function afterBlob(u, path) {
|
|
235
|
+
const pre = ["-c", "core.fsmonitor=false", "--git-dir", u.gitDir];
|
|
236
|
+
return execFileSync("git", [...pre, "cat-file", "blob", `${u.after}:${path}`], { env: ENV, timeout: TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] });
|
|
237
|
+
}
|
|
213
238
|
// After a command that pushes: each remote branch that moved, and how far.
|
|
214
239
|
export function notePushed(u) {
|
|
215
240
|
const before = u.git?.remotes;
|
|
@@ -240,6 +265,8 @@ export function undoTree(u, actionId) {
|
|
|
240
265
|
const before = git(["diff", "--cached", "--name-status", u.commit], { gitDir, workTree: root });
|
|
241
266
|
git(["read-tree", "--reset", "-u", u.commit], { gitDir, workTree: root });
|
|
242
267
|
git(["update-ref", "-d", `refs/backstep/${actionId}`], { gitDir, workTree: root });
|
|
268
|
+
if (u.after)
|
|
269
|
+
git(["update-ref", "-d", `refs/backstep/${actionId}-after`], { gitDir, workTree: root });
|
|
243
270
|
const lines = [];
|
|
244
271
|
for (const row of before.split("\n").filter(Boolean)) {
|
|
245
272
|
const [status, path] = row.split("\t");
|
package/dist/cli.js
CHANGED
|
@@ -4,9 +4,9 @@ import { fileURLToPath } from "node:url";
|
|
|
4
4
|
import { runProxy } from "./proxy.js";
|
|
5
5
|
import { startUi } from "./ui/server.js";
|
|
6
6
|
import { Store, snapshotDir } from "./store.js";
|
|
7
|
-
import { pruneDatabaseCopies, undoAction, undoRun, undoSince } from "./undo.js";
|
|
8
|
-
import { insideDir, snapshotPaths } from "./adapters/fs.js";
|
|
9
|
-
import { notePushed, snapshotSqlite, snapshotTree } from "./adapters/tree.js";
|
|
7
|
+
import { pendingManual, pruneDatabaseCopies, undoAction, undoRun, undoSince } from "./undo.js";
|
|
8
|
+
import { insideDir, recordAfter, snapshotPaths } from "./adapters/fs.js";
|
|
9
|
+
import { notePushed, recordTreeAfter, snapshotSqlite, snapshotTree } from "./adapters/tree.js";
|
|
10
10
|
import { revertPush } from "./push.js";
|
|
11
11
|
import { ghMade, grantScope, openPage, undoGh } from "./github.js";
|
|
12
12
|
import { accessNow, accessOnline, currentNote, login, logout, requireAccess } from "./account.js";
|
|
@@ -121,8 +121,10 @@ const HELP = `backstep: undo for everything your agents touch
|
|
|
121
121
|
backstep log [--limit 30] print recent actions
|
|
122
122
|
backstep undo <action-id> undo one action (and later ones that touched the same things)
|
|
123
123
|
backstep undo --run <run-id> undo everything a run did
|
|
124
|
-
backstep undo --since <ms> [--until <ms>] [--json] [--via-claude-code]
|
|
125
|
-
undo everything any agent did in that window
|
|
124
|
+
backstep undo --since <ms> [--until <ms>] [--json] [--via-claude-code] [--manual ask|undo|keep]
|
|
125
|
+
undo everything any agent did in that window; files changed since
|
|
126
|
+
(often by hand) are undone too, kept, or named first with ask
|
|
127
|
+
backstep after <action-id> note how a tool left the files, once it ran (used by the Claude Code mod)
|
|
126
128
|
backstep snap --session <id> --tool <name> [--cwd <dir>] -- <paths...>
|
|
127
129
|
save paths before Claude Code's own tools change them (used by the Claude Code mod)
|
|
128
130
|
backstep revert-push --root <repo>
|
|
@@ -620,6 +622,18 @@ async function main() {
|
|
|
620
622
|
return;
|
|
621
623
|
}
|
|
622
624
|
// Claude Code ran an opposite call `undo --via-claude-code` handed it.
|
|
625
|
+
// Once a tool Claude ran has finished (run by the Claude Code mod): notes how it
|
|
626
|
+
// left the files, so undo can tell changes made since apart from Claude's.
|
|
627
|
+
if (cmd === "after") {
|
|
628
|
+
const store = new Store();
|
|
629
|
+
const a = store.getAction(rest[0] ?? "");
|
|
630
|
+
const u = a?.undo ? JSON.parse(a.undo) : null;
|
|
631
|
+
if (a && u?.adapter === "fs")
|
|
632
|
+
store.updateAction(a.id, { undo: JSON.stringify(recordAfter(a.id, u)) });
|
|
633
|
+
if (a && u?.adapter === "tree")
|
|
634
|
+
store.updateAction(a.id, { undo: JSON.stringify(recordTreeAfter(u, a.id)) });
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
623
637
|
if (cmd === "settle") {
|
|
624
638
|
const { values, positionals } = parseArgs({ args: rest, allowPositionals: true, options: { failed: { type: "string" } } });
|
|
625
639
|
const store = new Store();
|
|
@@ -655,11 +669,25 @@ async function main() {
|
|
|
655
669
|
json: { type: "boolean", default: false },
|
|
656
670
|
"via-claude-code": { type: "boolean", default: false },
|
|
657
671
|
session: { type: "string" },
|
|
672
|
+
manual: { type: "string" },
|
|
658
673
|
},
|
|
659
674
|
});
|
|
660
675
|
const store = new Store();
|
|
676
|
+
const manual = values.manual ?? "undo";
|
|
677
|
+
if (manual !== "ask" && manual !== "undo" && manual !== "keep")
|
|
678
|
+
throw new Error("--manual is ask, undo or keep");
|
|
679
|
+
const window = { viaClaudeCode: values["via-claude-code"], session: values.session };
|
|
680
|
+
const until = values.until ? Number(values.until) : undefined;
|
|
681
|
+
// Asked first: files changed since Claude left them are named, and nothing is undone yet.
|
|
682
|
+
if (manual === "ask" && values.since) {
|
|
683
|
+
const files = pendingManual(store, Number(values.since), until, window);
|
|
684
|
+
if (files.length) {
|
|
685
|
+
console.log(values.json ? JSON.stringify({ manual: files }) : `These files changed since Claude's turn:\n ${files.join("\n ")}\nRun again with --manual keep (keep those changes) or --manual undo (undo them too).`);
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
661
689
|
const outcomes = values.since
|
|
662
|
-
? await undoSince(store, Number(values.since),
|
|
690
|
+
? await undoSince(store, Number(values.since), until, { ...window, manual: manual === "keep" ? "keep" : "undo" })
|
|
663
691
|
: values.run
|
|
664
692
|
? await undoRun(store, values.run)
|
|
665
693
|
: await undoAction(store, positionals[0] ?? "");
|
package/dist/manual.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Changes made since Claude's: usually a person typing, sometimes a formatter or
|
|
2
|
+
// a git pull. Undo puts files back as they were before Claude's turn, which
|
|
3
|
+
// would take these along, so they're found first and the person asked.
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
import { afterBlob, changedSinceAfter } from "./adapters/tree.js";
|
|
9
|
+
import { insidePath } from "./paths.js";
|
|
10
|
+
function readNow(path) {
|
|
11
|
+
if (!existsSync(path))
|
|
12
|
+
return null;
|
|
13
|
+
// A folder isn't compared: undefined says "can't tell".
|
|
14
|
+
return lstatSync(path).isFile() ? readFileSync(path) : undefined;
|
|
15
|
+
}
|
|
16
|
+
const same = (a, b) => (a === null || b === null ? a === b : a.equals(b));
|
|
17
|
+
// The chain undo is about to run, newest first. Each file is checked against the
|
|
18
|
+
// newest of Claude's actions that touched it: that's how Claude last left it.
|
|
19
|
+
export function manualChanges(chain) {
|
|
20
|
+
const ordered = [...chain].sort((x, y) => y.created_at - x.created_at || y.seq - x.seq);
|
|
21
|
+
const files = new Set();
|
|
22
|
+
const roots = [];
|
|
23
|
+
const found = [];
|
|
24
|
+
const covered = (p) => files.has(p) || roots.some((r) => insidePath(p, r));
|
|
25
|
+
for (const a of ordered) {
|
|
26
|
+
const u = a.undo ? JSON.parse(a.undo) : null;
|
|
27
|
+
if (!u)
|
|
28
|
+
continue;
|
|
29
|
+
if (u.adapter === "fs") {
|
|
30
|
+
for (const e of u.entries) {
|
|
31
|
+
if (covered(e.path))
|
|
32
|
+
continue;
|
|
33
|
+
files.add(e.path);
|
|
34
|
+
// Noted before this version, or a folder: nothing to compare with.
|
|
35
|
+
if (!e.after)
|
|
36
|
+
continue;
|
|
37
|
+
const now = readNow(e.path);
|
|
38
|
+
if (now === undefined)
|
|
39
|
+
continue;
|
|
40
|
+
const claude = e.after.copy && existsSync(e.after.copy) ? readFileSync(e.after.copy) : null;
|
|
41
|
+
if (e.after.copy && claude === null)
|
|
42
|
+
continue;
|
|
43
|
+
if (!same(claude, now))
|
|
44
|
+
found.push({ path: e.path, claude, now });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
else if (u.adapter === "tree") {
|
|
48
|
+
const t = u;
|
|
49
|
+
if (roots.some((r) => insidePath(t.root, r)))
|
|
50
|
+
continue;
|
|
51
|
+
roots.push(t.root);
|
|
52
|
+
let rows = [];
|
|
53
|
+
try {
|
|
54
|
+
rows = changedSinceAfter(t);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
for (const [status, rel] of rows) {
|
|
60
|
+
const path = join(t.root, rel);
|
|
61
|
+
// A newer edit of Claude's to this file is what it's compared with instead.
|
|
62
|
+
if (files.has(path))
|
|
63
|
+
continue;
|
|
64
|
+
try {
|
|
65
|
+
const now = status === "D" ? null : readNow(path);
|
|
66
|
+
if (now === undefined)
|
|
67
|
+
continue;
|
|
68
|
+
found.push({ path, claude: status === "A" ? null : afterBlob(t, rel), now });
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// unreadable: leave it out rather than guess
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return found;
|
|
77
|
+
}
|
|
78
|
+
function put(path, content) {
|
|
79
|
+
if (content === null) {
|
|
80
|
+
rmSync(path, { force: true });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
84
|
+
writeFileSync(path, content);
|
|
85
|
+
}
|
|
86
|
+
// Undo has put Claude's files back as they were before; now each changed file
|
|
87
|
+
// gets its later changes back. Where both edited different lines, Claude's
|
|
88
|
+
// change stays undone and theirs is kept; where they overlap, the file is left
|
|
89
|
+
// exactly as it was before undo, rather than guessed at.
|
|
90
|
+
// Returns each file's line verb: kept (merged), left (as it was), overlap (as it was, lines clash).
|
|
91
|
+
export function keepManual(changes) {
|
|
92
|
+
const result = new Map();
|
|
93
|
+
for (const c of changes) {
|
|
94
|
+
const before = existsSync(c.path) && lstatSync(c.path).isFile() ? readFileSync(c.path) : null;
|
|
95
|
+
const text = (b) => b !== null && !b.includes(0);
|
|
96
|
+
if (c.now === null || c.claude === null || before === null || !text(c.now) || !text(c.claude) || !text(before)) {
|
|
97
|
+
put(c.path, c.now);
|
|
98
|
+
result.set(c.path, "left");
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const dir = mkdtempSync(join(tmpdir(), "backstep-merge-"));
|
|
102
|
+
try {
|
|
103
|
+
const [mine, base, theirs] = ["now", "claude", "before"].map((n) => join(dir, n));
|
|
104
|
+
writeFileSync(mine, c.now);
|
|
105
|
+
writeFileSync(base, c.claude);
|
|
106
|
+
writeFileSync(theirs, before);
|
|
107
|
+
let merged = null;
|
|
108
|
+
try {
|
|
109
|
+
merged = execFileSync("git", ["merge-file", "-p", mine, base, theirs], { stdio: ["ignore", "pipe", "pipe"], timeout: 20_000 });
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
// a clash (git exits with the number of them) or no git: keep theirs as is
|
|
113
|
+
}
|
|
114
|
+
put(c.path, merged ?? c.now);
|
|
115
|
+
result.set(c.path, merged ? "kept" : "overlap");
|
|
116
|
+
}
|
|
117
|
+
finally {
|
|
118
|
+
rmSync(dir, { recursive: true, force: true });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
123
|
+
// Puts each changed file back exactly as it was before undo started.
|
|
124
|
+
export function restoreManual(changes) {
|
|
125
|
+
for (const c of changes)
|
|
126
|
+
put(c.path, c.now);
|
|
127
|
+
}
|
package/dist/setup.js
CHANGED
|
@@ -76,6 +76,15 @@ export async function setup() {
|
|
|
76
76
|
const cli = join(root, "dist", "cli.js");
|
|
77
77
|
// Without `backstep` on the PATH, the commands printed below go through npx.
|
|
78
78
|
const bs = onPath ? "backstep" : "npx -y backstep";
|
|
79
|
+
// Only a problem is worth a line here; the summary below says the rest.
|
|
80
|
+
const bound = ensureUndoKey();
|
|
81
|
+
if (bound)
|
|
82
|
+
console.log(` ${white("!")} ${bound}`);
|
|
83
|
+
// Servers point at the copy just put in place, which is whole by now.
|
|
84
|
+
autoWrap(true);
|
|
85
|
+
wrapServers(cli);
|
|
86
|
+
// The mod last: installing it makes Claude Code reload Backstep there, which
|
|
87
|
+
// ends the code that ran this update, so everything else is done first.
|
|
79
88
|
const install = () => run("claude", ["plugin", "install", "backstep", "--marketplace", join(root, "mod"), "--config", `cli=${cli}`]);
|
|
80
89
|
let r = install();
|
|
81
90
|
// Installed before from another copy (the file a person was given, or ~/.backstep before npm worked):
|
|
@@ -90,19 +99,14 @@ export async function setup() {
|
|
|
90
99
|
// was; re-reading the folder and updating brings the mod up to this version.
|
|
91
100
|
run("claude", ["plugin", "marketplace", "update", "backstep"]);
|
|
92
101
|
run("claude", ["plugin", "update", "backstep@backstep"]);
|
|
93
|
-
// Only a problem is worth a line here; the summary below says the rest.
|
|
94
|
-
const bound = ensureUndoKey();
|
|
95
|
-
if (bound)
|
|
96
|
-
console.log(` ${white("!")} ${bound}`);
|
|
97
|
-
// Wrap last, so a failed install never leaves servers pointing at a Backstep that isn't there.
|
|
98
|
-
autoWrap(true);
|
|
99
|
-
wrapServers(cli);
|
|
100
102
|
// What's covered and what isn't, said once here and kept up to date by `backstep status`.
|
|
101
103
|
const key = (undoKeys().length ? undoKeys() : defaultKeys()).map(keyLabel).join(" or ");
|
|
102
104
|
console.log(`\n ${pink("✓")} ${white("Backstep is ready.")} Restart Claude Code, then press ${white(key)} after any turn to undo it.`);
|
|
103
105
|
console.log(dim(` Want a different key? Just ask Claude: "change my Backstep undo key to ctrl+q"`));
|
|
104
106
|
console.log(dim(` To remove Backstep, type /uninstall-backstep in Claude Code, or ask Claude to uninstall it.`));
|
|
105
|
-
|
|
107
|
+
// An update runs this out of sight, so the summary (a few seconds of checks) is skipped there.
|
|
108
|
+
if (process.env.BACKSTEP_UPDATING !== "1")
|
|
109
|
+
printCheck(check(process.cwd()), bs);
|
|
106
110
|
if (telemetryOn()) {
|
|
107
111
|
console.log(dim(" Sends an anonymous install count. Turn it off with BACKSTEP_TELEMETRY=0.\n"));
|
|
108
112
|
await sendInstall();
|
package/dist/undo.js
CHANGED
|
@@ -9,6 +9,7 @@ import { dbKey, firebaseConn, freshUrl, restKey } from "./supabase.js";
|
|
|
9
9
|
import { undoFirebase } from "./adapters/firestore.js";
|
|
10
10
|
import { undoRest } from "./adapters/rest.js";
|
|
11
11
|
import { insidePath } from "./paths.js";
|
|
12
|
+
import { keepManual, manualChanges, restoreManual } from "./manual.js";
|
|
12
13
|
const INVERSE_TIMEOUT_MS = 20_000;
|
|
13
14
|
// Database copies sit in the person's own database, so only the newest few per
|
|
14
15
|
// database are kept; older commands keep their file undo but not their database undo.
|
|
@@ -208,14 +209,16 @@ export async function undoRun(store, runId) {
|
|
|
208
209
|
}
|
|
209
210
|
// Everything any agent did in a window of time: what one turn of a chat did,
|
|
210
211
|
// across Claude's own edits and every server wrapped by Backstep.
|
|
211
|
-
|
|
212
|
-
|
|
212
|
+
// What one turn of a chat did, in a window of time, across Claude's own edits and
|
|
213
|
+
// every server wrapped by Backstep: only this Claude Code session's, when it says which.
|
|
214
|
+
function inWindow(store, since, until, opts) {
|
|
215
|
+
let found = store.db
|
|
213
216
|
.prepare("SELECT * FROM actions WHERE created_at >= ? AND created_at <= ? ORDER BY created_at, seq")
|
|
214
217
|
.all(since, until);
|
|
215
218
|
if (opts.session) {
|
|
216
219
|
const mine = `session:${opts.session}`;
|
|
217
220
|
const commands = new Map();
|
|
218
|
-
|
|
221
|
+
found = found.filter((a) => {
|
|
219
222
|
if (!commands.has(a.run_id))
|
|
220
223
|
commands.set(a.run_id, store.getRun(a.run_id)?.command);
|
|
221
224
|
const command = commands.get(a.run_id);
|
|
@@ -224,11 +227,18 @@ export async function undoSince(store, since, until = Date.now(), opts = {}) {
|
|
|
224
227
|
return !(command?.startsWith("session:") && command !== mine);
|
|
225
228
|
});
|
|
226
229
|
}
|
|
227
|
-
return
|
|
230
|
+
return found.filter(undoable);
|
|
228
231
|
}
|
|
229
|
-
async function
|
|
230
|
-
|
|
231
|
-
|
|
232
|
+
export async function undoSince(store, since, until = Date.now(), opts = {}) {
|
|
233
|
+
return undoAll(store, inWindow(store, since, until, opts), opts);
|
|
234
|
+
}
|
|
235
|
+
// Files undoing this window would also take changes made since out of, without undoing anything.
|
|
236
|
+
export function pendingManual(store, since, until = Date.now(), opts = {}) {
|
|
237
|
+
const actions = inWindow(store, since, until, opts);
|
|
238
|
+
return actions.length ? [...new Set(manualChanges(chainOf(store, actions)).map((c) => c.path))] : [];
|
|
239
|
+
}
|
|
240
|
+
// Every action to undo, newest first: these, and every later one touching the same things.
|
|
241
|
+
function chainOf(store, actions) {
|
|
232
242
|
// Start from the oldest, which pulls in every later conflict across runs too.
|
|
233
243
|
const chain = [];
|
|
234
244
|
const seen = new Set();
|
|
@@ -240,6 +250,42 @@ async function undoAll(store, actions, opts = {}) {
|
|
|
240
250
|
}
|
|
241
251
|
}
|
|
242
252
|
}
|
|
243
|
-
chain.sort((x, y) => y.created_at - x.created_at || y.seq - x.seq);
|
|
244
|
-
|
|
253
|
+
return chain.sort((x, y) => y.created_at - x.created_at || y.seq - x.seq);
|
|
254
|
+
}
|
|
255
|
+
async function undoAll(store, actions, opts = {}) {
|
|
256
|
+
if (actions.length === 0)
|
|
257
|
+
return [];
|
|
258
|
+
const chain = chainOf(store, actions);
|
|
259
|
+
if (opts.manual !== "keep")
|
|
260
|
+
return runChain(store, chain, opts);
|
|
261
|
+
const changes = manualChanges(chain);
|
|
262
|
+
const out = await runChain(store, chain, opts);
|
|
263
|
+
if (!changes.length)
|
|
264
|
+
return out;
|
|
265
|
+
// Stopped partway: those files go back to how they were before undo started.
|
|
266
|
+
if (out.some((o) => !o.ok)) {
|
|
267
|
+
restoreManual(changes);
|
|
268
|
+
return out;
|
|
269
|
+
}
|
|
270
|
+
const verbs = keepManual(changes);
|
|
271
|
+
// Each file's line says what became of it: "restored x" turns into "kept x", "left x" or "overlap x".
|
|
272
|
+
const said = new Set();
|
|
273
|
+
for (const o of out) {
|
|
274
|
+
o.detail = o.detail
|
|
275
|
+
.split("\n")
|
|
276
|
+
.map((line) => {
|
|
277
|
+
const m = line.match(/^(restored|removed) (.*)$/);
|
|
278
|
+
const verb = m && verbs.get(m[2]);
|
|
279
|
+
if (!verb)
|
|
280
|
+
return line;
|
|
281
|
+
said.add(m[2]);
|
|
282
|
+
return `${verb} ${m[2]}`;
|
|
283
|
+
})
|
|
284
|
+
.join("\n");
|
|
285
|
+
}
|
|
286
|
+
const unsaid = [...verbs].filter(([p]) => !said.has(p)).map(([p, v]) => `${v} ${p}`);
|
|
287
|
+
const last = out.findLast((o) => o.ok);
|
|
288
|
+
if (unsaid.length && last)
|
|
289
|
+
last.detail = [last.detail, ...unsaid].filter(Boolean).join("\n");
|
|
290
|
+
return out;
|
|
245
291
|
}
|
package/dist/update.js
CHANGED
|
@@ -80,7 +80,7 @@ export function runUpdate(v, tries = 4) {
|
|
|
80
80
|
const r = spawnSync("npx", ["-y", "--prefer-online", `backstep@${v}`, "setup"], {
|
|
81
81
|
encoding: "utf8",
|
|
82
82
|
timeout: 5 * 60 * 1000,
|
|
83
|
-
env: { ...process.env, NO_COLOR: "1", BACKSTEP_TELEMETRY: "0" },
|
|
83
|
+
env: { ...process.env, NO_COLOR: "1", BACKSTEP_TELEMETRY: "0", BACKSTEP_UPDATING: "1" },
|
|
84
84
|
stdio: ["ignore", "pipe", "pipe"],
|
|
85
85
|
cwd: tmpdir(),
|
|
86
86
|
});
|
|
@@ -127,7 +127,7 @@ test('pressing undo rewinds the turn and tells Claude on the next prompt', { opt
|
|
|
127
127
|
const pressed = ui.press({ key: 'undo' })
|
|
128
128
|
await clock.settle()
|
|
129
129
|
expect((await ui.find({ type: 'Text', text: /Rewinding/ }))?.text).toBeTruthy()
|
|
130
|
-
expect(runs.at(-1)).toEqual(['node', '--disable-warning=ExperimentalWarning', CLI, 'undo', '--since', '5000', '--session', 's1', '--json', '--via-claude-code'])
|
|
130
|
+
expect(runs.at(-1)).toEqual(['node', '--disable-warning=ExperimentalWarning', CLI, 'undo', '--since', '5000', '--session', 's1', '--json', '--via-claude-code', '--manual', 'ask'])
|
|
131
131
|
|
|
132
132
|
await clock.advance(1000)
|
|
133
133
|
await pressed
|
|
@@ -271,7 +271,8 @@ test('servers added since setup are wrapped as a session starts', { options: { c
|
|
|
271
271
|
|
|
272
272
|
// A claude.ai connector: Backstep can't sit in front of it, so the mod reports
|
|
273
273
|
// the call and, on undo, runs the rule's opposite call through Claude Code.
|
|
274
|
-
function connector(on: On, opts: { start: object; undoable: boolean; mcpFails?: boolean; undo?: object }) {
|
|
274
|
+
function connector(on: On, opts: { start: object; undoable: boolean; mcpFails?: boolean; mcpThrows?: string; undo?: object }) {
|
|
275
|
+
const prompts: string[] = []
|
|
275
276
|
const runs: { argv: string[]; stdin?: string }[] = []
|
|
276
277
|
const calls: { server: string; tool: string; args: Record<string, unknown> }[] = []
|
|
277
278
|
const ran: Record<string, unknown>[] = []
|
|
@@ -295,6 +296,7 @@ function connector(on: On, opts: { start: object; undoable: boolean; mcpFails?:
|
|
|
295
296
|
return { value: { exitCode: 0, stdout: out ? JSON.stringify(out) : '', stderr: '', isStdoutTruncated: false, isStderrTruncated: false } }
|
|
296
297
|
})
|
|
297
298
|
on('mcp.call', (_, e) => {
|
|
299
|
+
if (opts.mcpThrows) return { value: { content: [{ type: 'text', text: opts.mcpThrows }], isError: true } }
|
|
298
300
|
calls.push({ server: e.server, tool: e.tool, args: e.args })
|
|
299
301
|
if (e.tool === 'notion-fetch') return { value: { content: [{ type: 'text', text: `page ${String(e.args.id)} as it was` }], isError: false } }
|
|
300
302
|
return { value: { content: [{ type: 'text', text: opts.mcpFails ? 'no such page' : 'archived' }], isError: opts.mcpFails === true } }
|
|
@@ -303,12 +305,15 @@ function connector(on: On, opts: { start: object; undoable: boolean; mcpFails?:
|
|
|
303
305
|
ran.push({ ...e })
|
|
304
306
|
return { result: { content: [{ type: 'text', text: '{"id":"p1"}' }] }, text: '{"id":"p1"}' }
|
|
305
307
|
})
|
|
306
|
-
on('prompt.submit', (_, e) =>
|
|
308
|
+
on('prompt.submit', (_, e) => {
|
|
309
|
+
prompts.push(e.text)
|
|
310
|
+
return { text: e.text, context: e.context }
|
|
311
|
+
})
|
|
307
312
|
on('ui.render', ($, e) => {
|
|
308
313
|
const { Text } = $.ui.resolve(e)
|
|
309
314
|
return <Text>engine</Text>
|
|
310
315
|
})
|
|
311
|
-
return { runs, calls, ran, toasts }
|
|
316
|
+
return { runs, calls, ran, toasts, prompts }
|
|
312
317
|
}
|
|
313
318
|
|
|
314
319
|
test('a connector call with an undo rule is undone through Claude Code', { options: { cli: CLI } }, async ($, on) => {
|
|
@@ -552,8 +557,9 @@ test('after undoing a turn that pushed, Backstep asks and pushes the undo', { op
|
|
|
552
557
|
|
|
553
558
|
await $.prompt.submit({ text: 'ship it', wait: false, origin: { kind: 'composer' } })
|
|
554
559
|
await $.tool.call({ tool: 'Bash', command: 'git push' })
|
|
555
|
-
// The push is noted once it has run.
|
|
556
|
-
expect(runs.at(-
|
|
560
|
+
// The push is noted once it has run, then how it left the project.
|
|
561
|
+
expect(runs.at(-2)?.argv.slice(3)).toEqual(['shell-end', 'p1', '--cwd', '/repo'])
|
|
562
|
+
expect(runs.at(-1)?.argv.slice(3)).toEqual(['after', 'p1'])
|
|
557
563
|
|
|
558
564
|
const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
|
|
559
565
|
await ui.press({ key: 'undo' })
|
|
@@ -744,7 +750,7 @@ test('about to change a Supabase project that is not connected, Backstep asks fi
|
|
|
744
750
|
await $.tool.call({ tool: 'Bash', command })
|
|
745
751
|
expect(asked).toHaveLength(1)
|
|
746
752
|
const order = runs.map((r) => r[3])
|
|
747
|
-
expect(order).toEqual(['db-check', 'connect', 'shell'])
|
|
753
|
+
expect(order).toEqual(['db-check', 'connect', 'shell', 'after'])
|
|
748
754
|
expect(runs[1].slice(3)).toEqual(['connect', 'supabase', '--dir', '/work/taskflow', '--json'])
|
|
749
755
|
|
|
750
756
|
// Asked once a session per project, whatever the answer.
|
|
@@ -801,7 +807,7 @@ test('when the last turn only committed and pushed, undo offers to take back the
|
|
|
801
807
|
await clock.advance(1000)
|
|
802
808
|
await pressed
|
|
803
809
|
// Both turns: everything since the first one started.
|
|
804
|
-
expect(runs.find((r) => r.includes('undo'))?.slice(3)).toEqual(['undo', '--since', '5000', '--session', 's1', '--json', '--via-claude-code'])
|
|
810
|
+
expect(runs.find((r) => r.includes('undo'))?.slice(3)).toEqual(['undo', '--since', '5000', '--session', 's1', '--json', '--via-claude-code', '--manual', 'ask'])
|
|
805
811
|
await ui.unmount()
|
|
806
812
|
})
|
|
807
813
|
|
|
@@ -1176,3 +1182,176 @@ test('in the desktop app, the update is offered the same way and says not to clo
|
|
|
1176
1182
|
expect(statuses[0]).toContain("don't close Claude Code")
|
|
1177
1183
|
expect(said).not.toContain('terminal')
|
|
1178
1184
|
})
|
|
1185
|
+
|
|
1186
|
+
// Files changed after Claude's turn: undo asks before taking those changes too.
|
|
1187
|
+
function handEdited(on: On, answer: string | null) {
|
|
1188
|
+
const runs: string[][] = []
|
|
1189
|
+
const asked: string[] = []
|
|
1190
|
+
const toasts: string[] = []
|
|
1191
|
+
on('session.cwd', () => ({ value: '/repo' }))
|
|
1192
|
+
on('session.id', () => ({ value: 's1' }))
|
|
1193
|
+
on('ui.log', () => ({ value: undefined }))
|
|
1194
|
+
on('ui.toast', (_, e) => {
|
|
1195
|
+
toasts.push(e.text)
|
|
1196
|
+
return { value: undefined }
|
|
1197
|
+
})
|
|
1198
|
+
on('process.run', (_, e) => {
|
|
1199
|
+
const argv = [...e.argv]
|
|
1200
|
+
runs.push(argv)
|
|
1201
|
+
const manual = argv.includes('undo') ? argv[argv.indexOf('--manual') + 1] : null
|
|
1202
|
+
const stdout =
|
|
1203
|
+
argv.includes('snap') ? JSON.stringify({ id: 'c1', undoable: true })
|
|
1204
|
+
: manual === 'ask' ? JSON.stringify({ manual: ['/repo/src/a.ts'] })
|
|
1205
|
+
: manual === 'keep' ? JSON.stringify([{ id: 'c1', tool: 'Edit', ok: true, detail: 'kept /repo/src/a.ts' }])
|
|
1206
|
+
: manual === 'undo' ? JSON.stringify([{ id: 'c1', tool: 'Edit', ok: true, detail: 'restored /repo/src/a.ts' }])
|
|
1207
|
+
: ''
|
|
1208
|
+
return { value: { exitCode: 0, stdout, stderr: '', isStdoutTruncated: false, isStderrTruncated: false } }
|
|
1209
|
+
})
|
|
1210
|
+
on('tool.call', { tool: 'AskUserQuestion' }, (_, e) => {
|
|
1211
|
+
const questions = (e as unknown as { questions: { question: string }[] }).questions
|
|
1212
|
+
asked.push(questions[0].question)
|
|
1213
|
+
return { result: { questions, answers: answer ? { [questions[0].question]: answer } : {} } }
|
|
1214
|
+
})
|
|
1215
|
+
on('tool.call', () => ({ result: {} }))
|
|
1216
|
+
on('prompt.submit', (_, e) => ({ text: e.text, context: e.context }))
|
|
1217
|
+
on('ui.render', ($, e) => {
|
|
1218
|
+
const { Text } = $.ui.resolve(e)
|
|
1219
|
+
return <Text>engine</Text>
|
|
1220
|
+
})
|
|
1221
|
+
return { runs, asked, toasts }
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
test('an edit is noted once it has run, and undo asks about files changed since, keeping them on Keep my changes', { options: { cli: CLI } }, async ($, on) => {
|
|
1225
|
+
const clock = mock.clock(on, { now: 5000 })
|
|
1226
|
+
const { runs, asked } = handEdited(on, 'Keep my changes')
|
|
1227
|
+
await $.prompt.submit({ text: 'fix it', wait: false, origin: { kind: 'composer' } })
|
|
1228
|
+
await $.tool.call({ tool: 'Edit', file_path: '/repo/src/a.ts', old_string: 'a', new_string: 'b' })
|
|
1229
|
+
expect(runs.at(-1)?.slice(3)).toEqual(['after', 'c1'])
|
|
1230
|
+
|
|
1231
|
+
const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
|
|
1232
|
+
await ui.press({ key: 'undo' })
|
|
1233
|
+
const pressed = ui.press({ key: 'undo' })
|
|
1234
|
+
await clock.advance(3000)
|
|
1235
|
+
await pressed
|
|
1236
|
+
await clock.advance(500)
|
|
1237
|
+
expect(asked).toEqual(["src/a.ts was changed after Claude's turn (by you, or by another tool). Undo those changes too?"])
|
|
1238
|
+
const undos = runs.filter((r) => r.includes('undo')).map((r) => r.at(-1))
|
|
1239
|
+
expect(undos).toEqual(['ask', 'keep'])
|
|
1240
|
+
expect(await ui.find({ type: 'Text', text: /src\/a\.ts: Claude's changes undone, yours kept/ })).toBeTruthy()
|
|
1241
|
+
await ui.unmount()
|
|
1242
|
+
})
|
|
1243
|
+
|
|
1244
|
+
test('dismissing the question about changed files undoes nothing', { options: { cli: CLI } }, async ($, on) => {
|
|
1245
|
+
const clock = mock.clock(on, { now: 5000 })
|
|
1246
|
+
const { runs, toasts } = handEdited(on, null)
|
|
1247
|
+
await $.prompt.submit({ text: 'fix it', wait: false, origin: { kind: 'composer' } })
|
|
1248
|
+
await $.tool.call({ tool: 'Edit', file_path: '/repo/src/a.ts', old_string: 'a', new_string: 'b' })
|
|
1249
|
+
const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
|
|
1250
|
+
await ui.press({ key: 'undo' })
|
|
1251
|
+
const pressed = ui.press({ key: 'undo' })
|
|
1252
|
+
await clock.advance(3000)
|
|
1253
|
+
await pressed
|
|
1254
|
+
expect(runs.filter((r) => r.includes('undo')).map((r) => r.at(-1))).toEqual(['ask'])
|
|
1255
|
+
expect(toasts).toContain('Undo cancelled. Nothing was changed.')
|
|
1256
|
+
expect(await ui.find({ type: 'Text', text: /Rewinding|Rewound/ })).toBeFalsy()
|
|
1257
|
+
await ui.unmount()
|
|
1258
|
+
})
|
|
1259
|
+
|
|
1260
|
+
test('an update that reloads the mod partway is finished by the reloaded mod: it says done and takes the warning down', { options: { cli: CLI } }, async ($, on) => {
|
|
1261
|
+
const clock = mock.clock(on, { now: 5000 })
|
|
1262
|
+
let started = false
|
|
1263
|
+
const logs: string[] = []
|
|
1264
|
+
const statuses: (string | undefined)[] = []
|
|
1265
|
+
on('process.run', (_, e) => {
|
|
1266
|
+
const argv = [...e.argv]
|
|
1267
|
+
// The update itself never answers: installing it reloaded the mod that was waiting on it.
|
|
1268
|
+
if (argv.includes('update') && argv.includes('--json')) {
|
|
1269
|
+
started = true
|
|
1270
|
+
return new Promise(() => {})
|
|
1271
|
+
}
|
|
1272
|
+
const stdout = argv.includes('update-check')
|
|
1273
|
+
? JSON.stringify({ current: started ? '0.2.0' : '0.1.2', latest: '0.2.0', available: !started })
|
|
1274
|
+
: argv.includes('status')
|
|
1275
|
+
? JSON.stringify({ items: [], suggest: [] })
|
|
1276
|
+
: '[]'
|
|
1277
|
+
return { value: { exitCode: 0, stdout, stderr: '', isStdoutTruncated: false, isStderrTruncated: false } }
|
|
1278
|
+
})
|
|
1279
|
+
on('ui.toast', () => ({ value: undefined }))
|
|
1280
|
+
on('ui.log', (_, e) => {
|
|
1281
|
+
logs.push(e.text)
|
|
1282
|
+
return { value: undefined }
|
|
1283
|
+
})
|
|
1284
|
+
on('ui.status', (_, e) => {
|
|
1285
|
+
statuses.push(e.text)
|
|
1286
|
+
return { value: undefined }
|
|
1287
|
+
})
|
|
1288
|
+
on('tool.register', (_, e) => ({ value: { tool: `mcp__backstep__${e.name}` } }))
|
|
1289
|
+
on('tool.call', { tool: 'AskUserQuestion' }, (_, e) => {
|
|
1290
|
+
const questions = (e as unknown as { questions: { question: string }[] }).questions
|
|
1291
|
+
return { result: { questions, answers: { [questions[0].question]: 'Update' } } }
|
|
1292
|
+
})
|
|
1293
|
+
on('session.start', (_, e) => ({ cwd: e.cwd }))
|
|
1294
|
+
on('session.cwd', () => ({ value: '/repo' }))
|
|
1295
|
+
on('session.id', () => ({ value: 's1' }))
|
|
1296
|
+
on('tool.call', () => ({ result: {} }))
|
|
1297
|
+
on('ui.render', ($, e) => {
|
|
1298
|
+
const { Text } = $.ui.resolve(e)
|
|
1299
|
+
return <Text>engine</Text>
|
|
1300
|
+
})
|
|
1301
|
+
|
|
1302
|
+
void $.session.start({ cwd: '/repo', surface: 'terminal', hasPerson: true } as never)
|
|
1303
|
+
for (let i = 0; i < 10 && !started; i++) {
|
|
1304
|
+
await settle()
|
|
1305
|
+
await clock.advance(1000)
|
|
1306
|
+
}
|
|
1307
|
+
expect(started).toBe(true)
|
|
1308
|
+
expect(statuses.at(-1)).toContain("don't close this terminal")
|
|
1309
|
+
|
|
1310
|
+
const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
|
|
1311
|
+
for (let i = 0; i < 5; i++) {
|
|
1312
|
+
await clock.advance(5000)
|
|
1313
|
+
await settle()
|
|
1314
|
+
}
|
|
1315
|
+
expect(statuses.at(-1)).toBeUndefined()
|
|
1316
|
+
expect(logs.join('\n')).toContain('Backstep is updated to 0.2.0 (you had 0.1.2). It\'s safe to close this terminal now.')
|
|
1317
|
+
await ui.unmount()
|
|
1318
|
+
})
|
|
1319
|
+
|
|
1320
|
+
test("when auto mode won't let Backstep undo a Notion change, Claude is asked to, and the change counts as undone once it has", { options: { cli: CLI } }, async ($, on) => {
|
|
1321
|
+
const clock = mock.clock(on, { now: 5000 })
|
|
1322
|
+
const refusal = "backstep: $.mcp.call(claude_ai_Notion, notion-delete-pages) refused: The server-side auto mode classifier gave no verdict for mcp__claude_ai_Notion__notion-delete-pages"
|
|
1323
|
+
const asked: string[] = []
|
|
1324
|
+
on('tool.call', { tool: 'AskUserQuestion' }, (_, e) => {
|
|
1325
|
+
const questions = (e as unknown as { questions: { question: string }[] }).questions
|
|
1326
|
+
asked.push(questions[0].question)
|
|
1327
|
+
return { result: { questions, answers: { [questions[0].question]: 'Ask Claude to do it' } } }
|
|
1328
|
+
})
|
|
1329
|
+
const { runs, ran, prompts } = connector(on, {
|
|
1330
|
+
start: { kind: 'reversible', id: 'w1' },
|
|
1331
|
+
undoable: true,
|
|
1332
|
+
mcpThrows: refusal,
|
|
1333
|
+
undo: { id: 'w1', tool: 'notion-create-pages', ok: true, detail: 'Notion: deleted the page', run: { server: 'claude_ai_Notion', tool: 'notion-delete-pages', args: { page_ids: ['p1'] }, label: 'Notion: delete the page Claude made' } },
|
|
1334
|
+
})
|
|
1335
|
+
await $.prompt.submit({ text: 'make a page', wait: false, origin: { kind: 'composer' } })
|
|
1336
|
+
await $.tool.call({ tool: 'mcp__claude_ai_Notion__notion-create-pages', pages: [{ title: 'Test' }], tool_use_id: 'tu1' } as never)
|
|
1337
|
+
|
|
1338
|
+
const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
|
|
1339
|
+
await ui.press({ key: 'undo' })
|
|
1340
|
+
const pressed = ui.press({ key: 'undo' })
|
|
1341
|
+
await clock.advance(1000)
|
|
1342
|
+
await pressed
|
|
1343
|
+
await clock.advance(1000)
|
|
1344
|
+
await settle()
|
|
1345
|
+
expect(runs.some((r) => r.argv.includes('settle') && r.argv.includes('--failed'))).toBe(true)
|
|
1346
|
+
expect(asked).toEqual(["Claude Code's auto mode didn't let Backstep undo this by itself: Notion: delete the page Claude made. Ask Claude to undo it?"])
|
|
1347
|
+
expect(prompts.at(-1)).toContain('mcp__claude_ai_Notion__notion-delete-pages with {"page_ids":["p1"]}')
|
|
1348
|
+
|
|
1349
|
+
// Claude makes the call: it isn't recorded as a change of its own, and the change is marked undone.
|
|
1350
|
+
const before = ran.length
|
|
1351
|
+
const watched = runs.filter((r) => r.argv.includes('watch-start')).length
|
|
1352
|
+
await $.tool.call({ tool: 'mcp__claude_ai_Notion__notion-delete-pages', page_ids: ['p1'], tool_use_id: 'tu2' } as never)
|
|
1353
|
+
expect(ran.length).toBe(before + 1)
|
|
1354
|
+
expect(runs.filter((r) => r.argv.includes('watch-start')).length).toBe(watched)
|
|
1355
|
+
expect(runs.at(-1)?.argv.slice(3)).toEqual(['settle', 'w1'])
|
|
1356
|
+
await ui.unmount()
|
|
1357
|
+
})
|
package/mod/hooks/register.tsx
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { atom, read, update } from 'claude-code'
|
|
8
8
|
import type { EngineInterface, Register } from 'claude-code'
|
|
9
9
|
|
|
10
|
-
import type { GithubCode, Rewind, RewindItem, Turn } from '../types'
|
|
10
|
+
import type { GithubCode, Rewind, RewindItem, Turn, Updating } from '../types'
|
|
11
11
|
|
|
12
12
|
const turns = atom({ plugin: 'backstep', key: 'turns' } as const, [] as Turn[])
|
|
13
13
|
const rewind = atom({ plugin: 'backstep', key: 'rewind' } as const, null as Rewind | null)
|
|
@@ -20,6 +20,12 @@ const keys = atom({ plugin: 'backstep', key: 'keys' } as const, ['ctrl+u'] as st
|
|
|
20
20
|
const githubCode = atom({ plugin: 'backstep', key: 'githubCode' } as const, null as GithubCode | null)
|
|
21
21
|
// Why ctrl+u can't run here, when getbackstep.com says so (a version too old, or no sign-in when one is needed).
|
|
22
22
|
const blocked = atom({ plugin: 'backstep', key: 'blocked' } as const, null as string | null)
|
|
23
|
+
// An update under way. Installing it reloads this mod partway through, which
|
|
24
|
+
// ends the code waiting on it, so the reloaded mod finishes the job: it says
|
|
25
|
+
// the update is done and takes the "don't close" line down.
|
|
26
|
+
const updating = atom({ plugin: 'backstep', key: 'updating' } as const, null as Updating | null)
|
|
27
|
+
// Whether this load of the mod has looked for an update the last one left running.
|
|
28
|
+
let resumed = false
|
|
23
29
|
// Backstep's mark, as on the logo: the front rewind triangle in the terminal's
|
|
24
30
|
// own black or white, the one behind it in the logo's pink.
|
|
25
31
|
const PINK = '#ee7bb6'
|
|
@@ -301,23 +307,49 @@ async function undoLastTurn($: EngineInterface, count = 1) {
|
|
|
301
307
|
if (!target) return
|
|
302
308
|
|
|
303
309
|
await update($, rewind, (): Rewind => ({ phase: 'running', frame: 0, shown: 0, items: [], message: '' }))
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
310
|
+
const spin = () =>
|
|
311
|
+
$.clock.every(55, () => {
|
|
312
|
+
void update($, rewind, (r) => (r && r.phase === 'running' ? { ...r, frame: r.frame + 1 } : r))
|
|
313
|
+
})
|
|
314
|
+
let ticker = spin()
|
|
307
315
|
|
|
308
316
|
const cwd = await $.session.cwd()
|
|
309
317
|
const short = (p: string) => relPath(p, cwd)
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
318
|
+
const sid = await $.session.id()
|
|
319
|
+
const undo = (manual: 'ask' | 'keep' | 'undo') =>
|
|
320
|
+
Promise.all([
|
|
321
|
+
$.process.run(backstep(['undo', '--since', String(target.startedAt), '--session', sid, '--json', '--via-claude-code', '--manual', manual])).catch((err: unknown) => ({
|
|
322
|
+
exitCode: 1,
|
|
323
|
+
stdout: '',
|
|
324
|
+
stderr: err instanceof Error ? err.message : String(err),
|
|
325
|
+
})),
|
|
326
|
+
// Long enough to read as a rewind, short enough to never feel slow.
|
|
327
|
+
$.clock.sleep(700),
|
|
328
|
+
]).then(([r]) => r)
|
|
329
|
+
let ran = await undo('ask')
|
|
319
330
|
ticker.cancel()
|
|
320
331
|
|
|
332
|
+
// Files changed since Claude left them, usually by hand: nothing was undone yet, so ask first.
|
|
333
|
+
const changed = manualFiles(ran.stdout)
|
|
334
|
+
if (changed) {
|
|
335
|
+
const names = changed.map(short)
|
|
336
|
+
const list = names.length > 3 ? `${names.slice(0, 3).join(', ')} and ${plural(names.length - 3, 'more file')}` : names.length === 1 ? names[0] : `${names.slice(0, -1).join(', ')} and ${names.at(-1)}`
|
|
337
|
+
const answer = await $.ui
|
|
338
|
+
.ask(`${list} ${names.length === 1 ? 'was' : 'were'} changed after Claude's turn (by you, or by another tool). Undo those changes too?`, {
|
|
339
|
+
header: 'Backstep',
|
|
340
|
+
options: [MANUAL.keep, MANUAL.undo],
|
|
341
|
+
})
|
|
342
|
+
.catch(() => null)
|
|
343
|
+
if (answer !== MANUAL.keep && answer !== MANUAL.undo) {
|
|
344
|
+
await update($, rewind, () => null)
|
|
345
|
+
$.ui.toast('Undo cancelled. Nothing was changed.')
|
|
346
|
+
return
|
|
347
|
+
}
|
|
348
|
+
ticker = spin()
|
|
349
|
+
ran = await undo(answer === MANUAL.keep ? 'keep' : 'undo')
|
|
350
|
+
ticker.cancel()
|
|
351
|
+
}
|
|
352
|
+
|
|
321
353
|
let outcomes: Outcome[] = []
|
|
322
354
|
try {
|
|
323
355
|
outcomes = JSON.parse(ran.stdout || '[]') as Outcome[]
|
|
@@ -329,19 +361,22 @@ async function undoLastTurn($: EngineInterface, count = 1) {
|
|
|
329
361
|
}
|
|
330
362
|
|
|
331
363
|
// Opposite calls on servers only Claude Code is connected to run here, newest first.
|
|
364
|
+
let handoff: Handoff | null = null
|
|
332
365
|
for (const o of outcomes) {
|
|
333
366
|
if (!o.ok || !o.run) continue
|
|
334
367
|
const run = o.run
|
|
335
368
|
let failed: { tool: string; why: string } | null = null
|
|
336
369
|
// The first opposite call, then any more on the same server (a move back to several places).
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
content: [{ type: 'text' as const, text: err instanceof Error ? err.message : String(err) }],
|
|
341
|
-
}))
|
|
370
|
+
const all = [{ tool: run.tool, args: run.args }, ...(run.more ?? [])]
|
|
371
|
+
for (const [i, call] of all.entries()) {
|
|
372
|
+
const done = await undoCall($, run.server, call.tool, call.args)
|
|
342
373
|
if (done.isError) {
|
|
343
374
|
const why = done.content.map((c) => (c.type === 'text' ? c.text : '')).join(' ').trim().split('\n')[0] || 'failed'
|
|
344
|
-
|
|
375
|
+
// Claude Code's auto mode won't let Backstep make it alone: Claude can, if the person says so.
|
|
376
|
+
if (REFUSED.test(why)) {
|
|
377
|
+
handoff = { id: o.id, server: run.server, calls: all.slice(i), label: run.label ?? `${o.tool}: ${call.tool}` }
|
|
378
|
+
failed = { tool: call.tool, why: "Claude Code's auto mode didn't let Backstep make this change itself" }
|
|
379
|
+
} else failed = { tool: call.tool, why }
|
|
345
380
|
break
|
|
346
381
|
}
|
|
347
382
|
}
|
|
@@ -365,7 +400,7 @@ async function undoLastTurn($: EngineInterface, count = 1) {
|
|
|
365
400
|
(o.detail || o.tool)
|
|
366
401
|
.split('\n')
|
|
367
402
|
.filter(Boolean)
|
|
368
|
-
.map((line) => ({ label: line
|
|
403
|
+
.map((line) => ({ label: undoneLabel(line, short), ok: true })),
|
|
369
404
|
)
|
|
370
405
|
const stopped = outcomes.filter((o) => !o.ok).map((o) => ({ label: `${o.tool}: ${o.detail.split('\n')[0] ?? 'failed'}`, ok: false }))
|
|
371
406
|
// A push, or something made on GitHub, is offered back below, so it isn't listed as lost.
|
|
@@ -417,7 +452,9 @@ async function undoLastTurn($: EngineInterface, count = 1) {
|
|
|
417
452
|
}
|
|
418
453
|
|
|
419
454
|
// Asked once the band shows what came back: GitHub things first, then pushes, then Vercel.
|
|
420
|
-
|
|
455
|
+
// A change auto mode wouldn't let Backstep make comes first of all: it's where undo stopped.
|
|
456
|
+
void (handoff ? offerHandoff($, handoff) : Promise.resolve())
|
|
457
|
+
.then(() => offerGithubUndo($, outcomes))
|
|
421
458
|
.then((gone) => offerPushUndo($, outcomes, gone.size > 0))
|
|
422
459
|
.then(() => offerVercelUndo($, outcomes))
|
|
423
460
|
|
|
@@ -525,6 +562,90 @@ function backstep(args: string[]): string[] {
|
|
|
525
562
|
return cli === 'backstep' ? ['backstep', ...args] : ['node', '--disable-warning=ExperimentalWarning', cli, ...args]
|
|
526
563
|
}
|
|
527
564
|
|
|
565
|
+
// The two answers when files changed after Claude's turn; keeping them comes first, so a quick Enter loses nothing.
|
|
566
|
+
const MANUAL = { keep: 'Keep my changes', undo: 'Undo them too' } as const
|
|
567
|
+
|
|
568
|
+
// The files `backstep undo --manual ask` named, when it stopped to ask.
|
|
569
|
+
function manualFiles(stdout: string): string[] | null {
|
|
570
|
+
try {
|
|
571
|
+
const r = JSON.parse(stdout || 'null') as unknown
|
|
572
|
+
return r && !Array.isArray(r) && Array.isArray((r as { manual?: unknown }).manual) && (r as { manual: unknown[] }).manual.length ? (r as { manual: string[] }).manual : null
|
|
573
|
+
} catch {
|
|
574
|
+
return null
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// One line of what undo did, as the band and the conversation show it.
|
|
579
|
+
function undoneLabel(line: string, short: (p: string) => string): string {
|
|
580
|
+
const m = line.match(/^(restored|removed|kept|left|overlap) (.*)$/)
|
|
581
|
+
if (!m) return line
|
|
582
|
+
const p = short(m[2])
|
|
583
|
+
if (m[1] === 'kept') return `${p}: Claude's changes undone, yours kept`
|
|
584
|
+
if (m[1] === 'left') return `${p} left as you had it`
|
|
585
|
+
if (m[1] === 'overlap') return `${p} left as you had it: your changes overlap Claude's`
|
|
586
|
+
return `${p} ${m[1]}`
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Opposite calls handed to Claude to make, after the person said yes: the call,
|
|
590
|
+
// by tool and arguments, to the action it undoes. Not recorded as Claude's own,
|
|
591
|
+
// and once all of an action's have run, it counts as undone.
|
|
592
|
+
const handedOver = new Map<string, string>()
|
|
593
|
+
|
|
594
|
+
type Handoff = { id: string; server: string; calls: { tool: string; args: Record<string, unknown> }[]; label: string }
|
|
595
|
+
const HANDOFF = { yes: 'Ask Claude to do it', no: 'Leave it' } as const
|
|
596
|
+
|
|
597
|
+
// Auto mode's classifier answers only calls a request of Claude's asked for, so it
|
|
598
|
+
// refuses Backstep's own. With the person's yes, the request is theirs: Claude is
|
|
599
|
+
// asked to make exactly these calls, and Backstep marks the change undone once it has.
|
|
600
|
+
async function offerHandoff($: EngineInterface, h: Handoff) {
|
|
601
|
+
const answer = await $.ui
|
|
602
|
+
.ask(`Claude Code's auto mode didn't let Backstep undo this by itself: ${h.label}. Ask Claude to undo it?`, { header: 'Backstep', options: [HANDOFF.yes, HANDOFF.no] })
|
|
603
|
+
.catch(() => null)
|
|
604
|
+
if (answer !== HANDOFF.yes) return
|
|
605
|
+
const server = toolServer(h.server)
|
|
606
|
+
for (const c of h.calls) handedOver.set(undoCallKey(`mcp__${server}__${c.tool}`, c.args), h.id)
|
|
607
|
+
const steps = h.calls.map((c) => `- mcp__${server}__${c.tool} with ${JSON.stringify(c.args)}`).join('\n')
|
|
608
|
+
await $.prompt
|
|
609
|
+
.submit({
|
|
610
|
+
text: `Undo this with Backstep: ${h.label}. Make exactly ${h.calls.length === 1 ? 'this call' : 'these calls, in order'}, and nothing else:\n${steps}`,
|
|
611
|
+
wait: false,
|
|
612
|
+
origin: { kind: 'composer' },
|
|
613
|
+
} as never)
|
|
614
|
+
.catch(() => null)
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// One opposite call, through Claude Code's own connection to the server.
|
|
618
|
+
async function undoCall($: EngineInterface, server: string, tool: string, args: Record<string, unknown>): Promise<{ isError: boolean; content: { type: string; text?: string }[] }> {
|
|
619
|
+
return $.mcp.call(server, tool, args).catch((err: unknown) => ({
|
|
620
|
+
isError: true,
|
|
621
|
+
content: [{ type: 'text' as const, text: err instanceof Error ? err.message : String(err) }],
|
|
622
|
+
}))
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Claude Code's permission check refused the call (auto mode's classifier
|
|
626
|
+
// answers only calls a request of Claude's asked for), not the server.
|
|
627
|
+
const REFUSED = /\$\.mcp\.call\([^)]*\) refused|auto mode classifier/i
|
|
628
|
+
|
|
629
|
+
// A server's name as tool names spell it: "claude.ai Notion" is claude_ai_Notion.
|
|
630
|
+
const toolServer = (server: string) => server.replace(/[^A-Za-z0-9_-]/g, '_')
|
|
631
|
+
|
|
632
|
+
// A call by tool and arguments, the arguments' keys in order, so the same call always reads the same.
|
|
633
|
+
function undoCallKey(tool: string, args: unknown): string {
|
|
634
|
+
const sorted = (v: unknown): unknown =>
|
|
635
|
+
Array.isArray(v) ? v.map(sorted) : v && typeof v === 'object' ? Object.fromEntries(Object.entries(v).sort(([a], [b]) => (a < b ? -1 : 1)).map(([k, x]) => [k, sorted(x)])) : v
|
|
636
|
+
return `${tool} ${JSON.stringify(sorted(args ?? {}))}`
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// The action id `backstep snap` printed.
|
|
640
|
+
function savedId(stdout: string): string | null {
|
|
641
|
+
try {
|
|
642
|
+
const { id } = JSON.parse(stdout || '{}') as { id?: unknown }
|
|
643
|
+
return typeof id === 'string' ? id : null
|
|
644
|
+
} catch {
|
|
645
|
+
return null
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
528
649
|
function newTurn(startedAt: number, prompt?: string): Turn {
|
|
529
650
|
return { id: `t${startedAt}`, startedAt, edits: 0, files: [], mcp: 0, shell: 0, lost: 0, undone: false, ...(prompt ? { prompt } : {}) }
|
|
530
651
|
}
|
|
@@ -728,6 +849,40 @@ async function offerUpdate($: EngineInterface) {
|
|
|
728
849
|
await installUpdate($, u.current, u.latest)
|
|
729
850
|
}
|
|
730
851
|
|
|
852
|
+
function updated($: EngineInterface, u: Updating) {
|
|
853
|
+
$.ui.log(`${MARK_TEXT} Backstep is updated to ${u.to}${u.from ? ` (you had ${u.from})` : ''}. It's safe to close ${u.win} now. Restart Claude Code to start using ${u.to}.`)
|
|
854
|
+
$.ui.toast(`Backstep is updated to ${u.to}. Restart Claude Code to use it.`)
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// After a reload in the middle of an update: once the new version is in place,
|
|
858
|
+
// say so and take the "don't close" line down. Setup may still be finishing,
|
|
859
|
+
// so it looks again for a while before giving up.
|
|
860
|
+
async function finishUpdate($: EngineInterface) {
|
|
861
|
+
const u = await read($, updating)
|
|
862
|
+
if (!u) return
|
|
863
|
+
for (let i = 0; i < 60; i++) {
|
|
864
|
+
const ran = await $.process.run(backstep(['update-check']), { timeoutMs: 15_000 }).catch(() => null)
|
|
865
|
+
let installed: string | undefined
|
|
866
|
+
try {
|
|
867
|
+
installed = (JSON.parse(ran?.stdout || '{}') as { current?: string }).current
|
|
868
|
+
} catch {
|
|
869
|
+
installed = undefined
|
|
870
|
+
}
|
|
871
|
+
if (installed === u.to) {
|
|
872
|
+
// Setup may still be wrapping servers: a moment more before saying it's safe to close.
|
|
873
|
+
await $.clock.sleep(5000)
|
|
874
|
+
$.ui.status(undefined)
|
|
875
|
+
await update($, updating, () => null)
|
|
876
|
+
updated($, u)
|
|
877
|
+
return
|
|
878
|
+
}
|
|
879
|
+
await $.clock.sleep(5000)
|
|
880
|
+
}
|
|
881
|
+
$.ui.status(undefined)
|
|
882
|
+
await update($, updating, () => null)
|
|
883
|
+
$.ui.log(`${MARK_TEXT} Backstep couldn't confirm the update to ${u.to} finished. If undo doesn't work after restarting Claude Code, run: npx backstep@latest setup`)
|
|
884
|
+
}
|
|
885
|
+
|
|
731
886
|
// Installs a version, saying not to close the terminal while it runs and which version it ended on.
|
|
732
887
|
async function installUpdate($: EngineInterface, current: string | undefined, latest: string, forced = false): Promise<boolean> {
|
|
733
888
|
const u = { current, latest }
|
|
@@ -737,6 +892,7 @@ async function installUpdate($: EngineInterface, current: string | undefined, la
|
|
|
737
892
|
$.ui.log(`${MARK_TEXT} Updating Backstep${from} to ${u.latest}… Don't close ${win} until it says it's done (about a minute).`)
|
|
738
893
|
$.ui.toast(`Updating Backstep to ${u.latest}. Don't close ${win} until it's done.`, { timeoutMs: 15_000 })
|
|
739
894
|
$.ui.status(`◀◀ Updating Backstep to ${u.latest}… don't close ${win}`)
|
|
895
|
+
await update($, updating, () => ({ ...(u.current ? { from: u.current } : {}), to: latest, win }))
|
|
740
896
|
const done = await $.process
|
|
741
897
|
.run(backstep(['update', '--version', u.latest, '--json']), { timeoutMs: 600_000 })
|
|
742
898
|
.catch((err: unknown) => ({ exitCode: 1, stdout: '', stderr: err instanceof Error ? err.message : String(err) }))
|
|
@@ -747,9 +903,9 @@ async function installUpdate($: EngineInterface, current: string | undefined, la
|
|
|
747
903
|
// keep the error above
|
|
748
904
|
}
|
|
749
905
|
$.ui.status(undefined)
|
|
906
|
+
await update($, updating, () => null)
|
|
750
907
|
if (r.ok) {
|
|
751
|
-
|
|
752
|
-
$.ui.toast(`Backstep is updated to ${u.latest}. Restart Claude Code to use it.`)
|
|
908
|
+
updated($, { ...(u.current ? { from: u.current } : {}), to: latest, win })
|
|
753
909
|
return true
|
|
754
910
|
} else {
|
|
755
911
|
$.ui.log(`${MARK_TEXT} Backstep couldn't update to ${u.latest}: ${r.detail}. You're still on ${u.current ?? 'the version you had'}${forced ? '' : ', which keeps working'}. To try again, run: npx backstep@latest setup`)
|
|
@@ -944,6 +1100,17 @@ export const register: Register = (on, options) => {
|
|
|
944
1100
|
|
|
945
1101
|
on('tool.call', async ($, e, next) => {
|
|
946
1102
|
const tool = String(e.tool)
|
|
1103
|
+
// An opposite call Claude is making for undo, as the person asked: not recorded as a change of its own.
|
|
1104
|
+
const handed = handedOver.size ? undoCallKey(tool, Object.fromEntries(Object.entries(e).filter(([k]) => !RESERVED.has(k)))) : ''
|
|
1105
|
+
const forAction = handed ? handedOver.get(handed) : undefined
|
|
1106
|
+
if (forAction) {
|
|
1107
|
+
const r = (await next(e)) as { deny?: string; isError?: boolean }
|
|
1108
|
+
if (!r.deny && !r.isError) {
|
|
1109
|
+
handedOver.delete(handed)
|
|
1110
|
+
if (![...handedOver.values()].includes(forAction)) await $.process.run(backstep(['settle', forAction])).catch(() => null)
|
|
1111
|
+
}
|
|
1112
|
+
return r as never
|
|
1113
|
+
}
|
|
947
1114
|
if (tool === CONNECT || tool === KEY || tool === UNINSTALL) return next(e)
|
|
948
1115
|
if (EDIT_TOOLS.has(tool)) {
|
|
949
1116
|
const input = e as { file_path?: string; notebook_path?: string }
|
|
@@ -960,6 +1127,13 @@ export const register: Register = (on, options) => {
|
|
|
960
1127
|
.catch(() => null)
|
|
961
1128
|
if (snap?.exitCode === 0) {
|
|
962
1129
|
await bump($, t.id, (x) => ({ ...x, gitOnly: false, edits: x.edits + 1, files: x.files.includes(path) ? x.files : [...x.files, path] }))
|
|
1130
|
+
// Once the edit has run: how it left the file, so undo can tell later changes (often by hand) apart.
|
|
1131
|
+
const id = savedId(snap.stdout)
|
|
1132
|
+
if (id) {
|
|
1133
|
+
const r = await next(e)
|
|
1134
|
+
await $.process.run(backstep(['after', id])).catch(() => null)
|
|
1135
|
+
return r
|
|
1136
|
+
}
|
|
963
1137
|
} else {
|
|
964
1138
|
await bump($, t.id, (x) => lostOne({ ...x, gitOnly: false }, `edit to ${path.split(/[\\/]/).pop()}: Backstep couldn't save a copy first`))
|
|
965
1139
|
}
|
|
@@ -1001,10 +1175,14 @@ export const register: Register = (on, options) => {
|
|
|
1001
1175
|
plan.undoable ? { ...x, gitOnly: x.gitOnly !== false && git, shell: (x.shell ?? 0) + 1 } : lostOne({ ...x, gitOnly: false }, `${brief(command)}: ${plan.reason ?? "Backstep couldn't save a copy first"}`),
|
|
1002
1176
|
)
|
|
1003
1177
|
}
|
|
1004
|
-
|
|
1005
|
-
if (plan?.id && (PUSH.test(command) || GH_CREATE.test(command) || VERCEL.test(command))) {
|
|
1178
|
+
if (plan?.id) {
|
|
1006
1179
|
const r = await next(e)
|
|
1007
|
-
|
|
1180
|
+
// A push: once it's run, note what it sent, so undo can offer to take it back there.
|
|
1181
|
+
if (PUSH.test(command) || GH_CREATE.test(command) || VERCEL.test(command)) {
|
|
1182
|
+
await $.process.run(backstep(['shell-end', plan.id, '--cwd', cwd]), { stdin: r.text ?? '', timeoutMs: 60_000 }).catch(() => null)
|
|
1183
|
+
}
|
|
1184
|
+
// How the command left the project, so undo can tell later changes (often by hand) apart.
|
|
1185
|
+
if (plan.undoable) await $.process.run(backstep(['after', plan.id]), { timeoutMs: 60_000 }).catch(() => null)
|
|
1008
1186
|
return r
|
|
1009
1187
|
}
|
|
1010
1188
|
} else if (tool.startsWith('mcp__')) {
|
|
@@ -1014,6 +1192,10 @@ export const register: Register = (on, options) => {
|
|
|
1014
1192
|
})
|
|
1015
1193
|
|
|
1016
1194
|
on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
|
|
1195
|
+
if (!resumed) {
|
|
1196
|
+
resumed = true
|
|
1197
|
+
void finishUpdate($)
|
|
1198
|
+
}
|
|
1017
1199
|
if (e.props.hasSurvey) return next(e)
|
|
1018
1200
|
const { Box, Button, Text } = $.ui.resolve(e)
|
|
1019
1201
|
// The logo keeps its own box with a fixed gap after it: a space inside the
|
package/mod/types/index.d.ts
CHANGED
|
@@ -21,6 +21,9 @@ export type Turn = {
|
|
|
21
21
|
export type RewindItem = { label: string; ok: boolean }
|
|
22
22
|
|
|
23
23
|
// The animation above the prompt while a turn is being rewound.
|
|
24
|
+
// An update under way, kept across the reload it causes, so the reloaded mod can say it's done.
|
|
25
|
+
export type Updating = { from?: string; to: string; win: string }
|
|
26
|
+
|
|
24
27
|
export type Rewind = {
|
|
25
28
|
phase: 'running' | 'done' | 'failed' | 'empty'
|
|
26
29
|
frame: number
|
|
@@ -38,6 +41,6 @@ declare module 'claude-code' {
|
|
|
38
41
|
// keys: the keys bound to undo, kept here so the band redraws when they change.
|
|
39
42
|
// githubCode: the code GitHub's approval page wants, shown on the band until GitHub answers.
|
|
40
43
|
// blocked: why ctrl+u can't run, when getbackstep.com says this version is too old or a sign-in is needed.
|
|
41
|
-
backstep: { turns: Turn[]; rewind: Rewind | null; note: string | null; armed: number | null; keys: string[]; githubCode: GithubCode | null; blocked: string | null }
|
|
44
|
+
backstep: { turns: Turn[]; rewind: Rewind | null; note: string | null; armed: number | null; keys: string[]; githubCode: GithubCode | null; blocked: string | null; updating: Updating | null }
|
|
42
45
|
}
|
|
43
46
|
}
|