backstep 0.1.31 → 0.1.33

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/CHANGES.json ADDED
@@ -0,0 +1,41 @@
1
+ [
2
+ {
3
+ "version": "0.1.33",
4
+ "notes": [
5
+ "After an update, Backstep shows what's new, like this."
6
+ ]
7
+ },
8
+ {
9
+ "version": "0.1.32",
10
+ "notes": [
11
+ "Undoing a Notion change in auto mode now finishes properly when Claude makes the last step.",
12
+ "Opening a page, file or app is no longer listed as something that couldn't be undone.",
13
+ "When something can't be undone, Backstep says why in plain words."
14
+ ]
15
+ },
16
+ {
17
+ "version": "0.1.31",
18
+ "notes": [
19
+ "In auto mode, Notion undos are finished by Claude straight away, with no extra question and no red error."
20
+ ]
21
+ },
22
+ {
23
+ "version": "0.1.30",
24
+ "notes": [
25
+ "Updates now say when they're done and it's safe to close the terminal.",
26
+ "Notion undos work in auto mode."
27
+ ]
28
+ },
29
+ {
30
+ "version": "0.1.29",
31
+ "notes": [
32
+ "If you edited a file after Claude's turn, undo asks before taking your changes along. Keep my changes undoes only Claude's."
33
+ ]
34
+ },
35
+ {
36
+ "version": "0.1.28",
37
+ "notes": [
38
+ "Updates install the new version even when Claude Code is open in a project named backstep."
39
+ ]
40
+ }
41
+ ]
package/README.md CHANGED
@@ -70,7 +70,7 @@ The undo key is ctrl+u (not ctrl+z: Claude Code suspends itself on ctrl+z in a t
70
70
 
71
71
  In the Claude Code desktop app, keys don't reach Backstep, so the line above the message box has an **Undo last turn** button to click instead (it asks once, then undoes). Typing `/backstep-undo` works everywhere and undoes straight away.
72
72
 
73
- Backstep checks npm for a newer version as each Claude Code session starts and asks before updating (Update, Not now, or Skip this version; `backstep update` does it by hand, `BACKSTEP_UPDATE_CHECK=0` turns the check off).
73
+ Backstep checks npm for a newer version as each Claude Code session starts and asks before updating (Update, Not now, or Skip this version; `backstep update` does it by hand, `BACKSTEP_UPDATE_CHECK=0` turns the check off). Once an update is in place, Backstep says what's new, from `CHANGES.json`.
74
74
 
75
75
  Other commands: `backstep ui`, `backstep log`, `backstep undo <action-id>`, `backstep undo --run <run-id>`, `backstep connect [supabase|firebase]` (connects what the project uses, the same as asking Claude), `backstep disconnect supabase <ref>`.
76
76
 
@@ -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";
@@ -57,7 +57,7 @@ import { connect } from "./connect.js";
57
57
  import { check, printCheck } from "./check.js";
58
58
  import { logo } from "./look.js";
59
59
  import { keyLabel, setUndoKey, undoKeys } from "./keys.js";
60
- import { checkUpdate, runUpdate, skipVersion } from "./update.js";
60
+ import { changesSince, checkUpdate, runUpdate, skipVersion } from "./update.js";
61
61
  import { askConnectionString } from "./setup.js";
62
62
  // Header values can name env vars, so tokens don't have to sit in agent config files.
63
63
  function parseHeaders(list) {
@@ -124,6 +124,8 @@ const HELP = `backstep: undo for everything your agents touch
124
124
  backstep undo --since <ms> [--until <ms>] [--json] [--via-claude-code] [--manual ask|undo|keep]
125
125
  undo everything any agent did in that window; files changed since
126
126
  (often by hand) are undone too, kept, or named first with ask
127
+ backstep changes --since <version> [--json]
128
+ what's new since that version
127
129
  backstep after <action-id> note how a tool left the files, once it ran (used by the Claude Code mod)
128
130
  backstep snap --session <id> --tool <name> [--cwd <dir>] -- <paths...>
129
131
  save paths before Claude Code's own tools change them (used by the Claude Code mod)
@@ -340,6 +342,19 @@ async function main() {
340
342
  return;
341
343
  }
342
344
  // `backstep update` installs the newest version; --skip <v> stops asking about that one.
345
+ // What's new since a version, for the mod to show once an update is in place.
346
+ if (cmd === "changes") {
347
+ const { values } = parseArgs({ args: rest, options: { since: { type: "string" }, json: { type: "boolean", default: false } } });
348
+ if (!values.since)
349
+ throw new Error("usage: backstep changes --since <version> [--json]");
350
+ const list = changesSince(values.since);
351
+ if (values.json)
352
+ console.log(JSON.stringify(list));
353
+ else
354
+ for (const c of list)
355
+ console.log(`${c.version}\n${c.notes.map((n) => ` - ${n}`).join("\n")}`);
356
+ return;
357
+ }
343
358
  if (cmd === "update") {
344
359
  const { values } = parseArgs({ args: rest, options: { skip: { type: "string" }, version: { type: "string" }, json: { type: "boolean", default: false } } });
345
360
  if (values.skip) {
@@ -520,13 +535,14 @@ async function main() {
520
535
  const kind = plan.kind === "outbound" && saved && /database|request/.test(plan.reason ?? "") ? "write" : plan.kind;
521
536
  if (kind !== plan.kind)
522
537
  store.updateAction(a.id, { kind: "reversible", reason: null });
538
+ 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
539
  if (!undo && kind !== "outbound")
524
- store.updateAction(a.id, { kind: "write", reason: "project too large to snapshot" });
540
+ store.updateAction(a.id, { kind: "write", reason: unsaved });
525
541
  if (missed.length)
526
542
  store.updateAction(a.id, { reason: `couldn't save the database first: ${missed[0]}` });
527
543
  if (dbs.length)
528
544
  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 }));
545
+ 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
546
  return;
531
547
  }
532
548
  // 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",
package/dist/update.js CHANGED
@@ -4,7 +4,8 @@
4
4
  // Off with BACKSTEP_UPDATE_CHECK=0.
5
5
  import { spawnSync } from "./spawn.js";
6
6
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
- import { join } from "node:path";
7
+ import { dirname, join } from "node:path";
8
+ import { fileURLToPath } from "node:url";
8
9
  import { tmpdir } from "node:os";
9
10
  import { homeDir } from "./store.js";
10
11
  import { version } from "./telemetry.js";
@@ -48,6 +49,15 @@ export async function checkUpdate() {
48
49
  const available = latest !== null && newer(latest, current) && settings().skipVersion !== latest;
49
50
  return { current, latest, available };
50
51
  }
52
+ export function changesSince(from, upTo = version()) {
53
+ try {
54
+ const all = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "CHANGES.json"), "utf8"));
55
+ return all.filter((c) => newer(c.version, from) && !newer(c.version, upTo)).sort((a, b) => (newer(a.version, b.version) ? -1 : 1));
56
+ }
57
+ catch {
58
+ return [];
59
+ }
60
+ }
51
61
  export function skipVersion(v) {
52
62
  mkdirSync(homeDir(), { recursive: true });
53
63
  writeFileSync(file(), JSON.stringify({ ...settings(), skipVersion: v }, null, 2) + "\n");
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backstep",
3
- "version": "0.1.31",
3
+ "version": "0.1.33",
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
 
@@ -1271,6 +1277,8 @@ test('an update that reloads the mod partway is finished by the reloaded mod: it
1271
1277
  }
1272
1278
  const stdout = argv.includes('update-check')
1273
1279
  ? JSON.stringify({ current: started ? '0.2.0' : '0.1.2', latest: '0.2.0', available: !started })
1280
+ : argv.includes('changes')
1281
+ ? JSON.stringify(argv.includes('0.1.2') ? [{ version: '0.2.0', notes: ['Undo is faster.', 'Notion undos finish in auto mode.'] }] : [])
1274
1282
  : argv.includes('status')
1275
1283
  ? JSON.stringify({ items: [], suggest: [] })
1276
1284
  : '[]'
@@ -1314,6 +1322,8 @@ test('an update that reloads the mod partway is finished by the reloaded mod: it
1314
1322
  }
1315
1323
  expect(statuses.at(-1)).toBeUndefined()
1316
1324
  expect(logs.join('\n')).toContain('Backstep is updated to 0.2.0 (you had 0.1.2). It\'s safe to close this terminal now.')
1325
+ // Then what changed, from the new version's own notes.
1326
+ expect(logs.join('\n')).toContain("What's new in 0.2.0:\n • Undo is faster.\n • Notion undos finish in auto mode.")
1317
1327
  await ui.unmount()
1318
1328
  })
1319
1329
 
@@ -1355,7 +1365,8 @@ test("when auto mode won't let Backstep undo a Notion change, Claude is asked to
1355
1365
  // Claude makes the call: it isn't recorded as a change of its own, and the change is marked undone.
1356
1366
  const before = ran.length
1357
1367
  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)
1368
+ // Claude adds an argument of its own (Notion's telemetry): still the same call.
1369
+ 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
1370
  expect(ran.length).toBe(before + 1)
1360
1371
  expect(runs.filter((r) => r.argv.includes('watch-start')).length).toBe(watched)
1361
1372
  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(/(?<=\.) /)
@@ -887,9 +905,26 @@ async function offerUpdate($: EngineInterface) {
887
905
  await installUpdate($, u.current, u.latest)
888
906
  }
889
907
 
890
- function updated($: EngineInterface, u: Updating) {
908
+ async function updated($: EngineInterface, u: Updating) {
891
909
  $.ui.log(`${MARK_TEXT} Backstep is updated to ${u.to}${u.from ? ` (you had ${u.from})` : ''}. It's safe to close ${u.win} now. Restart Claude Code to start using ${u.to}.`)
892
910
  $.ui.toast(`Backstep is updated to ${u.to}. Restart Claude Code to use it.`)
911
+ if (u.from) await whatsNew($, u.from)
912
+ }
913
+
914
+ // What changed since the version before, from the new version's own notes.
915
+ async function whatsNew($: EngineInterface, from: string) {
916
+ const ran = await $.process.run(backstep(['changes', '--since', from, '--json']), { timeoutMs: 15_000 }).catch(() => null)
917
+ let list: { version: string; notes: string[] }[] = []
918
+ try {
919
+ list = JSON.parse(ran?.stdout || '[]') as typeof list
920
+ } catch {
921
+ return
922
+ }
923
+ list = list.filter((c) => c.notes?.length)
924
+ if (!list.length) return
925
+ const one = list.length === 1
926
+ const lines = one ? list[0].notes.map((n) => ` • ${n}`) : list.flatMap((c) => [` ${c.version}`, ...c.notes.map((n) => ` • ${n}`)])
927
+ $.ui.log([`${MARK_TEXT} What's new${one ? ` in ${list[0].version}` : ` since ${from}`}:`, ...lines].join('\n'))
893
928
  }
894
929
 
895
930
  // After a reload in the middle of an update: once the new version is in place,
@@ -911,7 +946,7 @@ async function finishUpdate($: EngineInterface) {
911
946
  await $.clock.sleep(5000)
912
947
  $.ui.status(undefined)
913
948
  await update($, updating, () => null)
914
- updated($, u)
949
+ await updated($, u)
915
950
  return
916
951
  }
917
952
  await $.clock.sleep(5000)
@@ -943,7 +978,7 @@ async function installUpdate($: EngineInterface, current: string | undefined, la
943
978
  $.ui.status(undefined)
944
979
  await update($, updating, () => null)
945
980
  if (r.ok) {
946
- updated($, { ...(u.current ? { from: u.current } : {}), to: latest, win })
981
+ await updated($, { ...(u.current ? { from: u.current } : {}), to: latest, win })
947
982
  return true
948
983
  } else {
949
984
  $.ui.log(`${MARK_TEXT} Backstep couldn't update to ${u.latest}: ${r.detail}. You're still on ${u.current ?? 'the version you had'}${forced ? '' : ', which keeps working'}. To try again, run: npx backstep@latest setup`)
@@ -1139,19 +1174,22 @@ export const register: Register = (on, options) => {
1139
1174
  on('tool.call', async ($, e, next) => {
1140
1175
  const tool = String(e.tool)
1141
1176
  // 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) {
1177
+ const handed = handedOver.length ? handedCall(tool, Object.fromEntries(Object.entries(e).filter(([k]) => !RESERVED.has(k)))) : undefined
1178
+ if (handed) {
1179
+ const forAction = handed.action
1145
1180
  const r = (await next(e)) as { deny?: string; isError?: boolean; text?: string }
1181
+ const drop = (which: (h: Handed) => boolean) => {
1182
+ for (let i = handedOver.length - 1; i >= 0; i--) if (which(handedOver[i])) handedOver.splice(i, 1)
1183
+ }
1146
1184
  if (!r.deny && !r.isError) {
1147
- handedOver.delete(handed)
1148
- if (![...handedOver.values()].includes(forAction)) {
1185
+ drop((h) => h === handed)
1186
+ if (!handedOver.some((h) => h.action === forAction)) {
1149
1187
  await $.process.run(backstep(['settle', forAction])).catch(() => null)
1150
1188
  await handedBack($, forAction, true)
1151
1189
  }
1152
1190
  } else {
1153
1191
  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)
1192
+ drop((h) => h.action === forAction)
1155
1193
  await $.process.run(backstep(['settle', forAction, '--failed', why])).catch(() => null)
1156
1194
  await handedBack($, forAction, false, why)
1157
1195
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backstep",
3
- "version": "0.1.31",
3
+ "version": "0.1.33",
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": {
@@ -37,7 +37,8 @@
37
37
  "files": [
38
38
  "dist",
39
39
  "mod",
40
- "README.md"
40
+ "README.md",
41
+ "CHANGES.json"
41
42
  ],
42
43
  "keywords": [
43
44
  "mcp",