backstep 0.1.28 → 0.1.29

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 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
 
@@ -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) {
@@ -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), values.until ? Number(values.until) : undefined, { viaClaudeCode: values["via-claude-code"], session: values.session })
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/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
- export async function undoSince(store, since, until = Date.now(), opts = {}) {
212
- let inWindow = store.db
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
- inWindow = inWindow.filter((a) => {
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 undoAll(store, inWindow.filter(undoable), opts);
230
+ return found.filter(undoable);
228
231
  }
229
- async function undoAll(store, actions, opts = {}) {
230
- if (actions.length === 0)
231
- return [];
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
- return runChain(store, chain, opts);
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
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backstep",
3
- "version": "0.1.28",
3
+ "version": "0.1.29",
4
4
  "description": "Press ctrl+z to rewind everything Claude did in its last turn: file edits, shell commands, and database and tool changes.",
5
5
  "types": "./types/index.d.ts",
6
6
  "userConfig": {
@@ -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
@@ -552,8 +552,9 @@ test('after undoing a turn that pushed, Backstep asks and pushes the undo', { op
552
552
 
553
553
  await $.prompt.submit({ text: 'ship it', wait: false, origin: { kind: 'composer' } })
554
554
  await $.tool.call({ tool: 'Bash', command: 'git push' })
555
- // The push is noted once it has run.
556
- expect(runs.at(-1)?.argv.slice(3)).toEqual(['shell-end', 'p1', '--cwd', '/repo'])
555
+ // The push is noted once it has run, then how it left the project.
556
+ expect(runs.at(-2)?.argv.slice(3)).toEqual(['shell-end', 'p1', '--cwd', '/repo'])
557
+ expect(runs.at(-1)?.argv.slice(3)).toEqual(['after', 'p1'])
557
558
 
558
559
  const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
559
560
  await ui.press({ key: 'undo' })
@@ -744,7 +745,7 @@ test('about to change a Supabase project that is not connected, Backstep asks fi
744
745
  await $.tool.call({ tool: 'Bash', command })
745
746
  expect(asked).toHaveLength(1)
746
747
  const order = runs.map((r) => r[3])
747
- expect(order).toEqual(['db-check', 'connect', 'shell'])
748
+ expect(order).toEqual(['db-check', 'connect', 'shell', 'after'])
748
749
  expect(runs[1].slice(3)).toEqual(['connect', 'supabase', '--dir', '/work/taskflow', '--json'])
749
750
 
750
751
  // Asked once a session per project, whatever the answer.
@@ -801,7 +802,7 @@ test('when the last turn only committed and pushed, undo offers to take back the
801
802
  await clock.advance(1000)
802
803
  await pressed
803
804
  // 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'])
805
+ expect(runs.find((r) => r.includes('undo'))?.slice(3)).toEqual(['undo', '--since', '5000', '--session', 's1', '--json', '--via-claude-code', '--manual', 'ask'])
805
806
  await ui.unmount()
806
807
  })
807
808
 
@@ -1176,3 +1177,77 @@ test('in the desktop app, the update is offered the same way and says not to clo
1176
1177
  expect(statuses[0]).toContain("don't close Claude Code")
1177
1178
  expect(said).not.toContain('terminal')
1178
1179
  })
1180
+
1181
+ // Files changed after Claude's turn: undo asks before taking those changes too.
1182
+ function handEdited(on: On, answer: string | null) {
1183
+ const runs: string[][] = []
1184
+ const asked: string[] = []
1185
+ const toasts: string[] = []
1186
+ on('session.cwd', () => ({ value: '/repo' }))
1187
+ on('session.id', () => ({ value: 's1' }))
1188
+ on('ui.log', () => ({ value: undefined }))
1189
+ on('ui.toast', (_, e) => {
1190
+ toasts.push(e.text)
1191
+ return { value: undefined }
1192
+ })
1193
+ on('process.run', (_, e) => {
1194
+ const argv = [...e.argv]
1195
+ runs.push(argv)
1196
+ const manual = argv.includes('undo') ? argv[argv.indexOf('--manual') + 1] : null
1197
+ const stdout =
1198
+ argv.includes('snap') ? JSON.stringify({ id: 'c1', undoable: true })
1199
+ : manual === 'ask' ? JSON.stringify({ manual: ['/repo/src/a.ts'] })
1200
+ : manual === 'keep' ? JSON.stringify([{ id: 'c1', tool: 'Edit', ok: true, detail: 'kept /repo/src/a.ts' }])
1201
+ : manual === 'undo' ? JSON.stringify([{ id: 'c1', tool: 'Edit', ok: true, detail: 'restored /repo/src/a.ts' }])
1202
+ : ''
1203
+ return { value: { exitCode: 0, stdout, stderr: '', isStdoutTruncated: false, isStderrTruncated: false } }
1204
+ })
1205
+ on('tool.call', { tool: 'AskUserQuestion' }, (_, e) => {
1206
+ const questions = (e as unknown as { questions: { question: string }[] }).questions
1207
+ asked.push(questions[0].question)
1208
+ return { result: { questions, answers: answer ? { [questions[0].question]: answer } : {} } }
1209
+ })
1210
+ on('tool.call', () => ({ result: {} }))
1211
+ on('prompt.submit', (_, e) => ({ text: e.text, context: e.context }))
1212
+ on('ui.render', ($, e) => {
1213
+ const { Text } = $.ui.resolve(e)
1214
+ return <Text>engine</Text>
1215
+ })
1216
+ return { runs, asked, toasts }
1217
+ }
1218
+
1219
+ 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) => {
1220
+ const clock = mock.clock(on, { now: 5000 })
1221
+ const { runs, asked } = handEdited(on, 'Keep my changes')
1222
+ await $.prompt.submit({ text: 'fix it', wait: false, origin: { kind: 'composer' } })
1223
+ await $.tool.call({ tool: 'Edit', file_path: '/repo/src/a.ts', old_string: 'a', new_string: 'b' })
1224
+ expect(runs.at(-1)?.slice(3)).toEqual(['after', 'c1'])
1225
+
1226
+ const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
1227
+ await ui.press({ key: 'undo' })
1228
+ const pressed = ui.press({ key: 'undo' })
1229
+ await clock.advance(3000)
1230
+ await pressed
1231
+ await clock.advance(500)
1232
+ expect(asked).toEqual(["src/a.ts was changed after Claude's turn (by you, or by another tool). Undo those changes too?"])
1233
+ const undos = runs.filter((r) => r.includes('undo')).map((r) => r.at(-1))
1234
+ expect(undos).toEqual(['ask', 'keep'])
1235
+ expect(await ui.find({ type: 'Text', text: /src\/a\.ts: Claude's changes undone, yours kept/ })).toBeTruthy()
1236
+ await ui.unmount()
1237
+ })
1238
+
1239
+ test('dismissing the question about changed files undoes nothing', { options: { cli: CLI } }, async ($, on) => {
1240
+ const clock = mock.clock(on, { now: 5000 })
1241
+ const { runs, toasts } = handEdited(on, null)
1242
+ await $.prompt.submit({ text: 'fix it', wait: false, origin: { kind: 'composer' } })
1243
+ await $.tool.call({ tool: 'Edit', file_path: '/repo/src/a.ts', old_string: 'a', new_string: 'b' })
1244
+ const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
1245
+ await ui.press({ key: 'undo' })
1246
+ const pressed = ui.press({ key: 'undo' })
1247
+ await clock.advance(3000)
1248
+ await pressed
1249
+ expect(runs.filter((r) => r.includes('undo')).map((r) => r.at(-1))).toEqual(['ask'])
1250
+ expect(toasts).toContain('Undo cancelled. Nothing was changed.')
1251
+ expect(await ui.find({ type: 'Text', text: /Rewinding|Rewound/ })).toBeFalsy()
1252
+ await ui.unmount()
1253
+ })
@@ -301,23 +301,49 @@ async function undoLastTurn($: EngineInterface, count = 1) {
301
301
  if (!target) return
302
302
 
303
303
  await update($, rewind, (): Rewind => ({ phase: 'running', frame: 0, shown: 0, items: [], message: '' }))
304
- const ticker = $.clock.every(55, () => {
305
- void update($, rewind, (r) => (r && r.phase === 'running' ? { ...r, frame: r.frame + 1 } : r))
306
- })
304
+ const spin = () =>
305
+ $.clock.every(55, () => {
306
+ void update($, rewind, (r) => (r && r.phase === 'running' ? { ...r, frame: r.frame + 1 } : r))
307
+ })
308
+ let ticker = spin()
307
309
 
308
310
  const cwd = await $.session.cwd()
309
311
  const short = (p: string) => relPath(p, cwd)
310
- const [ran] = await Promise.all([
311
- $.process.run(backstep(['undo', '--since', String(target.startedAt), '--session', await $.session.id(), '--json', '--via-claude-code'])).catch((err: unknown) => ({
312
- exitCode: 1,
313
- stdout: '',
314
- stderr: err instanceof Error ? err.message : String(err),
315
- })),
316
- // Long enough to read as a rewind, short enough to never feel slow.
317
- $.clock.sleep(700),
318
- ])
312
+ const sid = await $.session.id()
313
+ const undo = (manual: 'ask' | 'keep' | 'undo') =>
314
+ Promise.all([
315
+ $.process.run(backstep(['undo', '--since', String(target.startedAt), '--session', sid, '--json', '--via-claude-code', '--manual', manual])).catch((err: unknown) => ({
316
+ exitCode: 1,
317
+ stdout: '',
318
+ stderr: err instanceof Error ? err.message : String(err),
319
+ })),
320
+ // Long enough to read as a rewind, short enough to never feel slow.
321
+ $.clock.sleep(700),
322
+ ]).then(([r]) => r)
323
+ let ran = await undo('ask')
319
324
  ticker.cancel()
320
325
 
326
+ // Files changed since Claude left them, usually by hand: nothing was undone yet, so ask first.
327
+ const changed = manualFiles(ran.stdout)
328
+ if (changed) {
329
+ const names = changed.map(short)
330
+ 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)}`
331
+ const answer = await $.ui
332
+ .ask(`${list} ${names.length === 1 ? 'was' : 'were'} changed after Claude's turn (by you, or by another tool). Undo those changes too?`, {
333
+ header: 'Backstep',
334
+ options: [MANUAL.keep, MANUAL.undo],
335
+ })
336
+ .catch(() => null)
337
+ if (answer !== MANUAL.keep && answer !== MANUAL.undo) {
338
+ await update($, rewind, () => null)
339
+ $.ui.toast('Undo cancelled. Nothing was changed.')
340
+ return
341
+ }
342
+ ticker = spin()
343
+ ran = await undo(answer === MANUAL.keep ? 'keep' : 'undo')
344
+ ticker.cancel()
345
+ }
346
+
321
347
  let outcomes: Outcome[] = []
322
348
  try {
323
349
  outcomes = JSON.parse(ran.stdout || '[]') as Outcome[]
@@ -365,7 +391,7 @@ async function undoLastTurn($: EngineInterface, count = 1) {
365
391
  (o.detail || o.tool)
366
392
  .split('\n')
367
393
  .filter(Boolean)
368
- .map((line) => ({ label: line.replace(/^(restored|removed) (.*)$/, (_, verb: string, p: string) => `${short(p)} ${verb}`), ok: true })),
394
+ .map((line) => ({ label: undoneLabel(line, short), ok: true })),
369
395
  )
370
396
  const stopped = outcomes.filter((o) => !o.ok).map((o) => ({ label: `${o.tool}: ${o.detail.split('\n')[0] ?? 'failed'}`, ok: false }))
371
397
  // A push, or something made on GitHub, is offered back below, so it isn't listed as lost.
@@ -525,6 +551,40 @@ function backstep(args: string[]): string[] {
525
551
  return cli === 'backstep' ? ['backstep', ...args] : ['node', '--disable-warning=ExperimentalWarning', cli, ...args]
526
552
  }
527
553
 
554
+ // The two answers when files changed after Claude's turn; keeping them comes first, so a quick Enter loses nothing.
555
+ const MANUAL = { keep: 'Keep my changes', undo: 'Undo them too' } as const
556
+
557
+ // The files `backstep undo --manual ask` named, when it stopped to ask.
558
+ function manualFiles(stdout: string): string[] | null {
559
+ try {
560
+ const r = JSON.parse(stdout || 'null') as unknown
561
+ return r && !Array.isArray(r) && Array.isArray((r as { manual?: unknown }).manual) && (r as { manual: unknown[] }).manual.length ? (r as { manual: string[] }).manual : null
562
+ } catch {
563
+ return null
564
+ }
565
+ }
566
+
567
+ // One line of what undo did, as the band and the conversation show it.
568
+ function undoneLabel(line: string, short: (p: string) => string): string {
569
+ const m = line.match(/^(restored|removed|kept|left|overlap) (.*)$/)
570
+ if (!m) return line
571
+ const p = short(m[2])
572
+ if (m[1] === 'kept') return `${p}: Claude's changes undone, yours kept`
573
+ if (m[1] === 'left') return `${p} left as you had it`
574
+ if (m[1] === 'overlap') return `${p} left as you had it: your changes overlap Claude's`
575
+ return `${p} ${m[1]}`
576
+ }
577
+
578
+ // The action id `backstep snap` printed.
579
+ function savedId(stdout: string): string | null {
580
+ try {
581
+ const { id } = JSON.parse(stdout || '{}') as { id?: unknown }
582
+ return typeof id === 'string' ? id : null
583
+ } catch {
584
+ return null
585
+ }
586
+ }
587
+
528
588
  function newTurn(startedAt: number, prompt?: string): Turn {
529
589
  return { id: `t${startedAt}`, startedAt, edits: 0, files: [], mcp: 0, shell: 0, lost: 0, undone: false, ...(prompt ? { prompt } : {}) }
530
590
  }
@@ -960,6 +1020,13 @@ export const register: Register = (on, options) => {
960
1020
  .catch(() => null)
961
1021
  if (snap?.exitCode === 0) {
962
1022
  await bump($, t.id, (x) => ({ ...x, gitOnly: false, edits: x.edits + 1, files: x.files.includes(path) ? x.files : [...x.files, path] }))
1023
+ // Once the edit has run: how it left the file, so undo can tell later changes (often by hand) apart.
1024
+ const id = savedId(snap.stdout)
1025
+ if (id) {
1026
+ const r = await next(e)
1027
+ await $.process.run(backstep(['after', id])).catch(() => null)
1028
+ return r
1029
+ }
963
1030
  } else {
964
1031
  await bump($, t.id, (x) => lostOne({ ...x, gitOnly: false }, `edit to ${path.split(/[\\/]/).pop()}: Backstep couldn't save a copy first`))
965
1032
  }
@@ -1001,10 +1068,14 @@ export const register: Register = (on, options) => {
1001
1068
  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
1069
  )
1003
1070
  }
1004
- // A push: once it's run, note what it sent, so undo can offer to take it back there.
1005
- if (plan?.id && (PUSH.test(command) || GH_CREATE.test(command) || VERCEL.test(command))) {
1071
+ if (plan?.id) {
1006
1072
  const r = await next(e)
1007
- await $.process.run(backstep(['shell-end', plan.id, '--cwd', cwd]), { stdin: r.text ?? '', timeoutMs: 60_000 }).catch(() => null)
1073
+ // A push: once it's run, note what it sent, so undo can offer to take it back there.
1074
+ if (PUSH.test(command) || GH_CREATE.test(command) || VERCEL.test(command)) {
1075
+ await $.process.run(backstep(['shell-end', plan.id, '--cwd', cwd]), { stdin: r.text ?? '', timeoutMs: 60_000 }).catch(() => null)
1076
+ }
1077
+ // How the command left the project, so undo can tell later changes (often by hand) apart.
1078
+ if (plan.undoable) await $.process.run(backstep(['after', plan.id]), { timeoutMs: 60_000 }).catch(() => null)
1008
1079
  return r
1009
1080
  }
1010
1081
  } else if (tool.startsWith('mcp__')) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backstep",
3
- "version": "0.1.28",
3
+ "version": "0.1.29",
4
4
  "description": "Undo for everything your agents touch: an MCP proxy that makes agent actions reversible.",
5
5
  "homepage": "https://getbackstep.com",
6
6
  "scripts": {