backstep 0.1.30 → 0.1.31

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backstep",
3
- "version": "0.1.30",
3
+ "version": "0.1.31",
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": {
@@ -1317,24 +1317,23 @@ test('an update that reloads the mod partway is finished by the reloaded mod: it
1317
1317
  await ui.unmount()
1318
1318
  })
1319
1319
 
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) => {
1320
+ // Auto mode refuses Backstep's own call: pressing undo was the go-ahead, so Claude is asked to make it.
1321
+ async function refusedNotionUndo($: Parameters<Parameters<typeof test>[2]>[0], on: On) {
1321
1322
  const clock = mock.clock(on, { now: 5000 })
1322
1323
  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
1324
  const asked: string[] = []
1324
1325
  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' } } }
1326
+ asked.push((e as unknown as { questions: { question: string }[] }).questions[0].question)
1327
+ return { result: {} }
1328
1328
  })
1329
- const { runs, ran, prompts } = connector(on, {
1329
+ const env = connector(on, {
1330
1330
  start: { kind: 'reversible', id: 'w1' },
1331
1331
  undoable: true,
1332
1332
  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' } },
1333
+ 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
1334
  })
1335
1335
  await $.prompt.submit({ text: 'make a page', wait: false, origin: { kind: 'composer' } })
1336
1336
  await $.tool.call({ tool: 'mcp__claude_ai_Notion__notion-create-pages', pages: [{ title: 'Test' }], tool_use_id: 'tu1' } as never)
1337
-
1338
1337
  const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
1339
1338
  await ui.press({ key: 'undo' })
1340
1339
  const pressed = ui.press({ key: 'undo' })
@@ -1342,16 +1341,27 @@ test("when auto mode won't let Backstep undo a Notion change, Claude is asked to
1342
1341
  await pressed
1343
1342
  await clock.advance(1000)
1344
1343
  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"]}')
1344
+ return { ...env, asked, ui }
1345
+ }
1346
+
1347
+ 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) => {
1348
+ const { runs, ran, prompts, asked, ui } = await refusedNotionUndo($, on)
1349
+ expect(asked).toEqual([])
1350
+ expect(runs.some((r) => r.argv.includes('--failed'))).toBe(false)
1351
+ expect(prompts.at(-1)).toContain('mcp__claude_ai_Notion__notion-delete-pages with {"page_ids":["p1"],"user_confirmed":true}')
1352
+ expect((await ui.find({ type: 'Text', text: /Rewound/ }))?.text).toContain('Rewound 1 change from the last turn. Claude is finishing it.')
1353
+ expect(await ui.find({ type: 'Text', text: /Not undone/ })).toBeFalsy()
1348
1354
 
1349
1355
  // Claude makes the call: it isn't recorded as a change of its own, and the change is marked undone.
1350
1356
  const before = ran.length
1351
1357
  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)
1358
+ await $.tool.call({ tool: 'mcp__claude_ai_Notion__notion-delete-pages', page_ids: ['p1'], user_confirmed: true, tool_use_id: 'tu2' } as never)
1353
1359
  expect(ran.length).toBe(before + 1)
1354
1360
  expect(runs.filter((r) => r.argv.includes('watch-start')).length).toBe(watched)
1355
1361
  expect(runs.at(-1)?.argv.slice(3)).toEqual(['settle', 'w1'])
1362
+ const line = await ui.find({ type: 'Text', text: /Rewound/ })
1363
+ expect(line?.text).toContain('Rewound 1 change from the last turn.')
1364
+ expect(line?.text).not.toContain('Claude is finishing')
1365
+ expect(await ui.find({ type: 'Text', text: /Claude is finishing this/ })).toBeFalsy()
1356
1366
  await ui.unmount()
1357
1367
  })
@@ -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
398
  o.detail = `${failed.tool} failed: ${failed.why}`
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,7 +413,7 @@ 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
418
  const stopped = outcomes.filter((o) => !o.ok).map((o) => ({ label: `${o.tool}: ${o.detail.split('\n')[0] ?? 'failed'}`, ok: false }))
406
419
  // A push, or something made on GitHub, is offered back below, so it isn't listed as lost.
@@ -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))
@@ -592,28 +605,53 @@ function undoneLabel(line: string, short: (p: string) => string): string {
592
605
  const handedOver = new Map<string, string>()
593
606
 
594
607
  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
608
+
609
+ // Said after "Rewound …" while Claude still has some of it to finish.
610
+ function finishing(items: RewindItem[]): string {
611
+ const n = items.filter((i) => i.pending).length
612
+ return n === 0 ? '' : n === items.length ? ' Claude is finishing it.' : ` Claude is finishing ${n} of them.`
613
+ }
596
614
 
597
615
  // 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')
616
+ // refuses Backstep's own. Pressing undo already said yes, so Claude is asked to make
617
+ // exactly these calls; Backstep marks each change undone once its calls have run.
618
+ async function handOff($: EngineInterface, list: Handoff[]) {
619
+ const steps: string[] = []
620
+ for (const h of list) {
621
+ const server = toolServer(h.server)
622
+ for (const c of h.calls) {
623
+ handedOver.set(undoCallKey(`mcp__${server}__${c.tool}`, c.args), h.id)
624
+ steps.push(`- mcp__${server}__${c.tool} with ${JSON.stringify(c.args)}`)
625
+ }
626
+ }
627
+ const what = list.map((h) => h.label).join('; ')
608
628
  await $.prompt
609
629
  .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}`,
630
+ 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
631
  wait: false,
612
632
  origin: { kind: 'composer' },
613
633
  } as never)
614
634
  .catch(() => null)
615
635
  }
616
636
 
637
+ // One of Claude's hand-over calls has run: the band's row for it says how it went.
638
+ async function handedBack($: EngineInterface, action: string, ok: boolean, why?: string) {
639
+ await update($, rewind, (r) => {
640
+ 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))
642
+ const undone = items.filter((i) => i.ok)
643
+ // "Rewound 2 changes from the last turn. Claude is finishing it." loses its last sentence once Claude has.
644
+ const [rewound, ...rest] = r.message.replace(/ Claude is finishing [^.]*\./, '').split(/(?<=\.) /)
645
+ return {
646
+ ...r,
647
+ items,
648
+ shown: items.length,
649
+ phase: ok ? r.phase : 'failed',
650
+ message: ok ? [rewound + finishing(undone), ...rest].join(' ') : `Rewound ${plural(undone.length, 'change')}. Claude couldn't finish one: listed below.`,
651
+ }
652
+ })
653
+ }
654
+
617
655
  // One opposite call, through Claude Code's own connection to the server.
618
656
  async function undoCall($: EngineInterface, server: string, tool: string, args: Record<string, unknown>): Promise<{ isError: boolean; content: { type: string; text?: string }[] }> {
619
657
  return $.mcp.call(server, tool, args).catch((err: unknown) => ({
@@ -1104,10 +1142,18 @@ export const register: Register = (on, options) => {
1104
1142
  const handed = handedOver.size ? undoCallKey(tool, Object.fromEntries(Object.entries(e).filter(([k]) => !RESERVED.has(k)))) : ''
1105
1143
  const forAction = handed ? handedOver.get(handed) : undefined
1106
1144
  if (forAction) {
1107
- const r = (await next(e)) as { deny?: string; isError?: boolean }
1145
+ const r = (await next(e)) as { deny?: string; isError?: boolean; text?: string }
1108
1146
  if (!r.deny && !r.isError) {
1109
1147
  handedOver.delete(handed)
1110
- if (![...handedOver.values()].includes(forAction)) await $.process.run(backstep(['settle', forAction])).catch(() => null)
1148
+ if (![...handedOver.values()].includes(forAction)) {
1149
+ await $.process.run(backstep(['settle', forAction])).catch(() => null)
1150
+ await handedBack($, forAction, true)
1151
+ }
1152
+ } else {
1153
+ 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)
1155
+ await $.process.run(backstep(['settle', forAction, '--failed', why])).catch(() => null)
1156
+ await handedBack($, forAction, false, why)
1111
1157
  }
1112
1158
  return r as never
1113
1159
  }
@@ -1251,8 +1297,9 @@ export const register: Register = (on, options) => {
1251
1297
  </Text>
1252
1298
  {back.slice(0, 5).map((i, n) => (
1253
1299
  <Text key={`b${n}`} dimColor>
1254
- {' ◀ '}
1300
+ {i.pending ? ' … ' : ' ◀ '}
1255
1301
  {i.label}
1302
+ {i.pending ? ' (Claude is finishing this)' : ''}
1256
1303
  </Text>
1257
1304
  ))}
1258
1305
  {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.31",
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": {