backstep 0.1.19 → 0.1.20
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/check.js +4 -0
- package/dist/cli.js +10 -3
- package/dist/notion.js +191 -0
- package/dist/undo.js +7 -1
- package/dist/watch.js +45 -2
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.test.tsx +36 -2
- package/mod/hooks/register.tsx +38 -11
- package/package.json +2 -1
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.
|
package/dist/check.js
CHANGED
|
@@ -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({
|
|
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
|
-
|
|
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/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/undo.js
CHANGED
|
@@ -119,7 +119,13 @@ function undoEngine(store, a, u, opts) {
|
|
|
119
119
|
if (!store.transition(a.id, a.status, "undo_requested")) {
|
|
120
120
|
return { id: a.id, tool: a.tool, ok: false, detail: "this action changed while undoing; try again" };
|
|
121
121
|
}
|
|
122
|
-
return {
|
|
122
|
+
return {
|
|
123
|
+
id: a.id,
|
|
124
|
+
tool: a.tool,
|
|
125
|
+
ok: true,
|
|
126
|
+
detail: u.label ?? `runs ${u.tool}`,
|
|
127
|
+
run: { server: u.server, tool: u.tool, args: u.args, ...(u.more?.length ? { more: u.more } : {}), ...(u.label ? { label: u.label } : {}) },
|
|
128
|
+
};
|
|
123
129
|
}
|
|
124
130
|
async function undoOne(store, a, opts = {}) {
|
|
125
131
|
const u = JSON.parse(a.undo);
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
@@ -251,7 +251,7 @@ test('servers added since setup are wrapped as a session starts', { options: { c
|
|
|
251
251
|
|
|
252
252
|
// A claude.ai connector: Backstep can't sit in front of it, so the mod reports
|
|
253
253
|
// 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 }) {
|
|
254
|
+
function connector(on: On, opts: { start: object; undoable: boolean; mcpFails?: boolean; undo?: object }) {
|
|
255
255
|
const runs: { argv: string[]; stdin?: string }[] = []
|
|
256
256
|
const calls: { server: string; tool: string; args: Record<string, unknown> }[] = []
|
|
257
257
|
const ran: Record<string, unknown>[] = []
|
|
@@ -270,12 +270,13 @@ function connector(on: On, opts: { start: object; undoable: boolean; mcpFails?:
|
|
|
270
270
|
: e.argv.includes('watch-end')
|
|
271
271
|
? { undoable: opts.undoable }
|
|
272
272
|
: 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' } } }]
|
|
273
|
+
? [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
274
|
: null
|
|
275
275
|
return { value: { exitCode: 0, stdout: out ? JSON.stringify(out) : '', stderr: '', isStdoutTruncated: false, isStderrTruncated: false } }
|
|
276
276
|
})
|
|
277
277
|
on('mcp.call', (_, e) => {
|
|
278
278
|
calls.push({ server: e.server, tool: e.tool, args: e.args })
|
|
279
|
+
if (e.tool === 'notion-fetch') return { value: { content: [{ type: 'text', text: `page ${String(e.args.id)} as it was` }], isError: false } }
|
|
279
280
|
return { value: { content: [{ type: 'text', text: opts.mcpFails ? 'no such page' : 'archived' }], isError: opts.mcpFails === true } }
|
|
280
281
|
})
|
|
281
282
|
on('tool.call', (_, e) => {
|
|
@@ -314,6 +315,39 @@ test('a connector call with an undo rule is undone through Claude Code', { optio
|
|
|
314
315
|
await ui.unmount()
|
|
315
316
|
})
|
|
316
317
|
|
|
318
|
+
test('a Notion move: the pages are read first, and undo runs every opposite call', { options: { cli: CLI } }, async ($, on) => {
|
|
319
|
+
const clock = mock.clock(on, { now: 5000 })
|
|
320
|
+
const back = { tool: 'notion-move-pages', args: { page_or_database_ids: ['p2'], new_parent: { type: 'workspace' } } }
|
|
321
|
+
const { runs, calls } = connector(on, {
|
|
322
|
+
start: { kind: 'reversible', id: 'w1' },
|
|
323
|
+
undoable: true,
|
|
324
|
+
undo: {
|
|
325
|
+
id: 'w1',
|
|
326
|
+
tool: 'notion-move-pages',
|
|
327
|
+
ok: true,
|
|
328
|
+
detail: 'Notion: moved 2 pages back',
|
|
329
|
+
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' },
|
|
330
|
+
},
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
await $.prompt.submit({ text: 'tidy my notion', wait: false, origin: { kind: 'composer' } })
|
|
334
|
+
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)
|
|
335
|
+
|
|
336
|
+
// Read before the move, through Claude Code's own connection, and handed over with the call.
|
|
337
|
+
expect(calls.map((c) => [c.tool, c.args.id])).toEqual([['notion-fetch', 'p1'], ['notion-fetch', 'p2']])
|
|
338
|
+
expect(runs[0].argv.slice(3)).toEqual(['watch-start', '--session', 's1', '--cwd', '/repo', '--before', '--', 'mcp__claude_ai_Notion__notion-move-pages'])
|
|
339
|
+
expect(JSON.parse(runs[0].stdin ?? '').before).toEqual({ p1: 'page p1 as it was', p2: 'page p2 as it was' })
|
|
340
|
+
|
|
341
|
+
const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
|
|
342
|
+
await ui.press({ key: 'undo' })
|
|
343
|
+
const pressed = ui.press({ key: 'undo' })
|
|
344
|
+
await clock.advance(1000)
|
|
345
|
+
await pressed
|
|
346
|
+
expect(calls.slice(2).map((c) => c.args.page_or_database_ids)).toEqual([['p1'], ['p2']])
|
|
347
|
+
expect(runs.at(-1)?.argv.slice(3)).toEqual(['settle', 'w1'])
|
|
348
|
+
await ui.unmount()
|
|
349
|
+
})
|
|
350
|
+
|
|
317
351
|
test('when the opposite call fails, the rewind says so', { options: { cli: CLI } }, async ($, on) => {
|
|
318
352
|
const clock = mock.clock(on, { now: 5000 })
|
|
319
353
|
const { runs } = connector(on, { start: { kind: 'reversible', id: 'w1' }, undoable: true, mcpFails: true })
|
package/mod/hooks/register.tsx
CHANGED
|
@@ -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
|
}
|
|
@@ -254,18 +254,27 @@ async function undoLastTurn($: EngineInterface, count = 1) {
|
|
|
254
254
|
// Opposite calls on servers only Claude Code is connected to run here, newest first.
|
|
255
255
|
for (const o of outcomes) {
|
|
256
256
|
if (!o.ok || !o.run) continue
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
}))
|
|
261
|
-
|
|
262
|
-
|
|
257
|
+
const run = o.run
|
|
258
|
+
let failed: { tool: string; why: string } | null = null
|
|
259
|
+
// The first opposite call, then any more on the same server (a move back to several places).
|
|
260
|
+
for (const call of [{ tool: run.tool, args: run.args }, ...(run.more ?? [])]) {
|
|
261
|
+
const done = await $.mcp.call(run.server, call.tool, call.args).catch((err: unknown) => ({
|
|
262
|
+
isError: true,
|
|
263
|
+
content: [{ type: 'text' as const, text: err instanceof Error ? err.message : String(err) }],
|
|
264
|
+
}))
|
|
265
|
+
if (done.isError) {
|
|
266
|
+
const why = done.content.map((c) => (c.type === 'text' ? c.text : '')).join(' ').trim().split('\n')[0] || 'failed'
|
|
267
|
+
failed = { tool: call.tool, why }
|
|
268
|
+
break
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (failed) {
|
|
263
272
|
o.ok = false
|
|
264
|
-
o.detail = `${
|
|
265
|
-
await $.process.run(backstep(['settle', o.id, '--failed', why])).catch(() => null)
|
|
273
|
+
o.detail = `${failed.tool} failed: ${failed.why}`
|
|
274
|
+
await $.process.run(backstep(['settle', o.id, '--failed', failed.why])).catch(() => null)
|
|
266
275
|
break
|
|
267
276
|
}
|
|
268
|
-
o.detail = `ran ${
|
|
277
|
+
o.detail = run.label ?? `ran ${run.tool}`
|
|
269
278
|
await $.process.run(backstep(['settle', o.id])).catch(() => null)
|
|
270
279
|
}
|
|
271
280
|
// Stop the list at the first failure, as the CLI does.
|
|
@@ -366,14 +375,32 @@ const RESERVED = new Set(['tool', 'tool_use_id', 'consent', 'agentId', 'requestM
|
|
|
366
375
|
// call here and keeps an opposite call when an undo rule matches, which undo
|
|
367
376
|
// runs through Claude Code's own connection. Supabase SQL is snapshotted
|
|
368
377
|
// first, and a DROP comes back rewritten as a move to Backstep's trash.
|
|
378
|
+
// Notion edits and moves are undone by writing back the page as it was, so it's
|
|
379
|
+
// read here, through Claude Code's own connection, right before the call.
|
|
380
|
+
async function readNotionFirst($: EngineInterface, tool: string, args: Record<string, unknown>): Promise<Record<string, string> | null> {
|
|
381
|
+
const m = tool.match(/^mcp__(.+?)__(notion-update-page|notion-move-pages)$/)
|
|
382
|
+
if (!m) return null
|
|
383
|
+
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) : []
|
|
384
|
+
const before: Record<string, string> = {}
|
|
385
|
+
for (const id of ids) {
|
|
386
|
+
if (typeof id !== 'string') continue
|
|
387
|
+
const r = await $.mcp.call(m[1], 'notion-fetch', { id }).catch(() => null)
|
|
388
|
+
if (r && !r.isError) before[id] = r.content.map((c) => (c.type === 'text' ? c.text : '')).join('')
|
|
389
|
+
}
|
|
390
|
+
return before
|
|
391
|
+
}
|
|
392
|
+
|
|
369
393
|
async function watchMcp($: EngineInterface, e: Record<string, unknown>, next: Next) {
|
|
370
394
|
const tool = String(e.tool)
|
|
371
395
|
const t = await current($)
|
|
372
396
|
const args = Object.fromEntries(Object.entries(e).filter(([k]) => !RESERVED.has(k)))
|
|
373
397
|
const cwd = await $.session.cwd()
|
|
374
398
|
const sid = await $.session.id()
|
|
399
|
+
const before = await readNotionFirst($, tool, args)
|
|
375
400
|
const started = await $.process
|
|
376
|
-
.run(backstep(['watch-start', '--session', sid, '--cwd', cwd, '--', tool]), {
|
|
401
|
+
.run(backstep(['watch-start', '--session', sid, '--cwd', cwd, ...(before ? ['--before'] : []), '--', tool]), {
|
|
402
|
+
stdin: JSON.stringify(before ? { args, before } : args),
|
|
403
|
+
})
|
|
377
404
|
.catch(() => null)
|
|
378
405
|
let plan: WatchPlan | null = null
|
|
379
406
|
try {
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "backstep",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.20",
|
|
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",
|