backstep 0.1.19 → 0.1.21

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
@@ -8,6 +8,7 @@ Undo for everything your agents touch. Backstep sits between your agent (Claude
8
8
  - **Supabase:** `execute_sql` and `apply_migration` snapshot the tables they change, over a connection to the project's database. Backstep never connects it on its own: when Claude Code opens a project that uses Supabase and isn't connected, it asks (Connect, Not now, or Don't ask for this project). Connect uses a connection string in the project's `.env` files, the local `supabase start` database, or a Supabase sign-in (`supabase login`, `SUPABASE_ACCESS_TOKEN`, or an access token you give Backstep once), with which the project gets a temporary database login that Backstep renews as it runs out. You can also ask Claude to "connect Backstep to Supabase" any time. Deleted rows, dropped tables and migrations, including the migration's history row, all come back. Works for the claude.ai Supabase connector and a local Supabase MCP server alike.
9
9
  - **Firebase:** before a change, Backstep reads what's there through the Firebase MCP server itself, and undoes through it too, so there's nothing to set up. Firestore documents updated, deleted or added; Realtime Database writes; Remote Config publishes (rolled back to the version before); and users enabled or disabled.
10
10
  - **claude.ai connectors and servers Claude Code signs in to:** Backstep can't sit in front of these, so the Claude Code mod watches their calls instead. Calls with an undo rule are undone through Claude Code's own connection.
11
+ - **Notion** (the claude.ai connector, or Notion's own MCP server): built in, nothing to set up. Pages and rows Claude creates, duplicates or databases it makes go to Notion's Trash on undo; pages it deletes come back from Trash; an edit (content, properties, icon, cover) is undone by writing back the page as the mod read it just before; moved pages go back where each came from. A page too big to read whole, or a result Backstep can't read the new pages from, is recorded and marked not undoable rather than guessed at.
11
12
  - **Database commands Claude runs** (`supabase db push`, `supabase migration up`, `supabase db reset`, `psql`, `prisma migrate`, `drizzle-kit push`, seed scripts…): in a project whose Supabase database is connected, the whole database is noted before the command runs, and ctrl+u puts it back in one transaction: rows, new tables dropped, dropped or altered tables rebuilt with their constraints, indexes, foreign keys, row level security policies, triggers and grants, sequence counters, and the migration history. Copies sit in a `backstep` schema in the same database; the newest 10 per database are kept. On a very big database it keeps every table's structure and the migration history, and rows smallest-table-first up to a budget (`BACKSTEP_DB_SNAPSHOT_MB`, default 250); undo names any table whose rows it couldn't bring back. Functions, views, types and extensions aren't restored.
12
13
  - **Supabase through its web API** (curl to `/rest/v1`, seed and app scripts using supabase-js): Backstep finds Supabase projects in the folder Claude Code opened or a level or two below, and the first time Claude is about to change one that isn't connected, it asks before the command runs. Connect uses the project's own `SUPABASE_URL` and service role key from its `.env`, no password: every table's rows are saved on this machine before each such command (smallest first, up to the size budget), and ctrl+u removes rows added since and writes back the old ones. Table changes need the database itself connected (password or access token).
13
14
  - **Any Postgres database** (Neon, Railway, Render, AWS, a local one) named in the project's `.env` (`DATABASE_URL` and the like): Backstep asks once, then saves the whole database before Claude's database commands (`psql`, `prisma migrate`, `drizzle-kit push`, seed and app scripts) and puts it back on undo, the same way as Supabase.
@@ -2,14 +2,12 @@
2
2
  // Restoring "the state each path was in" handles writes, edits, deletes,
3
3
  // creates and moves with one rule.
4
4
  import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, rmSync } from "node:fs";
5
- import { homedir } from "node:os";
6
- import { dirname, join, resolve } from "node:path";
5
+ import { dirname, join } from "node:path";
7
6
  import { snapshotDir } from "../store.js";
7
+ import { insidePath, resolveIn } from "../paths.js";
8
8
  const MAX_BYTES = 50 * 1024 * 1024;
9
9
  export function resolvePath(p, cwd) {
10
- if (p === "~" || p.startsWith("~/"))
11
- return join(homedir(), p.slice(1));
12
- return resolve(cwd, p);
10
+ return resolveIn(p, cwd);
13
11
  }
14
12
  function sizeOf(p, budget) {
15
13
  const st = lstatSync(p);
@@ -27,9 +25,7 @@ function sizeOf(p, budget) {
27
25
  // Whether a path sits in the folder Claude Code has open. Edits elsewhere belong to
28
26
  // other projects: Backstep never records them, so undo here can never touch them.
29
27
  export function insideDir(path, dir) {
30
- const p = resolvePath(path, dir);
31
- const d = resolve(dir).replace(/\/$/, "");
32
- return p === d || p.startsWith(d + "/");
28
+ return insidePath(resolvePath(path, dir), dir);
33
29
  }
34
30
  export function snapshotPaths(actionId, paths, cwd) {
35
31
  const dir = snapshotDir(actionId);
@@ -6,7 +6,8 @@
6
6
  import { createHash } from "node:crypto";
7
7
  import { closeSync, copyFileSync, existsSync, mkdirSync, openSync, readdirSync, readSync, rmSync, statSync, writeFileSync } from "node:fs";
8
8
  import { homedir } from "node:os";
9
- import { isAbsolute, join, relative, resolve } from "node:path";
9
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
10
+ import { isDiskRoot } from "../paths.js";
10
11
  import { execFileSync } from "node:child_process";
11
12
  import { homeDir, snapshotDir } from "../store.js";
12
13
  const SQLITE_BUDGET = () => Number(process.env.BACKSTEP_DB_SNAPSHOT_MB ?? 250) * 1024 * 1024;
@@ -26,6 +27,8 @@ function isSqlite(path) {
26
27
  // SQLite database files in a project, four folders deep at most.
27
28
  export function sqliteFiles(root) {
28
29
  const out = [];
30
+ // Backstep's own folder holds its live history: never saved or put back as a project's database.
31
+ const own = resolve(homeDir());
29
32
  const walk = (dir, depth) => {
30
33
  let entries = [];
31
34
  try {
@@ -37,7 +40,7 @@ export function sqliteFiles(root) {
37
40
  for (const e of entries) {
38
41
  const p = join(dir, e.name);
39
42
  if (e.isDirectory()) {
40
- if (depth > 0 && !SKIP_DIRS.test(e.name))
43
+ if (depth > 0 && !SKIP_DIRS.test(e.name) && resolve(p) !== own)
41
44
  walk(p, depth - 1);
42
45
  }
43
46
  else if (/\.(db|sqlite3?|db3)$/i.test(e.name) && isSqlite(p))
@@ -137,7 +140,9 @@ function tryGit(args, cwd) {
137
140
  }
138
141
  // The project a command runs in: its git repository, or the folder itself.
139
142
  export function projectRoot(cwd) {
140
- return tryGit(["rev-parse", "--show-toplevel"], cwd) ?? resolve(cwd);
143
+ // git answers C:/Users/me/app on Windows; resolve gives the system's own spelling.
144
+ const top = tryGit(["rev-parse", "--show-toplevel"], cwd);
145
+ return resolve(top ?? cwd);
141
146
  }
142
147
  function shadowFor(root) {
143
148
  const gitDir = join(homeDir(), "trees", createHash("sha256").update(root).digest("hex").slice(0, 16) + ".git");
@@ -147,7 +152,7 @@ function shadowFor(root) {
147
152
  mkdirSync(join(gitDir, "info"), { recursive: true });
148
153
  // Backstep's own folder, when it sits inside the project, holds the live journal: never snapshotted.
149
154
  const own = relative(root, resolve(homeDir()));
150
- const skipOwn = own && !own.startsWith("..") && !isAbsolute(own) ? [`/${own}/`] : [];
155
+ const skipOwn = own && !own.startsWith("..") && !isAbsolute(own) ? [`/${own.split(sep).join("/")}/`] : [];
151
156
  writeFileSync(join(gitDir, "info", "exclude"), [...EXCLUDE, ...skipOwn].join("\n") + "\n");
152
157
  writeFileSync(join(gitDir, "description"), `Backstep snapshots of ${root}\n`);
153
158
  }
@@ -191,7 +196,7 @@ function projectGit(root, actionId, pushes) {
191
196
  // whole disk, or a folder too big to stage in time.
192
197
  export function snapshotTree(actionId, cwd, opts = {}) {
193
198
  const root = projectRoot(cwd);
194
- if (root === "/" || root === homedir() || root === resolve(homeDir()))
199
+ if (isDiskRoot(root) || root === homedir() || root === resolve(homeDir()))
195
200
  return null;
196
201
  try {
197
202
  const gitDir = shadowFor(root);
package/dist/check.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // what to connect next. Setup prints it, `backstep status` prints it, and the
3
3
  // Claude Code mod checks it as a session starts, so nobody assumes a service
4
4
  // is covered when it isn't.
5
- import { spawnSync } from "node:child_process";
5
+ import { spawnSync } from "./spawn.js";
6
6
  import { configServers, projectConnected, projectUses, supabaseDirs } from "./connect.js";
7
7
  import { supabaseProjects } from "./supabase.js";
8
8
  import { watchRules } from "./watch.js";
@@ -11,6 +11,7 @@ import { DEFAULT_KEY, keyLabel, undoKeys } from "./keys.js";
11
11
  const WRAPPED = /(^|[/\s])(backstep|cli\.[jt]s) wrap\b/;
12
12
  const SUPABASE = /supabase/i;
13
13
  const FIREBASE = /firebase|firestore/i;
14
+ const NOTION = /notion/i;
14
15
  // `claude mcp list` sees everything a session would, plugins and claude.ai
15
16
  // connectors included, and whether each works; it prints "name: target - ✔ Connected".
16
17
  function listed(dir) {
@@ -47,6 +48,9 @@ function classify(s, supabaseConnected, ruled) {
47
48
  if (wrappable)
48
49
  return { name, level: "connect", detail: "undoable from the next Claude Code session", signIn };
49
50
  }
51
+ // Notion's own opposite calls are built in (src/notion.ts), through the connector or its own server.
52
+ if (NOTION.test(s.name))
53
+ return { name, level: "undoable", detail: "pages, edits, moves and deletes", signIn };
50
54
  if (ruled.has(s.name.replace(/[^a-zA-Z0-9_-]/g, "_")))
51
55
  return { name, level: "undoable", detail: "the tools your undo rules cover", signIn };
52
56
  if (wrapped)
package/dist/cli.js CHANGED
@@ -542,12 +542,19 @@ async function main() {
542
542
  // arguments, and later its result, come as JSON on stdin.
543
543
  if (cmd === "watch-start") {
544
544
  const sep = rest.indexOf("--");
545
- const { values } = parseArgs({ args: sep === -1 ? rest : rest.slice(0, sep), options: { session: { type: "string" }, cwd: { type: "string" } } });
545
+ const { values } = parseArgs({
546
+ args: sep === -1 ? rest : rest.slice(0, sep),
547
+ options: { session: { type: "string" }, cwd: { type: "string" }, before: { type: "boolean", default: false } },
548
+ });
546
549
  const tool = sep === -1 ? "" : rest[sep + 1];
547
550
  if (!values.session || !tool)
548
- throw new Error("usage: backstep watch-start --session <id> [--cwd <dir>] -- <mcp__server__tool> (arguments as JSON on stdin)");
551
+ throw new Error("usage: backstep watch-start --session <id> [--cwd <dir>] [--before] -- <mcp__server__tool> (arguments as JSON on stdin)");
549
552
  const input = readFileSync(0, "utf8").trim();
550
- console.log(JSON.stringify(await watchStart(new Store(), values.session, values.cwd ?? process.cwd(), tool, input ? JSON.parse(input) : {})));
553
+ const parsed = input ? JSON.parse(input) : {};
554
+ // --before: stdin is { args, before }, with the pages the mod read just before the call.
555
+ const args = values.before ? (parsed.args ?? {}) : parsed;
556
+ const before = values.before ? (parsed.before ?? {}) : {};
557
+ console.log(JSON.stringify(await watchStart(new Store(), values.session, values.cwd ?? process.cwd(), tool, args, before)));
551
558
  return;
552
559
  }
553
560
  if (cmd === "watch-end") {
package/dist/connect.js CHANGED
@@ -7,7 +7,7 @@
7
7
  // - Firebase: undo runs through Firebase's own MCP server, so there's nothing
8
8
  // to connect once Claude can reach Firebase. When the project uses Firebase
9
9
  // and Claude can't, this adds that server (behind Backstep).
10
- import { spawnSync } from "node:child_process";
10
+ import { spawnSync } from "./spawn.js";
11
11
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
12
12
  import { homedir } from "node:os";
13
13
  import { dirname, join, resolve } from "node:path";
@@ -107,7 +107,7 @@ export function firebaseCreds(dir) {
107
107
  const vars = envVars(dir);
108
108
  const named = vars.find(([k]) => k === "GOOGLE_APPLICATION_CREDENTIALS")?.[1] ?? process.env.GOOGLE_APPLICATION_CREDENTIALS;
109
109
  if (named) {
110
- const f = named.startsWith("/") ? named : join(dir, named);
110
+ const f = resolve(dir, named);
111
111
  if (existsSync(f))
112
112
  return f;
113
113
  }
package/dist/github.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Things Claude makes on GitHub with the gh command line (a repository, an
2
2
  // issue, a pull request, a release), noted from what gh printed, so undo can
3
3
  // offer to take them away again with gh itself, signed in as the person.
4
- import { spawn, spawnSync } from "node:child_process";
4
+ import { spawn, spawnSync } from "./spawn.js";
5
5
  const CREATES = /\bgh\s+(repo|issue|pr|release)\s+create\b/g;
6
6
  const URL = /https:\/\/github\.com\/([\w.-]+\/[\w.-]+?)(?:\.git)?(?:\/(issues|pull)\/(\d+)|\/releases\/tag\/([^\s)]+))?(?=[\s)'"]|$)/g;
7
7
  // What a gh command made, from the command and what it printed.
package/dist/notion.js ADDED
@@ -0,0 +1,191 @@
1
+ // Notion, through Claude Code's connector (or Notion's own MCP server): Backstep
2
+ // can't copy Notion, so each change is undone with Notion's opposite call.
3
+ //
4
+ // create pages or rows, duplicate a page → move what was made to Trash
5
+ // create a database → move it to Trash
6
+ // delete pages or databases → restore them from Trash
7
+ // edit a page → write back the page as read just before
8
+ // move pages → move them back to where they were
9
+ //
10
+ // Edits and moves need the page as it was: the Claude Code mod reads it with
11
+ // Notion's fetch right before the call and hands it over as `before`.
12
+ // Tools that only read, never change anything.
13
+ const READS = /^notion-(fetch|search|get-|list-|query-|view-|download-)/;
14
+ // Notion's own ids: 32 hex characters, with or without dashes.
15
+ const ID = /[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}/gi;
16
+ const bare = (id) => id.replace(/-/g, "").toLowerCase();
17
+ export const isNotionTool = (tool) => /^notion-/.test(tool);
18
+ // Which pages the mod should read before the call runs.
19
+ export function notionReadsFirst(tool, args) {
20
+ if (tool === "notion-update-page" && typeof args.page_id === "string")
21
+ return [args.page_id];
22
+ if (tool === "notion-move-pages" && Array.isArray(args.page_or_database_ids))
23
+ return args.page_or_database_ids.filter((x) => typeof x === "string").slice(0, 20);
24
+ return [];
25
+ }
26
+ // Arguments to change before the call: page creation must answer with the new pages' ids.
27
+ export function notionArgs(tool, args) {
28
+ if (tool === "notion-create-pages" && args.allow_async !== false)
29
+ return { allow_async: false };
30
+ return undefined;
31
+ }
32
+ function parseRead(raw) {
33
+ let outer = {};
34
+ try {
35
+ outer = JSON.parse(raw);
36
+ }
37
+ catch {
38
+ outer = { text: raw };
39
+ }
40
+ const text = typeof outer.text === "string" ? outer.text : raw;
41
+ if (!/<page[\s>]/.test(text) && !/<database[\s>]/.test(text))
42
+ return null;
43
+ const between = (tag) => {
44
+ const m = text.match(new RegExp(`<${tag}>\\n?([\\s\\S]*?)\\n?</${tag}>`));
45
+ return m ? m[1] : null;
46
+ };
47
+ let properties = null;
48
+ try {
49
+ const p = between("properties");
50
+ properties = p ? JSON.parse(p) : null;
51
+ }
52
+ catch {
53
+ properties = null;
54
+ }
55
+ const icon = text.match(/<page [^>]*\bicon="([^"]*)"/)?.[1] ?? null;
56
+ const c = outer.cover;
57
+ const cover = c === null ? null : c?.type === "external" && c.external?.url ? c.external.url : undefined;
58
+ // The first entry of the ancestor path is the direct parent; none means the workspace.
59
+ const path = between("ancestor-path");
60
+ let parent = null;
61
+ const first = path?.match(/<(parent-[a-z-]+)\s+url="([^"]+)"/);
62
+ if (!path || !first)
63
+ parent = { type: "workspace" };
64
+ else if (first[1] === "parent-data-source" && first[2].startsWith("collection://"))
65
+ parent = { type: "data_source_id", data_source_id: first[2].slice(13) };
66
+ else if (first[1] === "parent-page")
67
+ parent = { type: "page_id", page_id: bare(first[2].match(ID)?.at(-1) ?? "") };
68
+ else if (first[1] === "parent-database")
69
+ parent = { type: "database_id", database_id: bare(first[2].match(ID)?.at(-1) ?? "") };
70
+ if (parent && Object.values(parent).some((v) => v === ""))
71
+ parent = null;
72
+ // A page fetched in part can't be written back whole.
73
+ const complete = !outer.truncated && !(typeof outer.unknown_block_count === "number" && outer.unknown_block_count > 0) && !/"truncated":\s*true/.test(text);
74
+ return { text, properties, content: between("content"), icon, cover, parent, complete };
75
+ }
76
+ function editUndo(args, raw) {
77
+ const page = typeof args.page_id === "string" ? args.page_id : "";
78
+ const read = raw ? parseRead(raw) : null;
79
+ if (!page || !read)
80
+ return { kind: "never", reason: "Backstep couldn't read the page before it changed" };
81
+ const undo = { page_id: page };
82
+ // An icon or cover can ride along with any command.
83
+ if ("icon" in args)
84
+ undo.icon = read.icon ?? "none";
85
+ if ("cover" in args) {
86
+ if (read.cover === undefined)
87
+ return { kind: "never", reason: "the page's old cover was an upload Notion can't set back" };
88
+ undo.cover = read.cover ?? "none";
89
+ }
90
+ const command = args.command;
91
+ if (command === "update_properties") {
92
+ const changed = args.properties && typeof args.properties === "object" ? Object.keys(args.properties) : [];
93
+ if (changed.length && !read.properties)
94
+ return { kind: "never", reason: "Backstep couldn't read the page's properties before they changed" };
95
+ if (changed.length)
96
+ undo.properties = Object.fromEntries(changed.map((k) => [k, read.properties[k] ?? null]));
97
+ return { kind: "now", calls: [{ tool: "notion-update-page", args: { ...undo, command: "update_properties" } }], label: "put back a page's properties" };
98
+ }
99
+ if (command === "update_content" || command === "replace_content" || command === "insert_content" || command === "apply_template") {
100
+ if (read.content === null || !read.complete)
101
+ return { kind: "never", reason: "the page was too big for Backstep to read whole before it changed" };
102
+ return {
103
+ kind: "now",
104
+ calls: [{ tool: "notion-update-page", args: { ...undo, command: "replace_content", new_str: read.content, allow_deleting_content: true } }],
105
+ label: "put back a page's content",
106
+ };
107
+ }
108
+ return { kind: "never", reason: `Notion's ${String(command)} can't be taken back` };
109
+ }
110
+ function moveUndo(args, before) {
111
+ const ids = Array.isArray(args.page_or_database_ids) ? args.page_or_database_ids : [];
112
+ if (!ids.length || ids.length > 20)
113
+ return { kind: "never", reason: "too many pages moved at once for Backstep to note where each came from" };
114
+ // One call per old parent: pages that came from the same place go back together.
115
+ const groups = new Map();
116
+ for (const id of ids) {
117
+ const parent = before[id] ? parseRead(before[id])?.parent : null;
118
+ if (!parent)
119
+ return { kind: "never", reason: "Backstep couldn't tell where a moved page came from" };
120
+ const key = JSON.stringify(parent);
121
+ if (!groups.has(key))
122
+ groups.set(key, { parent, ids: [] });
123
+ groups.get(key).ids.push(id);
124
+ }
125
+ return {
126
+ kind: "now",
127
+ calls: [...groups.values()].map((g) => ({ tool: "notion-move-pages", args: { page_or_database_ids: g.ids, new_parent: g.parent } })),
128
+ label: `moved ${ids.length === 1 ? "a page" : `${ids.length} pages`} back`,
129
+ };
130
+ }
131
+ export function notionPlan(tool, args, before = {}) {
132
+ if (READS.test(tool))
133
+ return { kind: "read" };
134
+ const ids = (k) => (Array.isArray(args[k]) ? args[k].filter((x) => typeof x === "string") : []);
135
+ switch (tool) {
136
+ case "notion-create-pages":
137
+ return { kind: "after", made: "pages", label: "moved the pages Claude made to Trash" };
138
+ case "notion-duplicate-page":
139
+ return { kind: "after", made: "page", label: "moved the copy Claude made to Trash" };
140
+ case "notion-create-database":
141
+ return { kind: "after", made: "database", label: "moved the database Claude made to Trash" };
142
+ case "notion-delete-pages":
143
+ case "notion-delete-databases": {
144
+ const gone = ids(tool === "notion-delete-pages" ? "page_ids" : "database_ids");
145
+ if (!gone.length)
146
+ return { kind: "never", reason: "nothing to restore" };
147
+ return { kind: "now", calls: [{ tool: "notion-restore-pages", args: { page_ids: gone } }], label: `restored ${gone.length === 1 ? "it" : `${gone.length} of them`} from Trash` };
148
+ }
149
+ case "notion-restore-pages": {
150
+ const back = ids("page_ids");
151
+ return back.length ? { kind: "now", calls: [{ tool: "notion-delete-pages", args: { page_ids: back, user_confirmed: true } }], label: "moved them back to Trash" } : { kind: "never", reason: "nothing to put back" };
152
+ }
153
+ case "notion-update-page":
154
+ return editUndo(args, typeof args.page_id === "string" ? before[args.page_id] : undefined);
155
+ case "notion-move-pages":
156
+ return moveUndo(args, before);
157
+ }
158
+ return { kind: "never", reason: "Notion has no way to take this one back" };
159
+ }
160
+ // The ids of what a create call made, from its result, never the parent it was made under.
161
+ export function notionMade(made, args, text, want) {
162
+ const known = new Set((JSON.stringify(args).match(ID) ?? []).map(bare));
163
+ // The data source and views a new database comes with have ids of their own, written collection:// and view://.
164
+ const sources = new Set([...text.matchAll(/(?:collection|view):\/\/([0-9a-f-]{32,36})/gi)].map((m) => bare(m[1])));
165
+ let found = [];
166
+ try {
167
+ const r = JSON.parse(text);
168
+ if (Array.isArray(r.pages))
169
+ found = r.pages.map((p) => p.id ?? "").filter(Boolean);
170
+ else if (typeof r.id === "string")
171
+ found = [r.id];
172
+ }
173
+ catch {
174
+ // not JSON: read the ids from the text
175
+ }
176
+ if (!found.length)
177
+ found = text.match(ID) ?? [];
178
+ const ids = [...new Set(found.map(bare))].filter((id) => !known.has(id) && !sources.has(id));
179
+ return ids.length === want ? ids : null;
180
+ }
181
+ export function notionUndoAfter(made, args, text) {
182
+ const want = made === "pages" ? (Array.isArray(args.pages) ? args.pages.length : 0) : 1;
183
+ if (!want || want > 20)
184
+ return null;
185
+ const ids = notionMade(made, args, text, want);
186
+ if (!ids)
187
+ return null;
188
+ return made === "database"
189
+ ? { tool: "notion-delete-databases", args: { database_ids: ids, user_confirmed: true } }
190
+ : { tool: "notion-delete-pages", args: { page_ids: ids, user_confirmed: true } };
191
+ }
package/dist/paths.js ADDED
@@ -0,0 +1,31 @@
1
+ // Paths on every system Claude Code runs on. Windows writes C:\Users\me\app,
2
+ // and its Bash (Git Bash) writes the same folder as /c/Users/me/app; macOS and
3
+ // Linux write /Users/me/app. Each check here takes the path module to use, so
4
+ // tests can ask the Windows question on any machine.
5
+ import nodePath from "node:path";
6
+ import { homedir } from "node:os";
7
+ const native = nodePath;
8
+ const isWin = (p) => p.sep === "\\";
9
+ // A path as Git Bash on Windows writes it (/c/Users/me), turned into Windows' own (C:\Users\me).
10
+ export function fromShellPath(raw, p = native) {
11
+ if (!isWin(p))
12
+ return raw;
13
+ const m = raw.match(/^\/([a-zA-Z])(\/.*)?$/);
14
+ return m ? p.normalize(`${m[1].toUpperCase()}:${m[2] ?? "\\"}`) : raw;
15
+ }
16
+ // A path a tool or command named, made absolute against the folder it ran in. `~` is the home folder.
17
+ export function resolveIn(raw, cwd, p = native, home = homedir()) {
18
+ if (raw === "~" || raw.startsWith("~/") || (isWin(p) && raw.startsWith("~\\")))
19
+ return p.join(home, raw.slice(1));
20
+ return p.resolve(fromShellPath(cwd, p), fromShellPath(raw, p));
21
+ }
22
+ // Whether `path` is `dir` or inside it. Windows ignores letter case in paths.
23
+ export function insidePath(path, dir, p = native) {
24
+ const rel = p.relative(p.resolve(fromShellPath(dir, p)), p.resolve(fromShellPath(path, p)));
25
+ return rel === "" || (rel !== ".." && !rel.startsWith(".." + p.sep) && !p.isAbsolute(rel));
26
+ }
27
+ // The root of a disk: / on macOS and Linux, C:\ on Windows.
28
+ export function isDiskRoot(dir, p = native) {
29
+ const abs = p.resolve(dir);
30
+ return p.parse(abs).root === abs;
31
+ }
package/dist/servers.js CHANGED
@@ -1,4 +1,4 @@
1
- import { spawnSync } from "node:child_process";
1
+ import { spawnSync } from "./spawn.js";
2
2
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { join, resolve } from "node:path";
package/dist/setup.js CHANGED
@@ -1,4 +1,4 @@
1
- import { spawnSync } from "node:child_process";
1
+ import { spawnSync } from "./spawn.js";
2
2
  import { cpSync, readFileSync, rmSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
@@ -53,7 +53,7 @@ export async function setup() {
53
53
  if (run("claude", ["--version"]).missing) {
54
54
  throw new Error("Claude Code isn't installed (no `claude` on PATH). Install it from https://claude.com/claude-code, then run this again.");
55
55
  }
56
- const { root, onPath } = ROOT.includes("/_npx/") ? keepCopy() : { root: ROOT, onPath: true };
56
+ const { root, onPath } = /[\\/]_npx[\\/]/.test(ROOT) ? keepCopy() : { root: ROOT, onPath: true };
57
57
  const cli = join(root, "dist", "cli.js");
58
58
  // Without `backstep` on the PATH, the commands printed below go through npx.
59
59
  const bs = onPath ? "backstep" : "npx -y backstep";
package/dist/shell.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // Decides what a shell command Claude is about to run could do, before it runs.
2
2
  // Reads pass straight through; commands that reach outside the machine can't be
3
3
  // taken back, so they're held; everything else gets the project snapshotted.
4
+ import { resolveIn } from "./paths.js";
4
5
  // Programs that only look. A pipeline or chain is a read only if every part is.
5
6
  const READ_PROGRAMS = new Set([
6
7
  "ls", "ll", "la", "cat", "head", "tail", "less", "more", "grep", "egrep", "fgrep", "rg", "ag", "fd", "find", "tree",
@@ -139,7 +140,7 @@ export function commandDir(command, cwd) {
139
140
  const to = m?.[1] ?? m?.[2] ?? m?.[3];
140
141
  if (!to)
141
142
  return cwd;
142
- return to.startsWith("/") ? to : to.startsWith("~") ? to.replace(/^~/, process.env.HOME ?? "~") : `${cwd.replace(/\/$/, "")}/${to}`;
143
+ return resolveIn(to, cwd);
143
144
  }
144
145
  // Commands that change Firebase's data some other way than its MCP server.
145
146
  const FIREBASE_DATA = /\bfirebase(-tools)?\s+(firestore:|database:)|firestore\.googleapis\.com|firebaseio\.com|firebasedatabase\.app/;
package/dist/spawn.js ADDED
@@ -0,0 +1,6 @@
1
+ // Starting other programs (claude, npm, npx, gh) the same way on every system.
2
+ // On Windows most of them are .cmd scripts, which Node's own spawn can't start
3
+ // without a shell; cross-spawn finds them and quotes their arguments safely.
4
+ import crossSpawn from "cross-spawn";
5
+ export const spawnSync = crossSpawn.sync;
6
+ export const spawn = crossSpawn.spawn;
package/dist/undo.js CHANGED
@@ -8,6 +8,7 @@ import { snapshotDir } from "./store.js";
8
8
  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
+ import { insidePath } from "./paths.js";
11
12
  const INVERSE_TIMEOUT_MS = 20_000;
12
13
  // Database copies sit in the person's own database, so only the newest few per
13
14
  // database are kept; older commands keep their file undo but not their database undo.
@@ -60,7 +61,7 @@ function overlaps(a, b) {
60
61
  return true;
61
62
  if (!a.startsWith("fs:") || !b.startsWith("fs:"))
62
63
  return false;
63
- return a.startsWith(b + "/") || b.startsWith(a + "/");
64
+ return insidePath(a.slice(3), b.slice(3)) || insidePath(b.slice(3), a.slice(3));
64
65
  }
65
66
  // Failed calls stay undoable: a tool can change things before it errors out.
66
67
  function undoable(a) {
@@ -119,7 +120,13 @@ function undoEngine(store, a, u, opts) {
119
120
  if (!store.transition(a.id, a.status, "undo_requested")) {
120
121
  return { id: a.id, tool: a.tool, ok: false, detail: "this action changed while undoing; try again" };
121
122
  }
122
- return { id: a.id, tool: a.tool, ok: true, detail: `runs ${u.tool}`, run: { server: u.server, tool: u.tool, args: u.args } };
123
+ return {
124
+ id: a.id,
125
+ tool: a.tool,
126
+ ok: true,
127
+ detail: u.label ?? `runs ${u.tool}`,
128
+ run: { server: u.server, tool: u.tool, args: u.args, ...(u.more?.length ? { more: u.more } : {}), ...(u.label ? { label: u.label } : {}) },
129
+ };
123
130
  }
124
131
  async function undoOne(store, a, opts = {}) {
125
132
  const u = JSON.parse(a.undo);
package/dist/uninstall.js CHANGED
@@ -6,7 +6,7 @@
6
6
  // 4. the Claude Code plugin and its source are removed
7
7
  // 5. ~/.backstep (history, snapshots, saved connections) is deleted
8
8
  // 6. the global npm package, when setup installed one
9
- import { spawnSync } from "node:child_process";
9
+ import { spawnSync } from "./spawn.js";
10
10
  import { existsSync, rmSync } from "node:fs";
11
11
  import { homeDir, Store } from "./store.js";
12
12
  import { unwrapServers } from "./servers.js";
@@ -23,9 +23,12 @@ async function dropDatabaseCopies() {
23
23
  if (!existsSync(homeDir()))
24
24
  return null;
25
25
  const store = new Store();
26
+ // Closed right after reading: Windows won't delete a file that's still open.
27
+ const copies = store.databaseCopies();
28
+ store.close();
26
29
  const urls = new Map();
27
30
  let dropped = 0;
28
- for (const a of store.databaseCopies()) {
31
+ for (const a of copies) {
29
32
  const u = JSON.parse(a.undo);
30
33
  for (const d of u.dbs ?? []) {
31
34
  const url = await freshUrl(d.url).catch(() => d.url);
package/dist/update.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // whether npm has a newer version; if so it asks the person, and an update
3
3
  // runs the new version's own setup, the same command a first install runs.
4
4
  // Off with BACKSTEP_UPDATE_CHECK=0.
5
- import { spawnSync } from "node:child_process";
5
+ import { spawnSync } from "./spawn.js";
6
6
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
7
  import { join } from "node:path";
8
8
  import { homeDir } from "./store.js";
package/dist/watch.js CHANGED
@@ -12,6 +12,7 @@ import { buildInverse, findRule, loadRules } from "./rules.js";
12
12
  import { isWrappedServer } from "./servers.js";
13
13
  import { homeDir } from "./store.js";
14
14
  import { CONNECT_HINT, isSupabaseSql, supabaseDb, supabasePlanOptions, supabaseUrl } from "./supabase.js";
15
+ import { isNotionTool, notionArgs, notionPlan, notionUndoAfter } from "./notion.js";
15
16
  const DELETES = /(^|_|-)(delete|remove|trash|destroy|drop|purge|erase|archive)(_|-|$)/i;
16
17
  const MAX_RESULT = 20_000;
17
18
  // The engine spells MCP tools mcp__<server>__<tool>.
@@ -34,7 +35,8 @@ function unconnected(server, tool, args) {
34
35
  const plan = planCall(undefined, tool, args);
35
36
  return plan.kind === "sql" && !supabaseDb(typeof args.project_id === "string" ? args.project_id : undefined);
36
37
  }
37
- export async function watchStart(store, session, cwd, name, args) {
38
+ // `before`: pages the mod read right before the call (Notion edits and moves), by id.
39
+ export async function watchStart(store, session, cwd, name, args, before = {}) {
38
40
  const t = splitTool(name);
39
41
  if (!t)
40
42
  return { kind: "read" };
@@ -54,6 +56,22 @@ export async function watchStart(store, session, cwd, name, args) {
54
56
  release_at: null,
55
57
  });
56
58
  const rule = findRule(watchRules(), t.server, t.tool);
59
+ // Notion: its own opposite calls, built in. A rule the person wrote still wins.
60
+ if (!rule && isNotionTool(t.tool)) {
61
+ const n = notionPlan(t.tool, args, before);
62
+ if (n.kind === "read")
63
+ return { kind: "read" };
64
+ if (n.kind === "never")
65
+ return { kind: "write", id: record("write", n.reason).id, reason: n.reason };
66
+ const a = record("reversible", null);
67
+ if (n.kind === "now") {
68
+ const [first, ...more] = n.calls;
69
+ const undo = { adapter: "engine", server: t.server, tool: first.tool, args: first.args, ...(more.length ? { more } : {}), label: `Notion: ${n.label}` };
70
+ store.updateAction(a.id, { undo: JSON.stringify(undo) });
71
+ }
72
+ const rewrite = notionArgs(t.tool, args);
73
+ return rewrite ? { kind: "reversible", id: a.id, args: rewrite } : { kind: "reversible", id: a.id };
74
+ }
57
75
  const plan = rule ? null : planCall(undefined, t.tool, args);
58
76
  if (plan?.kind === "read")
59
77
  return { kind: "read" };
@@ -92,6 +110,14 @@ export async function watchStart(store, session, cwd, name, args) {
92
110
  reason = "no undo rule for this tool";
93
111
  return { kind, id: record(kind, reason ?? null).id, reason };
94
112
  }
113
+ // All the text in a result: what the model read, else the result's text parts.
114
+ function resultText(result, text) {
115
+ if (text)
116
+ return text;
117
+ if (result.structuredContent)
118
+ return JSON.stringify(result.structuredContent);
119
+ return (result.content ?? []).map((c) => (c.type === "text" ? c.text : "")).join("\n");
120
+ }
95
121
  // The engine hands the mod the tool's record and the text the model read.
96
122
  function asResult(r) {
97
123
  const rec = r.result;
@@ -121,7 +147,24 @@ export async function watchEnd(store, id, r) {
121
147
  return { undoable: true };
122
148
  }
123
149
  if (r.isError) {
124
- store.updateAction(id, { status: "error", error: text });
150
+ // An opposite call kept ahead (a Notion restore or rewrite) is dropped: the call changed nothing to take back.
151
+ store.updateAction(id, { status: "error", error: text, ...(kept?.adapter === "engine" ? { undo: null } : {}) });
152
+ return { undoable: false };
153
+ }
154
+ // Notion: kept ahead from the page as it was, or read now from what the call made.
155
+ if (isNotionTool(a.tool) && !findRule(watchRules(), server, a.tool)) {
156
+ if (kept?.adapter === "engine") {
157
+ store.updateAction(id, { status: "ok", result: text });
158
+ return { undoable: true };
159
+ }
160
+ const n = notionPlan(a.tool, args);
161
+ const call = n.kind === "after" ? notionUndoAfter(n.made, args, resultText(result, r.text)) : null;
162
+ if (n.kind === "after" && call) {
163
+ const undo = { adapter: "engine", server, tool: call.tool, args: call.args, label: `Notion: ${n.label}` };
164
+ store.updateAction(id, { status: "ok", result: text, undo: JSON.stringify(undo) });
165
+ return { undoable: true };
166
+ }
167
+ store.updateAction(id, { status: "ok", result: text, kind: "write", reason: "Backstep couldn't tell what Notion made from its answer" });
125
168
  return { undoable: false };
126
169
  }
127
170
  let undo = null;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backstep",
3
- "version": "0.1.19",
3
+ "version": "0.1.21",
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": {
@@ -11,11 +11,11 @@ const CLI = '/opt/backstep/cli.js'
11
11
 
12
12
  // The engine beneath the mod: a repo at /repo, and a Backstep CLI whose
13
13
  // `undo` answers with `undo`. Every argv the mod runs is recorded.
14
- function engine(on: On, undo: { exitCode: number; stdout: string; stderr?: string }, shell: object = { kind: 'read', undoable: false }) {
14
+ function engine(on: On, undo: { exitCode: number; stdout: string; stderr?: string }, shell: object = { kind: 'read', undoable: false }, cwd = '/repo') {
15
15
  const runs: string[][] = []
16
16
  const logs: string[] = []
17
17
  const prompts: { text: string; context?: readonly string[] }[] = []
18
- on('session.cwd', () => ({ value: '/repo' }))
18
+ on('session.cwd', () => ({ value: cwd }))
19
19
  on('session.id', () => ({ value: 's1' }))
20
20
  on('ui.log', (_, e) => {
21
21
  logs.push(e.text)
@@ -75,6 +75,25 @@ test('edits outside the open folder are never tracked', { options: { cli: CLI }
75
75
  await ui.unmount()
76
76
  })
77
77
 
78
+ test('on Windows, edits in the open folder are tracked however the path is written, and others never are', { options: { cli: CLI } }, async ($, on) => {
79
+ mock.clock(on, { now: 5000 })
80
+ const { runs } = engine(on, { exitCode: 0, stdout: '[]' }, undefined, 'C:\\Users\\me\\app')
81
+
82
+ await $.prompt.submit({ text: 'fix it', wait: false, origin: { kind: 'composer' } })
83
+ await $.tool.call({ tool: 'Edit', file_path: 'C:\\Users\\me\\app\\src\\a.ts', old_string: 'a', new_string: 'b' })
84
+ await $.tool.call({ tool: 'Edit', file_path: 'c:/users/me/APP/src/b.ts', old_string: 'a', new_string: 'b' })
85
+ await $.tool.call({ tool: 'Write', file_path: '/c/Users/me/app/src/c.ts', content: 'x' })
86
+ await $.tool.call({ tool: 'Write', file_path: 'C:\\Users\\me\\app-old\\x.ts', content: 'x' })
87
+ await $.tool.call({ tool: 'Write', file_path: 'C:\\Users\\me\\app\\..\\other\\x.ts', content: 'x' })
88
+ await $.tool.call({ tool: 'Write', file_path: 'D:\\Users\\me\\app\\x.ts', content: 'x' })
89
+
90
+ expect(runs.filter((r) => r.includes('snap')).map((r) => r.at(-1))).toEqual([
91
+ 'C:\\Users\\me\\app\\src\\a.ts',
92
+ 'c:/users/me/APP/src/b.ts',
93
+ '/c/Users/me/app/src/c.ts',
94
+ ])
95
+ })
96
+
78
97
  test('a long last turn stays on one line with a fixed gap after the logo', { options: { cli: CLI } }, async ($, on) => {
79
98
  mock.clock(on, { now: 5000 })
80
99
  engine(on, { exitCode: 0, stdout: '[]' }, { kind: 'write', undoable: true })
@@ -251,7 +270,7 @@ test('servers added since setup are wrapped as a session starts', { options: { c
251
270
 
252
271
  // A claude.ai connector: Backstep can't sit in front of it, so the mod reports
253
272
  // the call and, on undo, runs the rule's opposite call through Claude Code.
254
- function connector(on: On, opts: { start: object; undoable: boolean; mcpFails?: boolean }) {
273
+ function connector(on: On, opts: { start: object; undoable: boolean; mcpFails?: boolean; undo?: object }) {
255
274
  const runs: { argv: string[]; stdin?: string }[] = []
256
275
  const calls: { server: string; tool: string; args: Record<string, unknown> }[] = []
257
276
  const ran: Record<string, unknown>[] = []
@@ -270,12 +289,13 @@ function connector(on: On, opts: { start: object; undoable: boolean; mcpFails?:
270
289
  : e.argv.includes('watch-end')
271
290
  ? { undoable: opts.undoable }
272
291
  : e.argv.includes('undo')
273
- ? [{ id: 'w1', tool: 'create-page', ok: true, detail: 'runs archive-page', run: { server: 'claude_ai_Notion', tool: 'archive-page', args: { id: 'p1' } } }]
292
+ ? [opts.undo ?? { id: 'w1', tool: 'create-page', ok: true, detail: 'runs archive-page', run: { server: 'claude_ai_Notion', tool: 'archive-page', args: { id: 'p1' } } }]
274
293
  : null
275
294
  return { value: { exitCode: 0, stdout: out ? JSON.stringify(out) : '', stderr: '', isStdoutTruncated: false, isStderrTruncated: false } }
276
295
  })
277
296
  on('mcp.call', (_, e) => {
278
297
  calls.push({ server: e.server, tool: e.tool, args: e.args })
298
+ if (e.tool === 'notion-fetch') return { value: { content: [{ type: 'text', text: `page ${String(e.args.id)} as it was` }], isError: false } }
279
299
  return { value: { content: [{ type: 'text', text: opts.mcpFails ? 'no such page' : 'archived' }], isError: opts.mcpFails === true } }
280
300
  })
281
301
  on('tool.call', (_, e) => {
@@ -314,6 +334,39 @@ test('a connector call with an undo rule is undone through Claude Code', { optio
314
334
  await ui.unmount()
315
335
  })
316
336
 
337
+ test('a Notion move: the pages are read first, and undo runs every opposite call', { options: { cli: CLI } }, async ($, on) => {
338
+ const clock = mock.clock(on, { now: 5000 })
339
+ const back = { tool: 'notion-move-pages', args: { page_or_database_ids: ['p2'], new_parent: { type: 'workspace' } } }
340
+ const { runs, calls } = connector(on, {
341
+ start: { kind: 'reversible', id: 'w1' },
342
+ undoable: true,
343
+ undo: {
344
+ id: 'w1',
345
+ tool: 'notion-move-pages',
346
+ ok: true,
347
+ detail: 'Notion: moved 2 pages back',
348
+ run: { server: 'claude_ai_Notion', tool: 'notion-move-pages', args: { page_or_database_ids: ['p1'], new_parent: { type: 'page_id', page_id: 'home' } }, more: [back], label: 'Notion: moved 2 pages back' },
349
+ },
350
+ })
351
+
352
+ await $.prompt.submit({ text: 'tidy my notion', wait: false, origin: { kind: 'composer' } })
353
+ await $.tool.call({ tool: 'mcp__claude_ai_Notion__notion-move-pages', page_or_database_ids: ['p1', 'p2'], new_parent: { type: 'page_id', page_id: 'archive' } } as never)
354
+
355
+ // Read before the move, through Claude Code's own connection, and handed over with the call.
356
+ expect(calls.map((c) => [c.tool, c.args.id])).toEqual([['notion-fetch', 'p1'], ['notion-fetch', 'p2']])
357
+ expect(runs[0].argv.slice(3)).toEqual(['watch-start', '--session', 's1', '--cwd', '/repo', '--before', '--', 'mcp__claude_ai_Notion__notion-move-pages'])
358
+ expect(JSON.parse(runs[0].stdin ?? '').before).toEqual({ p1: 'page p1 as it was', p2: 'page p2 as it was' })
359
+
360
+ const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
361
+ await ui.press({ key: 'undo' })
362
+ const pressed = ui.press({ key: 'undo' })
363
+ await clock.advance(1000)
364
+ await pressed
365
+ expect(calls.slice(2).map((c) => c.args.page_or_database_ids)).toEqual([['p1'], ['p2']])
366
+ expect(runs.at(-1)?.argv.slice(3)).toEqual(['settle', 'w1'])
367
+ await ui.unmount()
368
+ })
369
+
317
370
  test('when the opposite call fails, the rewind says so', { options: { cli: CLI } }, async ($, on) => {
318
371
  const clock = mock.clock(on, { now: 5000 })
319
372
  const { runs } = connector(on, { start: { kind: 'reversible', id: 'w1' }, undoable: true, mcpFails: true })
@@ -57,7 +57,7 @@ type Outcome = {
57
57
  tool: string
58
58
  ok: boolean
59
59
  detail: string
60
- run?: { server: string; tool: string; args: Record<string, unknown> }
60
+ run?: { server: string; tool: string; args: Record<string, unknown>; more?: { tool: string; args: Record<string, unknown> }[]; label?: string }
61
61
  pushed?: { root: string; pushes: Pushed[] }
62
62
  github?: GhMade[]
63
63
  }
@@ -183,17 +183,38 @@ async function offerPushUndo($: EngineInterface, outcomes: Outcome[], repoGone =
183
183
  // The first press asks, right there above the prompt; a second press within a
184
184
  // few seconds undoes. A dialog would take the band off screen, and with it the
185
185
  // key, which would then suspend Claude Code in a terminal.
186
- // Whether a file path is inside the folder this session has open.
187
- function within(path: string, cwd: string): boolean {
188
- const abs = path.startsWith('/') ? path : `${cwd}/${path}`
186
+ // Whether a file path is inside the folder this session has open. Works for
187
+ // macOS and Linux paths (/Users/me/app), Windows ones (C:\Users\me\app, any
188
+ // letter case) and Git Bash's spelling of them (/c/Users/me/app).
189
+ function normPath(path: string, cwd: string): string {
190
+ const win = /^[a-zA-Z]:[\\/]/.test(cwd) || cwd.startsWith('\\\\')
191
+ let raw = path.replace(/\\/g, '/')
192
+ if (win) raw = raw.replace(/^\/([a-zA-Z])(?=\/|$)/, '$1:')
193
+ const base = cwd.replace(/\\/g, '/')
194
+ const absolute = win ? /^[a-zA-Z]:\//.test(raw) || raw.startsWith('//') : raw.startsWith('/')
195
+ const abs = absolute ? raw : `${base}/${raw}`
196
+ const unc = win && abs.startsWith('//')
189
197
  const parts: string[] = []
190
198
  for (const seg of abs.split('/')) {
191
199
  if (seg === '..') parts.pop()
192
200
  else if (seg && seg !== '.') parts.push(seg)
193
201
  }
194
- const p = '/' + parts.join('/')
195
- const d = cwd.replace(/\/+$/, '')
196
- return p === d || p.startsWith(d + '/')
202
+ const p = (unc ? '//' : win ? '' : '/') + parts.join('/')
203
+ return win ? p.toLowerCase() : p
204
+ }
205
+
206
+ function within(path: string, cwd: string): boolean {
207
+ const p = normPath(path, cwd)
208
+ const d = normPath('.', cwd)
209
+ return p === d || p.startsWith(d.replace(/\/$/, '') + '/')
210
+ }
211
+
212
+ // A file's path from the session's folder, for showing; anything else as given.
213
+ function relPath(path: string, cwd: string): string {
214
+ if (!within(path, cwd)) return path
215
+ const slashed = path.replace(/\\/g, '/')
216
+ const base = cwd.replace(/\\/g, '/').replace(/\/$/, '')
217
+ return slashed.length > base.length + 1 && slashed.toLowerCase().startsWith(base.toLowerCase() + '/') ? slashed.slice(base.length + 1) : path
197
218
  }
198
219
 
199
220
  async function pressUndo($: EngineInterface) {
@@ -229,7 +250,7 @@ async function undoLastTurn($: EngineInterface, count = 1) {
229
250
  })
230
251
 
231
252
  const cwd = await $.session.cwd()
232
- const short = (p: string) => (p.startsWith(cwd + '/') ? p.slice(cwd.length + 1) : p)
253
+ const short = (p: string) => relPath(p, cwd)
233
254
  const [ran] = await Promise.all([
234
255
  $.process.run(backstep(['undo', '--since', String(target.startedAt), '--session', await $.session.id(), '--json', '--via-claude-code'])).catch((err: unknown) => ({
235
256
  exitCode: 1,
@@ -254,18 +275,27 @@ async function undoLastTurn($: EngineInterface, count = 1) {
254
275
  // Opposite calls on servers only Claude Code is connected to run here, newest first.
255
276
  for (const o of outcomes) {
256
277
  if (!o.ok || !o.run) continue
257
- const done = await $.mcp.call(o.run.server, o.run.tool, o.run.args).catch((err: unknown) => ({
258
- isError: true,
259
- content: [{ type: 'text' as const, text: err instanceof Error ? err.message : String(err) }],
260
- }))
261
- if (done.isError) {
262
- const why = done.content.map((c) => (c.type === 'text' ? c.text : '')).join(' ').trim().split('\n')[0] || 'failed'
278
+ const run = o.run
279
+ let failed: { tool: string; why: string } | null = null
280
+ // The first opposite call, then any more on the same server (a move back to several places).
281
+ for (const call of [{ tool: run.tool, args: run.args }, ...(run.more ?? [])]) {
282
+ const done = await $.mcp.call(run.server, call.tool, call.args).catch((err: unknown) => ({
283
+ isError: true,
284
+ content: [{ type: 'text' as const, text: err instanceof Error ? err.message : String(err) }],
285
+ }))
286
+ if (done.isError) {
287
+ const why = done.content.map((c) => (c.type === 'text' ? c.text : '')).join(' ').trim().split('\n')[0] || 'failed'
288
+ failed = { tool: call.tool, why }
289
+ break
290
+ }
291
+ }
292
+ if (failed) {
263
293
  o.ok = false
264
- o.detail = `${o.run.tool} failed: ${why}`
265
- await $.process.run(backstep(['settle', o.id, '--failed', why])).catch(() => null)
294
+ o.detail = `${failed.tool} failed: ${failed.why}`
295
+ await $.process.run(backstep(['settle', o.id, '--failed', failed.why])).catch(() => null)
266
296
  break
267
297
  }
268
- o.detail = `ran ${o.run.tool}`
298
+ o.detail = run.label ?? `ran ${run.tool}`
269
299
  await $.process.run(backstep(['settle', o.id])).catch(() => null)
270
300
  }
271
301
  // Stop the list at the first failure, as the CLI does.
@@ -366,14 +396,32 @@ const RESERVED = new Set(['tool', 'tool_use_id', 'consent', 'agentId', 'requestM
366
396
  // call here and keeps an opposite call when an undo rule matches, which undo
367
397
  // runs through Claude Code's own connection. Supabase SQL is snapshotted
368
398
  // first, and a DROP comes back rewritten as a move to Backstep's trash.
399
+ // Notion edits and moves are undone by writing back the page as it was, so it's
400
+ // read here, through Claude Code's own connection, right before the call.
401
+ async function readNotionFirst($: EngineInterface, tool: string, args: Record<string, unknown>): Promise<Record<string, string> | null> {
402
+ const m = tool.match(/^mcp__(.+?)__(notion-update-page|notion-move-pages)$/)
403
+ if (!m) return null
404
+ const ids = m[2] === 'notion-update-page' ? [args.page_id] : Array.isArray(args.page_or_database_ids) ? args.page_or_database_ids.slice(0, 20) : []
405
+ const before: Record<string, string> = {}
406
+ for (const id of ids) {
407
+ if (typeof id !== 'string') continue
408
+ const r = await $.mcp.call(m[1], 'notion-fetch', { id }).catch(() => null)
409
+ if (r && !r.isError) before[id] = r.content.map((c) => (c.type === 'text' ? c.text : '')).join('')
410
+ }
411
+ return before
412
+ }
413
+
369
414
  async function watchMcp($: EngineInterface, e: Record<string, unknown>, next: Next) {
370
415
  const tool = String(e.tool)
371
416
  const t = await current($)
372
417
  const args = Object.fromEntries(Object.entries(e).filter(([k]) => !RESERVED.has(k)))
373
418
  const cwd = await $.session.cwd()
374
419
  const sid = await $.session.id()
420
+ const before = await readNotionFirst($, tool, args)
375
421
  const started = await $.process
376
- .run(backstep(['watch-start', '--session', sid, '--cwd', cwd, '--', tool]), { stdin: JSON.stringify(args) })
422
+ .run(backstep(['watch-start', '--session', sid, '--cwd', cwd, ...(before ? ['--before'] : []), '--', tool]), {
423
+ stdin: JSON.stringify(before ? { args, before } : args),
424
+ })
377
425
  .catch(() => null)
378
426
  let plan: WatchPlan | null = null
379
427
  try {
@@ -778,7 +826,7 @@ export const register: Register = (on, options) => {
778
826
  if (snap?.exitCode === 0) {
779
827
  await bump($, t.id, (x) => ({ ...x, gitOnly: false, edits: x.edits + 1, files: x.files.includes(path) ? x.files : [...x.files, path] }))
780
828
  } else {
781
- await bump($, t.id, (x) => lostOne({ ...x, gitOnly: false }, `edit to ${path.split('/').pop()}: Backstep couldn't save a copy first`))
829
+ await bump($, t.id, (x) => lostOne({ ...x, gitOnly: false }, `edit to ${path.split(/[\\/]/).pop()}: Backstep couldn't save a copy first`))
782
830
  }
783
831
  }
784
832
  } else if (tool === 'Bash') {
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "backstep",
3
- "version": "0.1.19",
3
+ "version": "0.1.21",
4
4
  "description": "Undo for everything your agents touch: an MCP proxy that makes agent actions reversible.",
5
+ "homepage": "https://getbackstep.com",
5
6
  "scripts": {
6
7
  "dev": "node src/cli.ts",
7
8
  "ui": "node --disable-warning=ExperimentalWarning src/cli.ts ui",
@@ -14,6 +15,7 @@
14
15
  "type": "module",
15
16
  "dependencies": {
16
17
  "@modelcontextprotocol/sdk": "^1.32.2",
18
+ "cross-spawn": "^7.0.6",
17
19
  "pg": "^8.23.1",
18
20
  "zod": "^4.6.7"
19
21
  },
@@ -21,6 +23,7 @@
21
23
  "@electric-sql/pglite": "^0.5.12",
22
24
  "@electric-sql/pglite-socket": "^0.2.15",
23
25
  "@modelcontextprotocol/server-filesystem": "^2.0.1",
26
+ "@types/cross-spawn": "^6.0.6",
24
27
  "@types/node": "^26.6.5",
25
28
  "@types/pg": "^8.23.1",
26
29
  "typescript": "^7.0.2"