backstep 0.1.29 → 0.1.30

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/dist/setup.js CHANGED
@@ -76,6 +76,15 @@ export async function setup() {
76
76
  const cli = join(root, "dist", "cli.js");
77
77
  // Without `backstep` on the PATH, the commands printed below go through npx.
78
78
  const bs = onPath ? "backstep" : "npx -y backstep";
79
+ // Only a problem is worth a line here; the summary below says the rest.
80
+ const bound = ensureUndoKey();
81
+ if (bound)
82
+ console.log(` ${white("!")} ${bound}`);
83
+ // Servers point at the copy just put in place, which is whole by now.
84
+ autoWrap(true);
85
+ wrapServers(cli);
86
+ // The mod last: installing it makes Claude Code reload Backstep there, which
87
+ // ends the code that ran this update, so everything else is done first.
79
88
  const install = () => run("claude", ["plugin", "install", "backstep", "--marketplace", join(root, "mod"), "--config", `cli=${cli}`]);
80
89
  let r = install();
81
90
  // Installed before from another copy (the file a person was given, or ~/.backstep before npm worked):
@@ -90,19 +99,14 @@ export async function setup() {
90
99
  // was; re-reading the folder and updating brings the mod up to this version.
91
100
  run("claude", ["plugin", "marketplace", "update", "backstep"]);
92
101
  run("claude", ["plugin", "update", "backstep@backstep"]);
93
- // Only a problem is worth a line here; the summary below says the rest.
94
- const bound = ensureUndoKey();
95
- if (bound)
96
- console.log(` ${white("!")} ${bound}`);
97
- // Wrap last, so a failed install never leaves servers pointing at a Backstep that isn't there.
98
- autoWrap(true);
99
- wrapServers(cli);
100
102
  // What's covered and what isn't, said once here and kept up to date by `backstep status`.
101
103
  const key = (undoKeys().length ? undoKeys() : defaultKeys()).map(keyLabel).join(" or ");
102
104
  console.log(`\n ${pink("✓")} ${white("Backstep is ready.")} Restart Claude Code, then press ${white(key)} after any turn to undo it.`);
103
105
  console.log(dim(` Want a different key? Just ask Claude: "change my Backstep undo key to ctrl+q"`));
104
106
  console.log(dim(` To remove Backstep, type /uninstall-backstep in Claude Code, or ask Claude to uninstall it.`));
105
- printCheck(check(process.cwd()), bs);
107
+ // An update runs this out of sight, so the summary (a few seconds of checks) is skipped there.
108
+ if (process.env.BACKSTEP_UPDATING !== "1")
109
+ printCheck(check(process.cwd()), bs);
106
110
  if (telemetryOn()) {
107
111
  console.log(dim(" Sends an anonymous install count. Turn it off with BACKSTEP_TELEMETRY=0.\n"));
108
112
  await sendInstall();
package/dist/update.js CHANGED
@@ -80,7 +80,7 @@ export function runUpdate(v, tries = 4) {
80
80
  const r = spawnSync("npx", ["-y", "--prefer-online", `backstep@${v}`, "setup"], {
81
81
  encoding: "utf8",
82
82
  timeout: 5 * 60 * 1000,
83
- env: { ...process.env, NO_COLOR: "1", BACKSTEP_TELEMETRY: "0" },
83
+ env: { ...process.env, NO_COLOR: "1", BACKSTEP_TELEMETRY: "0", BACKSTEP_UPDATING: "1" },
84
84
  stdio: ["ignore", "pipe", "pipe"],
85
85
  cwd: tmpdir(),
86
86
  });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backstep",
3
- "version": "0.1.29",
3
+ "version": "0.1.30",
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": {
@@ -271,7 +271,8 @@ test('servers added since setup are wrapped as a session starts', { options: { c
271
271
 
272
272
  // A claude.ai connector: Backstep can't sit in front of it, so the mod reports
273
273
  // the call and, on undo, runs the rule's opposite call through Claude Code.
274
- function connector(on: On, opts: { start: object; undoable: boolean; mcpFails?: boolean; undo?: object }) {
274
+ function connector(on: On, opts: { start: object; undoable: boolean; mcpFails?: boolean; mcpThrows?: string; undo?: object }) {
275
+ const prompts: string[] = []
275
276
  const runs: { argv: string[]; stdin?: string }[] = []
276
277
  const calls: { server: string; tool: string; args: Record<string, unknown> }[] = []
277
278
  const ran: Record<string, unknown>[] = []
@@ -295,6 +296,7 @@ function connector(on: On, opts: { start: object; undoable: boolean; mcpFails?:
295
296
  return { value: { exitCode: 0, stdout: out ? JSON.stringify(out) : '', stderr: '', isStdoutTruncated: false, isStderrTruncated: false } }
296
297
  })
297
298
  on('mcp.call', (_, e) => {
299
+ if (opts.mcpThrows) return { value: { content: [{ type: 'text', text: opts.mcpThrows }], isError: true } }
298
300
  calls.push({ server: e.server, tool: e.tool, args: e.args })
299
301
  if (e.tool === 'notion-fetch') return { value: { content: [{ type: 'text', text: `page ${String(e.args.id)} as it was` }], isError: false } }
300
302
  return { value: { content: [{ type: 'text', text: opts.mcpFails ? 'no such page' : 'archived' }], isError: opts.mcpFails === true } }
@@ -303,12 +305,15 @@ function connector(on: On, opts: { start: object; undoable: boolean; mcpFails?:
303
305
  ran.push({ ...e })
304
306
  return { result: { content: [{ type: 'text', text: '{"id":"p1"}' }] }, text: '{"id":"p1"}' }
305
307
  })
306
- on('prompt.submit', (_, e) => ({ text: e.text, context: e.context }))
308
+ on('prompt.submit', (_, e) => {
309
+ prompts.push(e.text)
310
+ return { text: e.text, context: e.context }
311
+ })
307
312
  on('ui.render', ($, e) => {
308
313
  const { Text } = $.ui.resolve(e)
309
314
  return <Text>engine</Text>
310
315
  })
311
- return { runs, calls, ran, toasts }
316
+ return { runs, calls, ran, toasts, prompts }
312
317
  }
313
318
 
314
319
  test('a connector call with an undo rule is undone through Claude Code', { options: { cli: CLI } }, async ($, on) => {
@@ -1251,3 +1256,102 @@ test('dismissing the question about changed files undoes nothing', { options: {
1251
1256
  expect(await ui.find({ type: 'Text', text: /Rewinding|Rewound/ })).toBeFalsy()
1252
1257
  await ui.unmount()
1253
1258
  })
1259
+
1260
+ test('an update that reloads the mod partway is finished by the reloaded mod: it says done and takes the warning down', { options: { cli: CLI } }, async ($, on) => {
1261
+ const clock = mock.clock(on, { now: 5000 })
1262
+ let started = false
1263
+ const logs: string[] = []
1264
+ const statuses: (string | undefined)[] = []
1265
+ on('process.run', (_, e) => {
1266
+ const argv = [...e.argv]
1267
+ // The update itself never answers: installing it reloaded the mod that was waiting on it.
1268
+ if (argv.includes('update') && argv.includes('--json')) {
1269
+ started = true
1270
+ return new Promise(() => {})
1271
+ }
1272
+ const stdout = argv.includes('update-check')
1273
+ ? JSON.stringify({ current: started ? '0.2.0' : '0.1.2', latest: '0.2.0', available: !started })
1274
+ : argv.includes('status')
1275
+ ? JSON.stringify({ items: [], suggest: [] })
1276
+ : '[]'
1277
+ return { value: { exitCode: 0, stdout, stderr: '', isStdoutTruncated: false, isStderrTruncated: false } }
1278
+ })
1279
+ on('ui.toast', () => ({ value: undefined }))
1280
+ on('ui.log', (_, e) => {
1281
+ logs.push(e.text)
1282
+ return { value: undefined }
1283
+ })
1284
+ on('ui.status', (_, e) => {
1285
+ statuses.push(e.text)
1286
+ return { value: undefined }
1287
+ })
1288
+ on('tool.register', (_, e) => ({ value: { tool: `mcp__backstep__${e.name}` } }))
1289
+ on('tool.call', { tool: 'AskUserQuestion' }, (_, e) => {
1290
+ const questions = (e as unknown as { questions: { question: string }[] }).questions
1291
+ return { result: { questions, answers: { [questions[0].question]: 'Update' } } }
1292
+ })
1293
+ on('session.start', (_, e) => ({ cwd: e.cwd }))
1294
+ on('session.cwd', () => ({ value: '/repo' }))
1295
+ on('session.id', () => ({ value: 's1' }))
1296
+ on('tool.call', () => ({ result: {} }))
1297
+ on('ui.render', ($, e) => {
1298
+ const { Text } = $.ui.resolve(e)
1299
+ return <Text>engine</Text>
1300
+ })
1301
+
1302
+ void $.session.start({ cwd: '/repo', surface: 'terminal', hasPerson: true } as never)
1303
+ for (let i = 0; i < 10 && !started; i++) {
1304
+ await settle()
1305
+ await clock.advance(1000)
1306
+ }
1307
+ expect(started).toBe(true)
1308
+ expect(statuses.at(-1)).toContain("don't close this terminal")
1309
+
1310
+ const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
1311
+ for (let i = 0; i < 5; i++) {
1312
+ await clock.advance(5000)
1313
+ await settle()
1314
+ }
1315
+ expect(statuses.at(-1)).toBeUndefined()
1316
+ 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.')
1317
+ await ui.unmount()
1318
+ })
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) => {
1321
+ const clock = mock.clock(on, { now: 5000 })
1322
+ 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
+ const asked: string[] = []
1324
+ 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' } } }
1328
+ })
1329
+ const { runs, ran, prompts } = connector(on, {
1330
+ start: { kind: 'reversible', id: 'w1' },
1331
+ undoable: true,
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' } },
1334
+ })
1335
+ await $.prompt.submit({ text: 'make a page', wait: false, origin: { kind: 'composer' } })
1336
+ await $.tool.call({ tool: 'mcp__claude_ai_Notion__notion-create-pages', pages: [{ title: 'Test' }], tool_use_id: 'tu1' } as never)
1337
+
1338
+ const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
1339
+ await ui.press({ key: 'undo' })
1340
+ const pressed = ui.press({ key: 'undo' })
1341
+ await clock.advance(1000)
1342
+ await pressed
1343
+ await clock.advance(1000)
1344
+ 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"]}')
1348
+
1349
+ // Claude makes the call: it isn't recorded as a change of its own, and the change is marked undone.
1350
+ const before = ran.length
1351
+ 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)
1353
+ expect(ran.length).toBe(before + 1)
1354
+ expect(runs.filter((r) => r.argv.includes('watch-start')).length).toBe(watched)
1355
+ expect(runs.at(-1)?.argv.slice(3)).toEqual(['settle', 'w1'])
1356
+ await ui.unmount()
1357
+ })
@@ -7,7 +7,7 @@
7
7
  import { atom, read, update } from 'claude-code'
8
8
  import type { EngineInterface, Register } from 'claude-code'
9
9
 
10
- import type { GithubCode, Rewind, RewindItem, Turn } from '../types'
10
+ import type { GithubCode, Rewind, RewindItem, Turn, Updating } from '../types'
11
11
 
12
12
  const turns = atom({ plugin: 'backstep', key: 'turns' } as const, [] as Turn[])
13
13
  const rewind = atom({ plugin: 'backstep', key: 'rewind' } as const, null as Rewind | null)
@@ -20,6 +20,12 @@ const keys = atom({ plugin: 'backstep', key: 'keys' } as const, ['ctrl+u'] as st
20
20
  const githubCode = atom({ plugin: 'backstep', key: 'githubCode' } as const, null as GithubCode | null)
21
21
  // Why ctrl+u can't run here, when getbackstep.com says so (a version too old, or no sign-in when one is needed).
22
22
  const blocked = atom({ plugin: 'backstep', key: 'blocked' } as const, null as string | null)
23
+ // An update under way. Installing it reloads this mod partway through, which
24
+ // ends the code waiting on it, so the reloaded mod finishes the job: it says
25
+ // the update is done and takes the "don't close" line down.
26
+ const updating = atom({ plugin: 'backstep', key: 'updating' } as const, null as Updating | null)
27
+ // Whether this load of the mod has looked for an update the last one left running.
28
+ let resumed = false
23
29
  // Backstep's mark, as on the logo: the front rewind triangle in the terminal's
24
30
  // own black or white, the one behind it in the logo's pink.
25
31
  const PINK = '#ee7bb6'
@@ -355,19 +361,22 @@ async function undoLastTurn($: EngineInterface, count = 1) {
355
361
  }
356
362
 
357
363
  // Opposite calls on servers only Claude Code is connected to run here, newest first.
364
+ let handoff: Handoff | null = null
358
365
  for (const o of outcomes) {
359
366
  if (!o.ok || !o.run) continue
360
367
  const run = o.run
361
368
  let failed: { tool: string; why: string } | null = null
362
369
  // The first opposite call, then any more on the same server (a move back to several places).
363
- for (const call of [{ tool: run.tool, args: run.args }, ...(run.more ?? [])]) {
364
- const done = await $.mcp.call(run.server, call.tool, call.args).catch((err: unknown) => ({
365
- isError: true,
366
- content: [{ type: 'text' as const, text: err instanceof Error ? err.message : String(err) }],
367
- }))
370
+ const all = [{ tool: run.tool, args: run.args }, ...(run.more ?? [])]
371
+ for (const [i, call] of all.entries()) {
372
+ const done = await undoCall($, run.server, call.tool, call.args)
368
373
  if (done.isError) {
369
374
  const why = done.content.map((c) => (c.type === 'text' ? c.text : '')).join(' ').trim().split('\n')[0] || 'failed'
370
- failed = { tool: call.tool, why }
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 }
371
380
  break
372
381
  }
373
382
  }
@@ -443,7 +452,9 @@ async function undoLastTurn($: EngineInterface, count = 1) {
443
452
  }
444
453
 
445
454
  // Asked once the band shows what came back: GitHub things first, then pushes, then Vercel.
446
- void offerGithubUndo($, outcomes)
455
+ // 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())
457
+ .then(() => offerGithubUndo($, outcomes))
447
458
  .then((gone) => offerPushUndo($, outcomes, gone.size > 0))
448
459
  .then(() => offerVercelUndo($, outcomes))
449
460
 
@@ -575,6 +586,56 @@ function undoneLabel(line: string, short: (p: string) => string): string {
575
586
  return `${p} ${m[1]}`
576
587
  }
577
588
 
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>()
593
+
594
+ 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
596
+
597
+ // 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')
608
+ await $.prompt
609
+ .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}`,
611
+ wait: false,
612
+ origin: { kind: 'composer' },
613
+ } as never)
614
+ .catch(() => null)
615
+ }
616
+
617
+ // One opposite call, through Claude Code's own connection to the server.
618
+ async function undoCall($: EngineInterface, server: string, tool: string, args: Record<string, unknown>): Promise<{ isError: boolean; content: { type: string; text?: string }[] }> {
619
+ return $.mcp.call(server, tool, args).catch((err: unknown) => ({
620
+ isError: true,
621
+ content: [{ type: 'text' as const, text: err instanceof Error ? err.message : String(err) }],
622
+ }))
623
+ }
624
+
625
+ // Claude Code's permission check refused the call (auto mode's classifier
626
+ // answers only calls a request of Claude's asked for), not the server.
627
+ const REFUSED = /\$\.mcp\.call\([^)]*\) refused|auto mode classifier/i
628
+
629
+ // A server's name as tool names spell it: "claude.ai Notion" is claude_ai_Notion.
630
+ const toolServer = (server: string) => server.replace(/[^A-Za-z0-9_-]/g, '_')
631
+
632
+ // A call by tool and arguments, the arguments' keys in order, so the same call always reads the same.
633
+ function undoCallKey(tool: string, args: unknown): string {
634
+ const sorted = (v: unknown): unknown =>
635
+ Array.isArray(v) ? v.map(sorted) : v && typeof v === 'object' ? Object.fromEntries(Object.entries(v).sort(([a], [b]) => (a < b ? -1 : 1)).map(([k, x]) => [k, sorted(x)])) : v
636
+ return `${tool} ${JSON.stringify(sorted(args ?? {}))}`
637
+ }
638
+
578
639
  // The action id `backstep snap` printed.
579
640
  function savedId(stdout: string): string | null {
580
641
  try {
@@ -788,6 +849,40 @@ async function offerUpdate($: EngineInterface) {
788
849
  await installUpdate($, u.current, u.latest)
789
850
  }
790
851
 
852
+ function updated($: EngineInterface, u: Updating) {
853
+ $.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}.`)
854
+ $.ui.toast(`Backstep is updated to ${u.to}. Restart Claude Code to use it.`)
855
+ }
856
+
857
+ // After a reload in the middle of an update: once the new version is in place,
858
+ // say so and take the "don't close" line down. Setup may still be finishing,
859
+ // so it looks again for a while before giving up.
860
+ async function finishUpdate($: EngineInterface) {
861
+ const u = await read($, updating)
862
+ if (!u) return
863
+ for (let i = 0; i < 60; i++) {
864
+ const ran = await $.process.run(backstep(['update-check']), { timeoutMs: 15_000 }).catch(() => null)
865
+ let installed: string | undefined
866
+ try {
867
+ installed = (JSON.parse(ran?.stdout || '{}') as { current?: string }).current
868
+ } catch {
869
+ installed = undefined
870
+ }
871
+ if (installed === u.to) {
872
+ // Setup may still be wrapping servers: a moment more before saying it's safe to close.
873
+ await $.clock.sleep(5000)
874
+ $.ui.status(undefined)
875
+ await update($, updating, () => null)
876
+ updated($, u)
877
+ return
878
+ }
879
+ await $.clock.sleep(5000)
880
+ }
881
+ $.ui.status(undefined)
882
+ await update($, updating, () => null)
883
+ $.ui.log(`${MARK_TEXT} Backstep couldn't confirm the update to ${u.to} finished. If undo doesn't work after restarting Claude Code, run: npx backstep@latest setup`)
884
+ }
885
+
791
886
  // Installs a version, saying not to close the terminal while it runs and which version it ended on.
792
887
  async function installUpdate($: EngineInterface, current: string | undefined, latest: string, forced = false): Promise<boolean> {
793
888
  const u = { current, latest }
@@ -797,6 +892,7 @@ async function installUpdate($: EngineInterface, current: string | undefined, la
797
892
  $.ui.log(`${MARK_TEXT} Updating Backstep${from} to ${u.latest}… Don't close ${win} until it says it's done (about a minute).`)
798
893
  $.ui.toast(`Updating Backstep to ${u.latest}. Don't close ${win} until it's done.`, { timeoutMs: 15_000 })
799
894
  $.ui.status(`◀◀ Updating Backstep to ${u.latest}… don't close ${win}`)
895
+ await update($, updating, () => ({ ...(u.current ? { from: u.current } : {}), to: latest, win }))
800
896
  const done = await $.process
801
897
  .run(backstep(['update', '--version', u.latest, '--json']), { timeoutMs: 600_000 })
802
898
  .catch((err: unknown) => ({ exitCode: 1, stdout: '', stderr: err instanceof Error ? err.message : String(err) }))
@@ -807,9 +903,9 @@ async function installUpdate($: EngineInterface, current: string | undefined, la
807
903
  // keep the error above
808
904
  }
809
905
  $.ui.status(undefined)
906
+ await update($, updating, () => null)
810
907
  if (r.ok) {
811
- $.ui.log(`${MARK_TEXT} Backstep is updated to ${u.latest}${u.current ? ` (you had ${u.current})` : ''}. It's safe to close ${win} now. Restart Claude Code to start using ${u.latest}.`)
812
- $.ui.toast(`Backstep is updated to ${u.latest}. Restart Claude Code to use it.`)
908
+ updated($, { ...(u.current ? { from: u.current } : {}), to: latest, win })
813
909
  return true
814
910
  } else {
815
911
  $.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`)
@@ -1004,6 +1100,17 @@ export const register: Register = (on, options) => {
1004
1100
 
1005
1101
  on('tool.call', async ($, e, next) => {
1006
1102
  const tool = String(e.tool)
1103
+ // 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 }
1108
+ if (!r.deny && !r.isError) {
1109
+ handedOver.delete(handed)
1110
+ if (![...handedOver.values()].includes(forAction)) await $.process.run(backstep(['settle', forAction])).catch(() => null)
1111
+ }
1112
+ return r as never
1113
+ }
1007
1114
  if (tool === CONNECT || tool === KEY || tool === UNINSTALL) return next(e)
1008
1115
  if (EDIT_TOOLS.has(tool)) {
1009
1116
  const input = e as { file_path?: string; notebook_path?: string }
@@ -1085,6 +1192,10 @@ export const register: Register = (on, options) => {
1085
1192
  })
1086
1193
 
1087
1194
  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1195
+ if (!resumed) {
1196
+ resumed = true
1197
+ void finishUpdate($)
1198
+ }
1088
1199
  if (e.props.hasSurvey) return next(e)
1089
1200
  const { Box, Button, Text } = $.ui.resolve(e)
1090
1201
  // The logo keeps its own box with a fixed gap after it: a space inside the
@@ -21,6 +21,9 @@ export type Turn = {
21
21
  export type RewindItem = { label: string; ok: boolean }
22
22
 
23
23
  // The animation above the prompt while a turn is being rewound.
24
+ // An update under way, kept across the reload it causes, so the reloaded mod can say it's done.
25
+ export type Updating = { from?: string; to: string; win: string }
26
+
24
27
  export type Rewind = {
25
28
  phase: 'running' | 'done' | 'failed' | 'empty'
26
29
  frame: number
@@ -38,6 +41,6 @@ declare module 'claude-code' {
38
41
  // keys: the keys bound to undo, kept here so the band redraws when they change.
39
42
  // githubCode: the code GitHub's approval page wants, shown on the band until GitHub answers.
40
43
  // blocked: why ctrl+u can't run, when getbackstep.com says this version is too old or a sign-in is needed.
41
- backstep: { turns: Turn[]; rewind: Rewind | null; note: string | null; armed: number | null; keys: string[]; githubCode: GithubCode | null; blocked: string | null }
44
+ backstep: { turns: Turn[]; rewind: Rewind | null; note: string | null; armed: number | null; keys: string[]; githubCode: GithubCode | null; blocked: string | null; updating: Updating | null }
42
45
  }
43
46
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backstep",
3
- "version": "0.1.29",
3
+ "version": "0.1.30",
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": {