backstep 0.1.31 → 0.1.32

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.
@@ -195,6 +195,7 @@ function projectGit(root, actionId, pushes) {
195
195
  // Returns null when the folder can't be snapshotted: the home folder or the
196
196
  // whole disk, or a folder too big to stage in time.
197
197
  export function snapshotTree(actionId, cwd, opts = {}) {
198
+ lastTreeError = null;
198
199
  const root = projectRoot(cwd);
199
200
  if (isDiskRoot(root) || root === homedir() || root === resolve(homeDir()))
200
201
  return null;
@@ -206,10 +207,13 @@ export function snapshotTree(actionId, cwd, opts = {}) {
206
207
  git(["update-ref", `refs/backstep/${actionId}`, commit], { gitDir, workTree: root });
207
208
  return { adapter: "tree", root, gitDir, commit, git: projectGit(root, actionId, opts.pushes === true) };
208
209
  }
209
- catch {
210
+ catch (err) {
211
+ lastTreeError = (err instanceof Error ? err.stderr || err.message : String(err)).trim().split("\n").find(Boolean) ?? null;
210
212
  return null;
211
213
  }
212
214
  }
215
+ // Why the last snapshot couldn't be made, in git's words: null when it was too big or the folder is off limits.
216
+ export let lastTreeError = null;
213
217
  // Once the command has run: the folder as it left it, kept like the snapshot before it.
214
218
  export function recordTreeAfter(u, actionId) {
215
219
  const tree = stage(u.gitDir, u.root);
package/dist/cli.js CHANGED
@@ -6,7 +6,7 @@ import { startUi } from "./ui/server.js";
6
6
  import { Store, snapshotDir } from "./store.js";
7
7
  import { pendingManual, pruneDatabaseCopies, undoAction, undoRun, undoSince } from "./undo.js";
8
8
  import { insideDir, recordAfter, snapshotPaths } from "./adapters/fs.js";
9
- import { notePushed, recordTreeAfter, snapshotSqlite, snapshotTree } from "./adapters/tree.js";
9
+ import { lastTreeError, 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";
@@ -520,13 +520,14 @@ async function main() {
520
520
  const kind = plan.kind === "outbound" && saved && /database|request/.test(plan.reason ?? "") ? "write" : plan.kind;
521
521
  if (kind !== plan.kind)
522
522
  store.updateAction(a.id, { kind: "reversible", reason: null });
523
+ const unsaved = lastTreeError ? `Backstep couldn't save a copy first (${lastTreeError})` : "Backstep couldn't save a copy first (the folder is too big, or it's your whole home folder)";
523
524
  if (!undo && kind !== "outbound")
524
- store.updateAction(a.id, { kind: "write", reason: "project too large to snapshot" });
525
+ store.updateAction(a.id, { kind: "write", reason: unsaved });
525
526
  if (missed.length)
526
527
  store.updateAction(a.id, { reason: `couldn't save the database first: ${missed[0]}` });
527
528
  if (dbs.length)
528
529
  await pruneDatabaseCopies(store).catch(() => { });
529
- console.log(JSON.stringify({ kind, reason: kind === plan.kind ? plan.reason : undefined, id: a.id, undoable: undo !== null, db: saved }));
530
+ console.log(JSON.stringify({ kind, reason: !undo && kind !== "outbound" ? unsaved : kind === plan.kind ? plan.reason : undefined, id: a.id, undoable: undo !== null, db: saved }));
530
531
  return;
531
532
  }
532
533
  // Before a shell command, from the Claude Code mod: does it reach a Supabase project Backstep
package/dist/shell.js CHANGED
@@ -10,6 +10,8 @@ const READ_PROGRAMS = new Set([
10
10
  "cut", "tr", "column", "jq", "yq", "date", "whoami", "id", "uname", "env", "printenv", "ps", "top", "uptime",
11
11
  "basename", "dirname", "realpath", "readlink", "md5", "md5sum", "shasum", "sha256sum", "true", "false", "test", "[",
12
12
  "sleep", "man", "tldr", "history", "lsof", "hostname", "cal", "nproc",
13
+ // Opening a page, a file or an app in it changes nothing here by itself.
14
+ "open", "xdg-open",
13
15
  ]);
14
16
  const READ_GIT = new Set([
15
17
  "status", "log", "diff", "show", "blame", "grep", "ls-files", "ls-tree", "rev-parse", "describe", "shortlog",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backstep",
3
- "version": "0.1.31",
3
+ "version": "0.1.32",
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": {
@@ -373,9 +373,14 @@ test('a Notion move: the pages are read first, and undo runs every opposite call
373
373
  await ui.unmount()
374
374
  })
375
375
 
376
- test('when the opposite call fails, the rewind says so', { options: { cli: CLI } }, async ($, on) => {
376
+ test('when the opposite call fails, the rewind says so, plainly', { options: { cli: CLI } }, async ($, on) => {
377
377
  const clock = mock.clock(on, { now: 5000 })
378
- const { runs } = connector(on, { start: { kind: 'reversible', id: 'w1' }, undoable: true, mcpFails: true })
378
+ const { runs } = connector(on, {
379
+ start: { kind: 'reversible', id: 'w1' },
380
+ undoable: true,
381
+ mcpFails: true,
382
+ undo: { id: 'w1', tool: 'notion-create-pages', ok: true, detail: 'runs notion-delete-pages', run: { server: 'claude_ai_Notion', tool: 'notion-delete-pages', args: { page_ids: ['p1'] }, label: 'Notion: moved the pages Claude made to Trash' } },
383
+ })
379
384
 
380
385
  await $.prompt.submit({ text: 'make a page', wait: false, origin: { kind: 'composer' } })
381
386
  await $.tool.call({ tool: 'mcp__claude_ai_Notion__create-page', title: 'Plan' } as never)
@@ -385,7 +390,8 @@ test('when the opposite call fails, the rewind says so', { options: { cli: CLI }
385
390
  await clock.advance(1000)
386
391
  await pressed
387
392
  expect(runs.at(-1)?.argv.slice(3)).toEqual(['settle', 'w1', '--failed', 'no such page'])
388
- expect((await ui.find({ type: 'Text', text: /no such page/ }))).toBeTruthy()
393
+ expect((await ui.find({ type: 'Text', text: /no such page/ }))?.text).toContain(`Couldn't move the pages Claude made to Trash: Notion said "no such page"`)
394
+ expect(await ui.find({ type: 'Text', text: /notion-delete-pages failed/ })).toBeFalsy()
389
395
  await ui.unmount()
390
396
  })
391
397
 
@@ -1355,7 +1361,8 @@ test("when auto mode won't let Backstep undo a Notion change, Claude is asked to
1355
1361
  // Claude makes the call: it isn't recorded as a change of its own, and the change is marked undone.
1356
1362
  const before = ran.length
1357
1363
  const watched = runs.filter((r) => r.argv.includes('watch-start')).length
1358
- await $.tool.call({ tool: 'mcp__claude_ai_Notion__notion-delete-pages', page_ids: ['p1'], user_confirmed: true, tool_use_id: 'tu2' } as never)
1364
+ // Claude adds an argument of its own (Notion's telemetry): still the same call.
1365
+ await $.tool.call({ tool: 'mcp__claude_ai_Notion__notion-delete-pages', page_ids: ['p1'], user_confirmed: true, telemetry: { intent: 'undo' }, tool_use_id: 'tu2' } as never)
1359
1366
  expect(ran.length).toBe(before + 1)
1360
1367
  expect(runs.filter((r) => r.argv.includes('watch-start')).length).toBe(watched)
1361
1368
  expect(runs.at(-1)?.argv.slice(3)).toEqual(['settle', 'w1'])
@@ -395,7 +395,7 @@ async function undoLastTurn($: EngineInterface, count = 1) {
395
395
  }
396
396
  if (failed) {
397
397
  o.ok = false
398
- o.detail = `${failed.tool} failed: ${failed.why}`
398
+ o.detail = couldnt(run.label, failed.why, `undo ${o.tool}`)
399
399
  await $.process.run(backstep(['settle', o.id, '--failed', failed.why])).catch(() => null)
400
400
  break
401
401
  }
@@ -415,7 +415,7 @@ async function undoLastTurn($: EngineInterface, count = 1) {
415
415
  .filter(Boolean)
416
416
  .map((line): RewindItem => (o.pending ? { label: undoneLabel(line, short), ok: true, pending: true, action: o.id } : { label: undoneLabel(line, short), ok: true })),
417
417
  )
418
- const stopped = outcomes.filter((o) => !o.ok).map((o) => ({ label: `${o.tool}: ${o.detail.split('\n')[0] ?? 'failed'}`, ok: false }))
418
+ const stopped = outcomes.filter((o) => !o.ok).map((o) => { const line = o.detail.split('\n')[0] ?? 'failed'; return { label: line.startsWith("Couldn't ") ? line : `${o.tool}: ${line}`, ok: false } })
419
419
  // A push, or something made on GitHub, is offered back below, so it isn't listed as lost.
420
420
  const offered = outcomes.some((o) => o.ok && o.pushed)
421
421
  const offeredGh = outcomes.some((o) => o.ok && o.github?.length)
@@ -599,13 +599,31 @@ function undoneLabel(line: string, short: (p: string) => string): string {
599
599
  return `${p} ${m[1]}`
600
600
  }
601
601
 
602
- // Opposite calls handed to Claude to make, after the person said yes: the call,
603
- // by tool and arguments, to the action it undoes. Not recorded as Claude's own,
604
- // and once all of an action's have run, it counts as undone.
605
- const handedOver = new Map<string, string>()
602
+ // Opposite calls handed to Claude to make after the person pressed undo: the call
603
+ // and the action it undoes. Not recorded as Claude's own, and once all of an
604
+ // action's have run, it counts as undone.
605
+ type Handed = { tool: string; args: Record<string, unknown>; action: string }
606
+ const handedOver: Handed[] = []
607
+
608
+ // Claude's call is the one handed over when it's the same tool with the same
609
+ // values for every argument Backstep gave; Claude adds its own (Notion's telemetry, say).
610
+ function handedCall(tool: string, args: Record<string, unknown>): Handed | undefined {
611
+ return handedOver.find((h) => h.tool === tool && Object.entries(h.args).every(([k, v]) => undoCallKey('', v) === undoCallKey('', args[k])))
612
+ }
606
613
 
607
614
  type Handoff = { id: string; server: string; calls: { tool: string; args: Record<string, unknown> }[]; label: string }
608
615
 
616
+ // A change that didn't come back, said plainly: "Notion: moved the pages Claude made to Trash"
617
+ // that failed reads "Couldn't move the pages Claude made to Trash: Notion said "no such page"".
618
+ const INFINITIVE: Record<string, string> = { moved: 'move', restored: 'restore', put: 'put', archived: 'archive', deleted: 'delete', removed: 'remove', ran: 'run', closed: 'close', reopened: 'reopen', set: 'set' }
619
+ function couldnt(label: string | undefined, why: string, fallback: string): string {
620
+ const reason = why.replace(/^(backstep|error):\s*/i, '').split(/(?<=\.)\s/)[0].slice(0, 140)
621
+ const m = label?.match(/^(?:([A-Z][\w.]*(?: [A-Z][\w.]*)*): )?(\w+)\s(.*)$/)
622
+ const verb = m ? INFINITIVE[m[2]] : undefined
623
+ const what = m && verb ? `${verb} ${m[3]}` : fallback
624
+ return m?.[1] ? `Couldn't ${what}: ${m[1]} said "${reason}"` : `Couldn't ${what}: ${reason}`
625
+ }
626
+
609
627
  // Said after "Rewound …" while Claude still has some of it to finish.
610
628
  function finishing(items: RewindItem[]): string {
611
629
  const n = items.filter((i) => i.pending).length
@@ -620,7 +638,7 @@ async function handOff($: EngineInterface, list: Handoff[]) {
620
638
  for (const h of list) {
621
639
  const server = toolServer(h.server)
622
640
  for (const c of h.calls) {
623
- handedOver.set(undoCallKey(`mcp__${server}__${c.tool}`, c.args), h.id)
641
+ handedOver.push({ tool: `mcp__${server}__${c.tool}`, args: c.args, action: h.id })
624
642
  steps.push(`- mcp__${server}__${c.tool} with ${JSON.stringify(c.args)}`)
625
643
  }
626
644
  }
@@ -638,7 +656,7 @@ async function handOff($: EngineInterface, list: Handoff[]) {
638
656
  async function handedBack($: EngineInterface, action: string, ok: boolean, why?: string) {
639
657
  await update($, rewind, (r) => {
640
658
  if (!r) return r
641
- const items = r.items.map((i) => (i.pending && i.action === action ? (ok ? { ...i, pending: false } : { label: `${i.label}: Claude couldn't (${why ?? 'it failed'})`, ok: false }) : i))
659
+ const items = r.items.map((i) => (i.pending && i.action === action ? (ok ? { ...i, pending: false } : { label: couldnt(i.label, why ?? 'it failed', 'finish undoing it'), ok: false }) : i))
642
660
  const undone = items.filter((i) => i.ok)
643
661
  // "Rewound 2 changes from the last turn. Claude is finishing it." loses its last sentence once Claude has.
644
662
  const [rewound, ...rest] = r.message.replace(/ Claude is finishing [^.]*\./, '').split(/(?<=\.) /)
@@ -1139,19 +1157,22 @@ export const register: Register = (on, options) => {
1139
1157
  on('tool.call', async ($, e, next) => {
1140
1158
  const tool = String(e.tool)
1141
1159
  // An opposite call Claude is making for undo, as the person asked: not recorded as a change of its own.
1142
- const handed = handedOver.size ? undoCallKey(tool, Object.fromEntries(Object.entries(e).filter(([k]) => !RESERVED.has(k)))) : ''
1143
- const forAction = handed ? handedOver.get(handed) : undefined
1144
- if (forAction) {
1160
+ const handed = handedOver.length ? handedCall(tool, Object.fromEntries(Object.entries(e).filter(([k]) => !RESERVED.has(k)))) : undefined
1161
+ if (handed) {
1162
+ const forAction = handed.action
1145
1163
  const r = (await next(e)) as { deny?: string; isError?: boolean; text?: string }
1164
+ const drop = (which: (h: Handed) => boolean) => {
1165
+ for (let i = handedOver.length - 1; i >= 0; i--) if (which(handedOver[i])) handedOver.splice(i, 1)
1166
+ }
1146
1167
  if (!r.deny && !r.isError) {
1147
- handedOver.delete(handed)
1148
- if (![...handedOver.values()].includes(forAction)) {
1168
+ drop((h) => h === handed)
1169
+ if (!handedOver.some((h) => h.action === forAction)) {
1149
1170
  await $.process.run(backstep(['settle', forAction])).catch(() => null)
1150
1171
  await handedBack($, forAction, true)
1151
1172
  }
1152
1173
  } else {
1153
1174
  const why = (r.deny ?? r.text ?? 'it failed').trim().split('\n')[0]
1154
- for (const [k, a] of handedOver) if (a === forAction) handedOver.delete(k)
1175
+ drop((h) => h.action === forAction)
1155
1176
  await $.process.run(backstep(['settle', forAction, '--failed', why])).catch(() => null)
1156
1177
  await handedBack($, forAction, false, why)
1157
1178
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backstep",
3
- "version": "0.1.31",
3
+ "version": "0.1.32",
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": {