backstep 0.1.30 → 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.30",
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
 
@@ -1317,24 +1323,23 @@ test('an update that reloads the mod partway is finished by the reloaded mod: it
1317
1323
  await ui.unmount()
1318
1324
  })
1319
1325
 
1320
- test("when auto mode won't let Backstep undo a Notion change, Claude is asked to, and the change counts as undone once it has", { options: { cli: CLI } }, async ($, on) => {
1326
+ // Auto mode refuses Backstep's own call: pressing undo was the go-ahead, so Claude is asked to make it.
1327
+ async function refusedNotionUndo($: Parameters<Parameters<typeof test>[2]>[0], on: On) {
1321
1328
  const clock = mock.clock(on, { now: 5000 })
1322
1329
  const refusal = "backstep: $.mcp.call(claude_ai_Notion, notion-delete-pages) refused: The server-side auto mode classifier gave no verdict for mcp__claude_ai_Notion__notion-delete-pages"
1323
1330
  const asked: string[] = []
1324
1331
  on('tool.call', { tool: 'AskUserQuestion' }, (_, e) => {
1325
- const questions = (e as unknown as { questions: { question: string }[] }).questions
1326
- asked.push(questions[0].question)
1327
- return { result: { questions, answers: { [questions[0].question]: 'Ask Claude to do it' } } }
1332
+ asked.push((e as unknown as { questions: { question: string }[] }).questions[0].question)
1333
+ return { result: {} }
1328
1334
  })
1329
- const { runs, ran, prompts } = connector(on, {
1335
+ const env = connector(on, {
1330
1336
  start: { kind: 'reversible', id: 'w1' },
1331
1337
  undoable: true,
1332
1338
  mcpThrows: refusal,
1333
- undo: { id: 'w1', tool: 'notion-create-pages', ok: true, detail: 'Notion: deleted the page', run: { server: 'claude_ai_Notion', tool: 'notion-delete-pages', args: { page_ids: ['p1'] }, label: 'Notion: delete the page Claude made' } },
1339
+ 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'], user_confirmed: true }, label: 'Notion: moved the pages Claude made to Trash' } },
1334
1340
  })
1335
1341
  await $.prompt.submit({ text: 'make a page', wait: false, origin: { kind: 'composer' } })
1336
1342
  await $.tool.call({ tool: 'mcp__claude_ai_Notion__notion-create-pages', pages: [{ title: 'Test' }], tool_use_id: 'tu1' } as never)
1337
-
1338
1343
  const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
1339
1344
  await ui.press({ key: 'undo' })
1340
1345
  const pressed = ui.press({ key: 'undo' })
@@ -1342,16 +1347,28 @@ test("when auto mode won't let Backstep undo a Notion change, Claude is asked to
1342
1347
  await pressed
1343
1348
  await clock.advance(1000)
1344
1349
  await settle()
1345
- expect(runs.some((r) => r.argv.includes('settle') && r.argv.includes('--failed'))).toBe(true)
1346
- expect(asked).toEqual(["Claude Code's auto mode didn't let Backstep undo this by itself: Notion: delete the page Claude made. Ask Claude to undo it?"])
1347
- expect(prompts.at(-1)).toContain('mcp__claude_ai_Notion__notion-delete-pages with {"page_ids":["p1"]}')
1350
+ return { ...env, asked, ui }
1351
+ }
1352
+
1353
+ test("when auto mode won't let Backstep undo a Notion change, Claude is asked to make the call, with no question and nothing shown as failed", { options: { cli: CLI } }, async ($, on) => {
1354
+ const { runs, ran, prompts, asked, ui } = await refusedNotionUndo($, on)
1355
+ expect(asked).toEqual([])
1356
+ expect(runs.some((r) => r.argv.includes('--failed'))).toBe(false)
1357
+ expect(prompts.at(-1)).toContain('mcp__claude_ai_Notion__notion-delete-pages with {"page_ids":["p1"],"user_confirmed":true}')
1358
+ expect((await ui.find({ type: 'Text', text: /Rewound/ }))?.text).toContain('Rewound 1 change from the last turn. Claude is finishing it.')
1359
+ expect(await ui.find({ type: 'Text', text: /Not undone/ })).toBeFalsy()
1348
1360
 
1349
1361
  // Claude makes the call: it isn't recorded as a change of its own, and the change is marked undone.
1350
1362
  const before = ran.length
1351
1363
  const watched = runs.filter((r) => r.argv.includes('watch-start')).length
1352
- await $.tool.call({ tool: 'mcp__claude_ai_Notion__notion-delete-pages', page_ids: ['p1'], 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)
1353
1366
  expect(ran.length).toBe(before + 1)
1354
1367
  expect(runs.filter((r) => r.argv.includes('watch-start')).length).toBe(watched)
1355
1368
  expect(runs.at(-1)?.argv.slice(3)).toEqual(['settle', 'w1'])
1369
+ const line = await ui.find({ type: 'Text', text: /Rewound/ })
1370
+ expect(line?.text).toContain('Rewound 1 change from the last turn.')
1371
+ expect(line?.text).not.toContain('Claude is finishing')
1372
+ expect(await ui.find({ type: 'Text', text: /Claude is finishing this/ })).toBeFalsy()
1356
1373
  await ui.unmount()
1357
1374
  })
@@ -65,6 +65,8 @@ type Outcome = {
65
65
  tool: string
66
66
  ok: boolean
67
67
  detail: string
68
+ // Handed to Claude to finish: auto mode wouldn't let Backstep make the call.
69
+ pending?: boolean
68
70
  run?: { server: string; tool: string; args: Record<string, unknown>; more?: { tool: string; args: Record<string, unknown> }[]; label?: string }
69
71
  pushed?: { root: string; pushes: Pushed[] }
70
72
  github?: GhMade[]
@@ -361,32 +363,43 @@ async function undoLastTurn($: EngineInterface, count = 1) {
361
363
  }
362
364
 
363
365
  // Opposite calls on servers only Claude Code is connected to run here, newest first.
364
- let handoff: Handoff | null = null
366
+ // Once auto mode refuses one, it and every one after it go to Claude, in order.
367
+ const handoff: Handoff[] = []
365
368
  for (const o of outcomes) {
366
369
  if (!o.ok || !o.run) continue
367
370
  const run = o.run
371
+ const all = [{ tool: run.tool, args: run.args }, ...(run.more ?? [])]
372
+ const label = run.label ?? `ran ${run.tool}`
373
+ if (handoff.length) {
374
+ handoff.push({ id: o.id, server: run.server, calls: all, label })
375
+ o.pending = true
376
+ o.detail = label
377
+ continue
378
+ }
368
379
  let failed: { tool: string; why: string } | null = null
369
380
  // The first opposite call, then any more on the same server (a move back to several places).
370
- const all = [{ tool: run.tool, args: run.args }, ...(run.more ?? [])]
371
381
  for (const [i, call] of all.entries()) {
372
382
  const done = await undoCall($, run.server, call.tool, call.args)
373
383
  if (done.isError) {
374
384
  const why = done.content.map((c) => (c.type === 'text' ? c.text : '')).join(' ').trim().split('\n')[0] || 'failed'
375
- // Claude Code's auto mode won't let Backstep make it alone: Claude can, if the person says so.
376
- if (REFUSED.test(why)) {
377
- handoff = { id: o.id, server: run.server, calls: all.slice(i), label: run.label ?? `${o.tool}: ${call.tool}` }
378
- failed = { tool: call.tool, why: "Claude Code's auto mode didn't let Backstep make this change itself" }
379
- } else failed = { tool: call.tool, why }
385
+ // Claude Code's auto mode won't let Backstep make it alone; the person's undo is the go-ahead, so Claude makes it.
386
+ if (REFUSED.test(why)) handoff.push({ id: o.id, server: run.server, calls: all.slice(i), label })
387
+ else failed = { tool: call.tool, why }
380
388
  break
381
389
  }
382
390
  }
391
+ if (handoff.length) {
392
+ o.pending = true
393
+ o.detail = label
394
+ continue
395
+ }
383
396
  if (failed) {
384
397
  o.ok = false
385
- o.detail = `${failed.tool} failed: ${failed.why}`
398
+ o.detail = couldnt(run.label, failed.why, `undo ${o.tool}`)
386
399
  await $.process.run(backstep(['settle', o.id, '--failed', failed.why])).catch(() => null)
387
400
  break
388
401
  }
389
- o.detail = run.label ?? `ran ${run.tool}`
402
+ o.detail = label
390
403
  await $.process.run(backstep(['settle', o.id])).catch(() => null)
391
404
  }
392
405
  // Stop the list at the first failure, as the CLI does.
@@ -400,9 +413,9 @@ async function undoLastTurn($: EngineInterface, count = 1) {
400
413
  (o.detail || o.tool)
401
414
  .split('\n')
402
415
  .filter(Boolean)
403
- .map((line) => ({ label: undoneLabel(line, short), ok: true })),
416
+ .map((line): RewindItem => (o.pending ? { label: undoneLabel(line, short), ok: true, pending: true, action: o.id } : { label: undoneLabel(line, short), ok: true })),
404
417
  )
405
- 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 } })
406
419
  // A push, or something made on GitHub, is offered back below, so it isn't listed as lost.
407
420
  const offered = outcomes.some((o) => o.ok && o.pushed)
408
421
  const offeredGh = outcomes.some((o) => o.ok && o.github?.length)
@@ -428,7 +441,7 @@ async function undoLastTurn($: EngineInterface, count = 1) {
428
441
  message: failed
429
442
  ? `Stopped after ${plural(done, 'change')}: the next one couldn't be undone.`
430
443
  : items.length
431
- ? `Rewound ${plural(done, 'change')} from ${which}.${shell}`
444
+ ? `Rewound ${plural(done, 'change')} from ${which}.${finishing(undone)}${shell}`
432
445
  : `Nothing in ${which} could be undone.${shell}`,
433
446
  }))
434
447
  for (const t of taken) await bump($, t.id, (x) => ({ ...x, undone: !failed }))
@@ -436,7 +449,7 @@ async function undoLastTurn($: EngineInterface, count = 1) {
436
449
  // The whole account, kept in the conversation, where the band's few rows fold away.
437
450
  const report = [
438
451
  `${MARK_TEXT} Backstep undid ${plural(done, 'change')} from ${which}${taken.length === 1 ? named(target) : ''}${done ? ':' : '.'}`,
439
- ...undone.map((i) => ` ◀ ${i.label}`),
452
+ ...undone.map((i) => ` ◀ ${i.label}${i.pending ? ' (Claude is finishing this)' : ''}`),
440
453
  ...(stopped.length || kept.length ? ["Not undone:", ...[...stopped, ...kept].map((i) => ` ✗ ${i.label}`)] : []),
441
454
  ]
442
455
  $.ui.log(report.join('\n'))
@@ -453,7 +466,7 @@ async function undoLastTurn($: EngineInterface, count = 1) {
453
466
 
454
467
  // Asked once the band shows what came back: GitHub things first, then pushes, then Vercel.
455
468
  // A change auto mode wouldn't let Backstep make comes first of all: it's where undo stopped.
456
- void (handoff ? offerHandoff($, handoff) : Promise.resolve())
469
+ void (handoff.length ? handOff($, handoff) : Promise.resolve())
457
470
  .then(() => offerGithubUndo($, outcomes))
458
471
  .then((gone) => offerPushUndo($, outcomes, gone.size > 0))
459
472
  .then(() => offerVercelUndo($, outcomes))
@@ -586,34 +599,77 @@ function undoneLabel(line: string, short: (p: string) => string): string {
586
599
  return `${p} ${m[1]}`
587
600
  }
588
601
 
589
- // Opposite calls handed to Claude to make, after the person said yes: the call,
590
- // by tool and arguments, to the action it undoes. Not recorded as Claude's own,
591
- // and once all of an action's have run, it counts as undone.
592
- 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
+ }
593
613
 
594
614
  type Handoff = { id: string; server: string; calls: { tool: string; args: Record<string, unknown> }[]; label: string }
595
- const HANDOFF = { yes: 'Ask Claude to do it', no: 'Leave it' } as const
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
+
627
+ // Said after "Rewound …" while Claude still has some of it to finish.
628
+ function finishing(items: RewindItem[]): string {
629
+ const n = items.filter((i) => i.pending).length
630
+ return n === 0 ? '' : n === items.length ? ' Claude is finishing it.' : ` Claude is finishing ${n} of them.`
631
+ }
596
632
 
597
633
  // Auto mode's classifier answers only calls a request of Claude's asked for, so it
598
- // refuses Backstep's own. With the person's yes, the request is theirs: Claude is
599
- // asked to make exactly these calls, and Backstep marks the change undone once it has.
600
- async function offerHandoff($: EngineInterface, h: Handoff) {
601
- const answer = await $.ui
602
- .ask(`Claude Code's auto mode didn't let Backstep undo this by itself: ${h.label}. Ask Claude to undo it?`, { header: 'Backstep', options: [HANDOFF.yes, HANDOFF.no] })
603
- .catch(() => null)
604
- if (answer !== HANDOFF.yes) return
605
- const server = toolServer(h.server)
606
- for (const c of h.calls) handedOver.set(undoCallKey(`mcp__${server}__${c.tool}`, c.args), h.id)
607
- const steps = h.calls.map((c) => `- mcp__${server}__${c.tool} with ${JSON.stringify(c.args)}`).join('\n')
634
+ // refuses Backstep's own. Pressing undo already said yes, so Claude is asked to make
635
+ // exactly these calls; Backstep marks each change undone once its calls have run.
636
+ async function handOff($: EngineInterface, list: Handoff[]) {
637
+ const steps: string[] = []
638
+ for (const h of list) {
639
+ const server = toolServer(h.server)
640
+ for (const c of h.calls) {
641
+ handedOver.push({ tool: `mcp__${server}__${c.tool}`, args: c.args, action: h.id })
642
+ steps.push(`- mcp__${server}__${c.tool} with ${JSON.stringify(c.args)}`)
643
+ }
644
+ }
645
+ const what = list.map((h) => h.label).join('; ')
608
646
  await $.prompt
609
647
  .submit({
610
- text: `Undo this with Backstep: ${h.label}. Make exactly ${h.calls.length === 1 ? 'this call' : 'these calls, in order'}, and nothing else:\n${steps}`,
648
+ text: `I pressed undo, and Backstep needs you to finish it (auto mode doesn't let Backstep make these calls itself): ${what}. Make exactly ${steps.length === 1 ? 'this call' : 'these calls, in order'}, and nothing else:\n${steps.join('\n')}`,
611
649
  wait: false,
612
650
  origin: { kind: 'composer' },
613
651
  } as never)
614
652
  .catch(() => null)
615
653
  }
616
654
 
655
+ // One of Claude's hand-over calls has run: the band's row for it says how it went.
656
+ async function handedBack($: EngineInterface, action: string, ok: boolean, why?: string) {
657
+ await update($, rewind, (r) => {
658
+ if (!r) return r
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))
660
+ const undone = items.filter((i) => i.ok)
661
+ // "Rewound 2 changes from the last turn. Claude is finishing it." loses its last sentence once Claude has.
662
+ const [rewound, ...rest] = r.message.replace(/ Claude is finishing [^.]*\./, '').split(/(?<=\.) /)
663
+ return {
664
+ ...r,
665
+ items,
666
+ shown: items.length,
667
+ phase: ok ? r.phase : 'failed',
668
+ message: ok ? [rewound + finishing(undone), ...rest].join(' ') : `Rewound ${plural(undone.length, 'change')}. Claude couldn't finish one: listed below.`,
669
+ }
670
+ })
671
+ }
672
+
617
673
  // One opposite call, through Claude Code's own connection to the server.
618
674
  async function undoCall($: EngineInterface, server: string, tool: string, args: Record<string, unknown>): Promise<{ isError: boolean; content: { type: string; text?: string }[] }> {
619
675
  return $.mcp.call(server, tool, args).catch((err: unknown) => ({
@@ -1101,13 +1157,24 @@ export const register: Register = (on, options) => {
1101
1157
  on('tool.call', async ($, e, next) => {
1102
1158
  const tool = String(e.tool)
1103
1159
  // An opposite call Claude is making for undo, as the person asked: not recorded as a change of its own.
1104
- const handed = handedOver.size ? undoCallKey(tool, Object.fromEntries(Object.entries(e).filter(([k]) => !RESERVED.has(k)))) : ''
1105
- const forAction = handed ? handedOver.get(handed) : undefined
1106
- if (forAction) {
1107
- const r = (await next(e)) as { deny?: string; isError?: boolean }
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
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
+ }
1108
1167
  if (!r.deny && !r.isError) {
1109
- handedOver.delete(handed)
1110
- if (![...handedOver.values()].includes(forAction)) await $.process.run(backstep(['settle', forAction])).catch(() => null)
1168
+ drop((h) => h === handed)
1169
+ if (!handedOver.some((h) => h.action === forAction)) {
1170
+ await $.process.run(backstep(['settle', forAction])).catch(() => null)
1171
+ await handedBack($, forAction, true)
1172
+ }
1173
+ } else {
1174
+ const why = (r.deny ?? r.text ?? 'it failed').trim().split('\n')[0]
1175
+ drop((h) => h.action === forAction)
1176
+ await $.process.run(backstep(['settle', forAction, '--failed', why])).catch(() => null)
1177
+ await handedBack($, forAction, false, why)
1111
1178
  }
1112
1179
  return r as never
1113
1180
  }
@@ -1251,8 +1318,9 @@ export const register: Register = (on, options) => {
1251
1318
  </Text>
1252
1319
  {back.slice(0, 5).map((i, n) => (
1253
1320
  <Text key={`b${n}`} dimColor>
1254
- {' ◀ '}
1321
+ {i.pending ? ' … ' : ' ◀ '}
1255
1322
  {i.label}
1323
+ {i.pending ? ' (Claude is finishing this)' : ''}
1256
1324
  </Text>
1257
1325
  ))}
1258
1326
  {back.length >= 5 && more > 0 ? <Text dimColor>{` and ${more} more (all listed in the conversation)`}</Text> : null}
@@ -18,7 +18,8 @@ export type Turn = {
18
18
  lostItems?: string[]
19
19
  }
20
20
 
21
- export type RewindItem = { label: string; ok: boolean }
21
+ // `pending`: handed to Claude to finish (auto mode wouldn't let Backstep make the call); `action`: the change it undoes.
22
+ export type RewindItem = { label: string; ok: boolean; pending?: boolean; action?: string }
22
23
 
23
24
  // The animation above the prompt while a turn is being rewound.
24
25
  // An update under way, kept across the reload it causes, so the reloaded mod can say it's done.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backstep",
3
- "version": "0.1.30",
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": {