backstep 0.1.4 → 0.1.6

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/README.md CHANGED
@@ -8,8 +8,8 @@ Undo for everything your agents touch. Backstep sits between your agent (Claude
8
8
  - **Supabase:** `execute_sql` and `apply_migration` snapshot the tables they change, over a connection to the project's database. Backstep never connects it on its own: when Claude Code opens a project that uses Supabase and isn't connected, it asks (Connect, Not now, or Don't ask for this project). Connect uses a connection string in the project's `.env` files, the local `supabase start` database, or a Supabase sign-in (`supabase login`, `SUPABASE_ACCESS_TOKEN`, or an access token you give Backstep once), with which the project gets a temporary database login that Backstep renews as it runs out. You can also ask Claude to "connect Backstep to Supabase" any time. Deleted rows, dropped tables and migrations, including the migration's history row, all come back. Works for the claude.ai Supabase connector and a local Supabase MCP server alike.
9
9
  - **Firebase:** before a change, Backstep reads what's there through the Firebase MCP server itself, and undoes through it too, so there's nothing to set up. Firestore documents updated, deleted or added; Realtime Database writes; Remote Config publishes (rolled back to the version before); and users enabled or disabled.
10
10
  - **claude.ai connectors and servers Claude Code signs in to:** Backstep can't sit in front of these, so the Claude Code mod watches their calls instead. Calls with an undo rule are undone through Claude Code's own connection.
11
- - **Database commands Claude runs** (`supabase db push`, `supabase migration up`, `supabase db reset`, `psql`, `prisma migrate`, `drizzle-kit push`, seed scripts…): in a project whose Supabase database is connected, the whole database is noted before the command runs, and ctrl+z puts it back in one transaction: rows, new tables dropped, dropped or altered tables rebuilt with their constraints, indexes, foreign keys, row level security policies, triggers and grants, sequence counters, and the migration history. Copies sit in a `backstep` schema in the same database; the newest 10 per database are kept. On a very big database it keeps every table's structure and the migration history, and rows smallest-table-first up to a budget (`BACKSTEP_DB_SNAPSHOT_MB`, default 250); undo names any table whose rows it couldn't bring back. Functions, views, types and extensions aren't restored.
12
- - **Code Claude pushed:** ctrl+z rewinds the commits locally, then asks whether to undo them on GitHub (or any remote) too. Yes pushes one new commit that undoes Claude's, made in a temporary worktree: no force push, no rewritten history, so it works on protected branches and for teammates who already pulled. Newer commits that clash, or a rewritten branch, stop it. A branch Claude created is deleted instead, if nobody has added to it. It uses the project's own git sign-in, so there's nothing to connect.
11
+ - **Database commands Claude runs** (`supabase db push`, `supabase migration up`, `supabase db reset`, `psql`, `prisma migrate`, `drizzle-kit push`, seed scripts…): in a project whose Supabase database is connected, the whole database is noted before the command runs, and ctrl+u puts it back in one transaction: rows, new tables dropped, dropped or altered tables rebuilt with their constraints, indexes, foreign keys, row level security policies, triggers and grants, sequence counters, and the migration history. Copies sit in a `backstep` schema in the same database; the newest 10 per database are kept. On a very big database it keeps every table's structure and the migration history, and rows smallest-table-first up to a budget (`BACKSTEP_DB_SNAPSHOT_MB`, default 250); undo names any table whose rows it couldn't bring back. Functions, views, types and extensions aren't restored.
12
+ - **Code Claude pushed:** ctrl+u rewinds the commits locally, then asks whether to undo them on GitHub (or any remote) too. Yes pushes one new commit that undoes Claude's, made in a temporary worktree: no force push, no rewritten history, so it works on protected branches and for teammates who already pulled. Newer commits that clash, or a rewritten branch, stop it. A branch Claude created is deleted instead, if nobody has added to it. It uses the project's own git sign-in, so there's nothing to connect.
13
13
  - **Things that can't be undone** (sending email, payments, deploys, `ALTER TABLE`…) run as normal and are marked in the journal, so you know what an undo didn't cover.
14
14
  - **Anything else, by rule:** a rules file maps a tool to its opposite (`create_issue` → close the issue, `send_message` → retract it), filled in from the call and its result. See [Undo rules](#undo-rules).
15
15
  - **Conflicts:** undoing an action first undoes any later action that touched the same file or table, so nothing gets silently thrown away.
@@ -20,7 +20,7 @@ Undo for everything your agents touch. Backstep sits between your agent (Claude
20
20
  npx backstep setup
21
21
  ```
22
22
 
23
- This installs Backstep, adds its Claude Code mod, binds **ctrl+z** to undo everything Claude did in its last turn, and puts the MCP servers you've already added to Claude Code behind Backstep, so their changes can be undone too. If you use Supabase, it asks for your database's connection string (Enter skips; you can ask Claude to connect it later). Restart Claude Code afterwards. Needs Node 22.13+ and Claude Code.
23
+ This installs Backstep, adds its Claude Code mod, binds **ctrl+u** to undo everything Claude did in its last turn, and puts the MCP servers you've already added to Claude Code behind Backstep, so their changes can be undone too. If you use Supabase, it asks for your database's connection string (Enter skips; you can ask Claude to connect it later). Restart Claude Code afterwards. Needs Node 22.13+ and Claude Code.
24
24
 
25
25
  Servers in a repo's shared `.mcp.json` that you've approved get a wrapped copy for that project only; the repo's file is never edited. Remote servers that Claude Code signs in to stay as they are, and the mod watches their calls instead. Servers you add later are wrapped the next time Claude Code starts. `backstep unwrap` puts every server back the way it was and stops new ones being wrapped.
26
26
 
@@ -53,7 +53,7 @@ With Claude Code: `claude mcp add files -- backstep wrap --name files -- <the se
53
53
  | `--rules <file>` | undo rules (or set `BACKSTEP_RULES`) |
54
54
  | `--pg <url>` | the Postgres database the wrapped server writes to. Enables database undo. Without it, SQL writes are recorded but can't be undone. |
55
55
 
56
- The undo key is ⌘Z and ctrl+z on a Mac (⌘Z where the terminal passes it on, as Ghostty, Kitty and WezTerm do; Terminal.app and iTerm2 keep ⌘Z for their own menu) and ctrl+z elsewhere. The first press asks "Undo Claude's last turn?" above the prompt and a second press within 5 seconds undoes, so a stray press does nothing. Change the key whenever you like: ask Claude ("change my Backstep undo key to ctrl+u"), or run `backstep key ctrl+u`. The line above the prompt is always there, even with nothing to undo, because the key only reaches Backstep while it's on screen; otherwise ctrl+z would fall through to the terminal and suspend Claude Code.
56
+ The undo key is ctrl+u (not ctrl+z: Claude Code suspends itself on ctrl+z in a terminal before anything else sees the key, so it can't be used). The first press asks "Undo Claude's last turn?" above the prompt and a second press within 5 seconds undoes, so a stray press does nothing. Change the key whenever you like: ask Claude ("change my Backstep undo key to ctrl+q"), or run `backstep key ctrl+q`. The line above the prompt is always there, even with nothing to undo, because the key only reaches Backstep while it's on screen; otherwise a press would do nothing.
57
57
 
58
58
  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).
59
59
 
@@ -76,7 +76,7 @@ For tools Backstep can't snapshot, a rule names the tool that undoes them. `{{ar
76
76
  ] }
77
77
  ```
78
78
 
79
- `server` is optional and matches `--name`. For claude.ai connectors and servers Claude Code signs in to, put rules in `~/.backstep/rules.json`; `server` is the name as it appears in tool names (`claude_ai_Notion`), and pressing ctrl+z runs the opposite call through Claude Code. A rule overrides the built-in guess, so a `send_message` with a retract rule is undoable. The undo runs through a live `backstep wrap` of that server; if none is running, the dashboard tells you to start it, and any running copy with the same `--name` picks up the request. `examples/rules.json` has GitHub rules as a starting point. Check the tool and argument names against your server's version.
79
+ `server` is optional and matches `--name`. For claude.ai connectors and servers Claude Code signs in to, put rules in `~/.backstep/rules.json`; `server` is the name as it appears in tool names (`claude_ai_Notion`), and pressing ctrl+u runs the opposite call through Claude Code. A rule overrides the built-in guess, so a `send_message` with a retract rule is undoable. The undo runs through a live `backstep wrap` of that server; if none is running, the dashboard tells you to start it, and any running copy with the same `--name` picks up the request. `examples/rules.json` has GitHub rules as a starting point. Check the tool and argument names against your server's version.
80
80
 
81
81
  ## How it decides
82
82
 
@@ -101,7 +101,7 @@ Everything is journaled to `~/.backstep/journal.db` (SQLite, mode 0600; override
101
101
  - Undoing a table restore runs `DELETE` and then re-inserts rows, so foreign keys from other tables pointing at deleted rows can block it. The undo then fails cleanly inside a transaction.
102
102
  - `--pg` must point at the same database the wrapped server uses.
103
103
  - The dashboard only accepts requests from this machine. The journal stores the `--pg` URL so undo works later.
104
- - Remote servers that need an OAuth login can't be wrapped; they're watched from Claude Code instead, so their undo works with ctrl+z but not from the dashboard.
104
+ - Remote servers that need an OAuth login can't be wrapped; they're watched from Claude Code instead, so their undo works with ctrl+u but not from the dashboard.
105
105
  - Shell snapshots cover the project folder (the git repository, or the folder Claude Code runs in). Files outside it, and ignored folders like `node_modules`, aren't saved: undo restores `package.json` and the lockfile, and you re-run the install.
106
106
  - Shell commands are judged before they run by what they look like, so a script that pushes or deploys from inside is snapshotted, not held.
107
107
 
package/dist/cli.js CHANGED
@@ -53,7 +53,7 @@ function envHeaders() {
53
53
  }
54
54
  const HELP = `backstep: undo for everything your agents touch
55
55
 
56
- backstep setup install the Claude Code mod, bind ctrl+z to undo Claude's last turn,
56
+ backstep setup install the Claude Code mod, bind ctrl+u to undo Claude's last turn,
57
57
  and put your MCP servers behind Backstep
58
58
  backstep unwrap take your MCP servers back out from behind Backstep
59
59
  backstep connect [supabase|firebase] [<access-token>|<connection-string>] [--project <ref>] [--dir <folder>] [--json]
package/dist/keys.js CHANGED
@@ -1,17 +1,17 @@
1
1
  // The key that undoes Claude's last turn, in Claude Code's ~/.claude/keybindings.json.
2
2
  // The mod's undo button borrows a keybinding action only the diff panel uses
3
3
  // (mod buttons can't have actions of their own yet), and whatever key is bound
4
- // to it, in Global and Chat, presses the button. ctrl+z by default; the person
4
+ // to it, in Global and Chat, presses the button. ctrl+u by default (ctrl+z can't
5
+ // be: Claude Code suspends itself on it before any binding sees it); the person
5
6
  // can ask Claude for another, which runs `backstep key <key>`.
6
7
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
8
  import { homedir } from "node:os";
8
9
  import { join } from "node:path";
9
10
  export const UNDO_ACTION = "app:cycleDiffBase";
10
- export const DEFAULT_KEY = "ctrl+z";
11
- // A Mac gets ⌘Z too, where the terminal passes it on (Ghostty, Kitty, WezTerm,
12
- // Claude Code's desktop app); Terminal.app and iTerm2 keep ⌘Z for their own
13
- // Edit menu, so ctrl+z stays bound alongside it.
14
- export const defaultKeys = () => (process.platform === "darwin" ? ["cmd+z", DEFAULT_KEY] : [DEFAULT_KEY]);
11
+ export const DEFAULT_KEY = "ctrl+u";
12
+ // Bound by Backstep before it knew Claude Code suspends on it; moved to the default on update.
13
+ export const OLD_KEY = "ctrl+z";
14
+ export const defaultKeys = () => [DEFAULT_KEY];
15
15
  const CONTEXTS = ["Global", "Chat"];
16
16
  // Claude Code never hands these to anything else.
17
17
  const RESERVED = new Set(["ctrl+c", "ctrl+d", "ctrl+m", "ctrl+i", "ctrl+h", "ctrl+[", "ctrl+\\"]);
@@ -44,6 +44,8 @@ export function normalizeKey(raw) {
44
44
  throw new Error(`"${raw}" on its own would type a letter. Use a modifier, like ctrl+${last}`);
45
45
  if (RESERVED.has(key))
46
46
  throw new Error(`Claude Code keeps ${key} for itself. Pick another, like ctrl+u`);
47
+ if (key === OLD_KEY || key === "ctrl+shift+z")
48
+ throw new Error(`Claude Code suspends itself on ${key} in a terminal, before Backstep sees it. Pick another, like ctrl+u`);
47
49
  return key;
48
50
  }
49
51
  // Binds undo to `keys` (one, or several) and unbinds the old ones. Refuses a key that does something else, unless it was undo's.
@@ -86,3 +88,19 @@ export function setUndoKey(raw) {
86
88
  export function keyLabel(k) {
87
89
  return k.startsWith("cmd+") ? `⌘${k.slice(4).toUpperCase()}` : k;
88
90
  }
91
+ // As setup runs: keeps a key the person picked, binds ctrl+u on a first
92
+ // install, and moves an install that had ctrl+z onto it. Returns a problem to
93
+ // show, or "".
94
+ export function ensureUndoKey() {
95
+ const keys = undoKeys();
96
+ if (keys.length && !keys.includes(OLD_KEY))
97
+ return "";
98
+ const rest = keys.filter((k) => k !== OLD_KEY);
99
+ try {
100
+ setUndoKey(rest.length ? rest : defaultKeys());
101
+ return "";
102
+ }
103
+ catch (err) {
104
+ return err instanceof Error ? err.message : String(err);
105
+ }
106
+ }
package/dist/setup.js CHANGED
@@ -3,7 +3,7 @@ import { cpSync, readFileSync, rmSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { homeDir } from "./store.js";
6
- import { defaultKeys, keyLabel, setUndoKey, undoKey, undoKeys } from "./keys.js";
6
+ import { defaultKeys, ensureUndoKey, keyLabel, undoKeys } from "./keys.js";
7
7
  import { createInterface } from "node:readline/promises";
8
8
  import { autoWrap, wrapServers } from "./servers.js";
9
9
  import { check, printCheck } from "./check.js";
@@ -29,18 +29,6 @@ function keepCopy() {
29
29
  cpSync(dirname(ROOT), modules, { recursive: true });
30
30
  return { root: join(modules, "backstep"), onPath: false };
31
31
  }
32
- // Keeps a key the person picked; on a first install binds ⌘Z and ctrl+z on a Mac, ctrl+z elsewhere.
33
- function bindUndoKey() {
34
- if (undoKey())
35
- return "";
36
- try {
37
- setUndoKey(defaultKeys());
38
- return "";
39
- }
40
- catch (err) {
41
- return err instanceof Error ? err.message : String(err);
42
- }
43
- }
44
32
  const WHERE = `In Supabase, open your project, press Connect at the top, and copy the URI under
45
33
  "Direct connection" (or "Session pooler" if your network has no IPv6). Put your
46
34
  database password in place of [YOUR-PASSWORD].`;
@@ -80,7 +68,7 @@ export async function setup() {
80
68
  if (!r.ok)
81
69
  throw new Error(`claude plugin install failed:\n${r.out}`);
82
70
  // Only a problem is worth a line here; the summary below says the rest.
83
- const bound = bindUndoKey();
71
+ const bound = ensureUndoKey();
84
72
  if (bound)
85
73
  console.log(` ${white("!")} ${bound}`);
86
74
  // Wrap last, so a failed install never leaves servers pointing at a Backstep that isn't there.
@@ -89,7 +77,7 @@ export async function setup() {
89
77
  // What's covered and what isn't, said once here and kept up to date by `backstep status`.
90
78
  const key = (undoKeys().length ? undoKeys() : defaultKeys()).map(keyLabel).join(" or ");
91
79
  console.log(`\n ${pink("✓")} ${white("Backstep is ready.")} Restart Claude Code, then press ${white(key)} after any turn to undo it.`);
92
- console.log(dim(` Want a different key? Just ask Claude: "change my Backstep undo key to ctrl+u"`));
80
+ console.log(dim(` Want a different key? Just ask Claude: "change my Backstep undo key to ctrl+q"`));
93
81
  printCheck(check(process.cwd()), bs);
94
82
  if (telemetryOn()) {
95
83
  console.log(dim(" Sends an anonymous install count. Turn it off with BACKSTEP_TELEMETRY=0.\n"));
package/dist/undo.js CHANGED
@@ -111,7 +111,7 @@ async function undoInverse(store, a, u) {
111
111
  // Claude Code reports back how the call went with `backstep settle`.
112
112
  function undoEngine(store, a, u, opts) {
113
113
  if (!opts.viaClaudeCode) {
114
- return { id: a.id, tool: a.tool, ok: false, detail: `undo this with ctrl+z in Claude Code: only it is connected to ${u.server}` };
114
+ return { id: a.id, tool: a.tool, ok: false, detail: `undo this from Claude Code (ctrl+u): only it is connected to ${u.server}` };
115
115
  }
116
116
  if (!store.transition(a.id, a.status, "undo_requested")) {
117
117
  return { id: a.id, tool: a.tool, ok: false, detail: "this action changed while undoing; try again" };
package/dist/update.js CHANGED
@@ -51,17 +51,39 @@ export function skipVersion(v) {
51
51
  mkdirSync(homeDir(), { recursive: true });
52
52
  writeFileSync(file(), JSON.stringify({ ...settings(), skipVersion: v }, null, 2) + "\n");
53
53
  }
54
+ // What went wrong, in a line a person can act on: npm's own reason, not its
55
+ // pointer to a log file.
56
+ export function npmReason(out) {
57
+ if (/ETARGET|notarget|No matching version/i.test(out))
58
+ return "npm hasn't finished publishing it yet. Try again in a few minutes";
59
+ if (/ENOTFOUND|EAI_AGAIN|ECONNREFUSED|ETIMEDOUT|network/i.test(out))
60
+ return "couldn't reach npm. Check your internet connection";
61
+ if (/EACCES|permission denied/i.test(out))
62
+ return "npm wasn't allowed to install it (a permissions problem with npm's global folder)";
63
+ const lines = out.split("\n").map((l) => l.trim()).filter((l) => l && !/complete log of this run|^npm (error|ERR!)\s*$/i.test(l));
64
+ return (lines.find((l) => /^backstep:/.test(l)) ?? lines.findLast((l) => /error/i.test(l)) ?? lines.at(-1) ?? "npx failed").replace(/^(backstep:|npm (error|ERR!))\s*/i, "");
65
+ }
66
+ const pause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
54
67
  // The new version's setup puts its own copy in place, points Claude Code's mod
55
- // at it and re-wraps servers, exactly as a fresh install would.
56
- export function runUpdate(v) {
57
- const r = spawnSync("npx", ["-y", `backstep@${v}`, "setup"], {
58
- encoding: "utf8",
59
- timeout: 5 * 60 * 1000,
60
- env: { ...process.env, NO_COLOR: "1", BACKSTEP_TELEMETRY: "0" },
61
- stdio: ["ignore", "pipe", "pipe"],
62
- });
63
- if (r.status === 0)
64
- return { ok: true, detail: `updated to ${v}` };
65
- const why = `${r.stderr ?? ""}${r.stdout ?? ""}`.trim().split("\n").filter(Boolean).at(-1) ?? "npx failed";
66
- return { ok: false, detail: why.replace(/^backstep: /, "") };
68
+ // at it and re-wraps servers, exactly as a fresh install would. npm is asked
69
+ // online, never from its cache, which can be older than a version just
70
+ // published; and asked again for a while if it still hasn't heard of it.
71
+ export function runUpdate(v, tries = 4) {
72
+ let out = "";
73
+ for (let i = 0; i < tries; i++) {
74
+ if (i)
75
+ pause(15_000);
76
+ const r = spawnSync("npx", ["-y", "--prefer-online", `backstep@${v}`, "setup"], {
77
+ encoding: "utf8",
78
+ timeout: 5 * 60 * 1000,
79
+ env: { ...process.env, NO_COLOR: "1", BACKSTEP_TELEMETRY: "0" },
80
+ stdio: ["ignore", "pipe", "pipe"],
81
+ });
82
+ if (r.status === 0)
83
+ return { ok: true, detail: `updated to ${v}` };
84
+ out = `${r.stderr ?? ""}\n${r.stdout ?? ""}`;
85
+ if (!/ETARGET|notarget|No matching version/i.test(out))
86
+ break;
87
+ }
88
+ return { ok: false, detail: npmReason(out) };
67
89
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backstep",
3
- "version": "0.1.0",
3
+ "version": "0.1.6",
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": {
@@ -119,7 +119,7 @@ test('a change that cannot be undone stops the rewind and says so', { options: {
119
119
  expect(prompts).toHaveLength(1)
120
120
  })
121
121
 
122
- test('with nothing to undo the band still holds the key, so ctrl+z never suspends Claude Code', async ($, on) => {
122
+ test('with nothing to undo the band still holds the key, and a press says there is nothing to undo', async ($, on) => {
123
123
  engine(on, { exitCode: 0, stdout: '[]' })
124
124
  const toasts: string[] = []
125
125
  on('ui.toast', (_, e) => {
@@ -502,7 +502,7 @@ test('asking Claude to change the undo key rebinds it and relabels the band', {
502
502
  const runs: string[][] = []
503
503
  on('process.run', (_, e) => {
504
504
  runs.push([...e.argv])
505
- const stdout = e.argv.includes('key') ? JSON.stringify({ ok: true, key: 'ctrl+u', previous: 'ctrl+z' }) : '[]'
505
+ const stdout = e.argv.includes('key') ? JSON.stringify({ ok: true, key: 'ctrl+q', previous: 'ctrl+u' }) : '[]'
506
506
  return { value: { exitCode: 0, stdout, stderr: '', isStdoutTruncated: false, isStderrTruncated: false } }
507
507
  })
508
508
  on('ui.toast', () => ({ value: undefined }))
@@ -510,11 +510,11 @@ test('asking Claude to change the undo key rebinds it and relabels the band', {
510
510
  const { Text } = $.ui.resolve(e)
511
511
  return <Text>engine</Text>
512
512
  })
513
- const r = await $.tool.call({ tool: 'mcp__backstep__undo_key', key: 'Ctrl + U' } as never)
514
- expect(runs[0].slice(3)).toEqual(['key', 'Ctrl + U', '--json'])
515
- expect(r.result).toBe('Undo is now ctrl+u (it was ctrl+z). It works right away.')
513
+ const r = await $.tool.call({ tool: 'mcp__backstep__undo_key', key: 'Ctrl + Q' } as never)
514
+ expect(runs[0].slice(3)).toEqual(['key', 'Ctrl + Q', '--json'])
515
+ expect(r.result).toBe('Undo is now ctrl+q (it was ctrl+u). It works right away.')
516
516
  const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
517
- expect((await ui.find({ type: 'Button', key: 'undo' }))?.text).toBe('ctrl+u to undo')
517
+ expect((await ui.find({ type: 'Button', key: 'undo' }))?.text).toBe('ctrl+q to undo')
518
518
  await ui.unmount()
519
519
  })
520
520
 
@@ -528,7 +528,7 @@ test('one press only asks; it lets go after a few seconds, or on cancel, and und
528
528
 
529
529
  await ui.press({ key: 'undo' })
530
530
  expect((await ui.find({ type: 'Text', text: /Undo Claude's last turn\?/ }))).toBeTruthy()
531
- expect((await ui.find({ type: 'Button', key: 'undo' }))?.text).toBe('ctrl+z again to undo')
531
+ expect((await ui.find({ type: 'Button', key: 'undo' }))?.text).toBe('ctrl+u again to undo')
532
532
  await clock.advance(6000)
533
533
  expect((await ui.find({ type: 'Text', text: /Last turn/ }))).toBeTruthy()
534
534
 
@@ -541,9 +541,9 @@ test('one press only asks; it lets go after a few seconds, or on cancel, and und
541
541
  await ui.unmount()
542
542
  })
543
543
 
544
- test('on a Mac the band names both keys', { options: { cli: CLI } }, async ($, on) => {
544
+ test('with two keys bound the band names both', { options: { cli: CLI } }, async ($, on) => {
545
545
  on('process.run', (_, e) => {
546
- const stdout = e.argv.includes('key') ? JSON.stringify({ key: 'cmd+z', keys: ['cmd+z', 'ctrl+z'] }) : '[]'
546
+ const stdout = e.argv.includes('key') ? JSON.stringify({ key: 'ctrl+u', keys: ['ctrl+u', 'ctrl+q'] }) : '[]'
547
547
  return { value: { exitCode: 0, stdout, stderr: '', isStdoutTruncated: false, isStderrTruncated: false } }
548
548
  })
549
549
  const logs: string[] = []
@@ -559,8 +559,8 @@ test('on a Mac the band names both keys', { options: { cli: CLI } }, async ($, o
559
559
  return <Text>engine</Text>
560
560
  })
561
561
  await $.session.start({ cwd: '/repo', surface: 'terminal', hasPerson: true } as never)
562
- expect(logs[0]).toContain('press ⌘Z / ctrl+z to undo')
562
+ expect(logs[0]).toContain('press ctrl+u / ctrl+q to undo')
563
563
  const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
564
- expect((await ui.find({ type: 'Button', key: 'undo' }))?.text).toBe('⌘Z / ctrl+z to undo')
564
+ expect((await ui.find({ type: 'Button', key: 'undo' }))?.text).toBe('ctrl+u / ctrl+q to undo')
565
565
  await ui.unmount()
566
566
  })
@@ -1,6 +1,6 @@
1
1
  // Backstep for Claude Code: snapshot every file Claude is about to change,
2
2
  // and the whole project before each shell command, and rewind its whole last
3
- // turn with one key (ctrl+z) and a short animation above the prompt. Database
3
+ // turn with one key (ctrl+u) and a short animation above the prompt. Database
4
4
  // and tool changes made through servers wrapped by `backstep wrap`, and
5
5
  // Supabase changes over a connected database, rewind with it, by the turn's
6
6
  // time window.
@@ -18,7 +18,7 @@ const CONFIRM_MS = 5000
18
18
 
19
19
  // The engine has no keybinding actions of its own for plugins yet, so the undo
20
20
  // button borrows one that only the diff panel handles; ~/.claude/keybindings.json
21
- // binds ctrl+z to it everywhere else.
21
+ // binds ctrl+u to it everywhere else.
22
22
  const UNDO_ACTION = 'app:cycleDiffBase'
23
23
  const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit'])
24
24
  const KEEP_TURNS = 30
@@ -276,7 +276,7 @@ type ShellPlan = { kind: 'read' | 'write' | 'outbound'; reason?: string; id?: st
276
276
  // Set from the plugin's options each time the module registers.
277
277
  let cli = 'backstep'
278
278
  // The keys bound to undo, read as a session starts and when the person changes them.
279
- let undoKeys = ['ctrl+z']
279
+ let undoKeys = ['ctrl+u']
280
280
  const keyLabel = (k: string) => (k.startsWith('cmd+') ? `⌘${k.slice(4).toUpperCase()}` : k)
281
281
  const keysLabel = () => undoKeys.map(keyLabel).join(' / ')
282
282
 
@@ -320,7 +320,7 @@ async function wrapNewServers($: EngineInterface, cwd: string) {
320
320
 
321
321
  // As a session starts: connects what the project's own settings allow, then
322
322
  // says what Claude reaches that Backstep could undo once connected, so nobody
323
- // presses ctrl+z expecting it to come back.
323
+ // presses undo expecting it to come back.
324
324
  async function checkCoverage($: EngineInterface, cwd: string) {
325
325
  // Supabase is only ever noticed here, never connected: that's the person's
326
326
  // call. Firebase is added once someone is signed in to it.
@@ -403,7 +403,7 @@ async function offerUpdate($: EngineInterface) {
403
403
  if (answer !== UPDATE.yes) return
404
404
  $.ui.toast(`Updating Backstep to ${u.latest}…`)
405
405
  const done = await $.process
406
- .run(backstep(['update', '--version', u.latest, '--json']), { timeoutMs: 300_000 })
406
+ .run(backstep(['update', '--version', u.latest, '--json']), { timeoutMs: 600_000 })
407
407
  .catch((err: unknown) => ({ exitCode: 1, stdout: '', stderr: err instanceof Error ? err.message : String(err) }))
408
408
  let r: { ok: boolean; detail: string } = { ok: false, detail: (done.stderr || 'it failed').trim().split('\n').at(-1) ?? 'it failed' }
409
409
  try {
@@ -419,13 +419,13 @@ const KEY = 'mcp__backstep__undo_key'
419
419
  const KEY_TOOL = {
420
420
  name: 'undo_key',
421
421
  description:
422
- "Change the key that undoes Claude's last turn with Backstep (⌘Z and ctrl+z on a Mac, ctrl+z elsewhere, unless the user changed it). " +
422
+ "Change the key that undoes Claude's last turn with Backstep (ctrl+u unless the user changed it; ctrl+z can't be used, Claude Code suspends on it). " +
423
423
  'Use it when the user asks to change, rebind or pick a different Backstep or undo shortcut. ' +
424
424
  'Keys are written like ctrl+u, cmd+z (⌘ on a Mac; most Mac terminals keep ⌘ keys for themselves, so pair it with a ctrl key), meta+z (Option) or ctrl+shift+z. ' +
425
425
  'Several keys can share undo: separate them with commas. It takes effect right away, no restart.',
426
426
  inputSchema: {
427
427
  type: 'object',
428
- properties: { key: { type: 'string', description: 'the new key, like ctrl+u, or several separated by commas, like "cmd+z, ctrl+z"' } },
428
+ properties: { key: { type: 'string', description: 'the new key, like ctrl+u, or several separated by commas, like "ctrl+u, ctrl+q"' } },
429
429
  required: ['key'],
430
430
  },
431
431
  }
@@ -452,7 +452,7 @@ const CONNECT = 'mcp__backstep__connect'
452
452
  const CONNECT_TOOL = {
453
453
  name: 'connect',
454
454
  description:
455
- "Connect Backstep (the undo for this session's changes) to the services the project uses, so changes Claude makes to them can be undone with ctrl+z. " +
455
+ "Connect Backstep (the undo for this session's changes) to the services the project uses, so changes Claude makes to them can be undone with ctrl+u. " +
456
456
  'Use it when the user asks to connect Backstep, or to make their database or Firebase undoable. ' +
457
457
  "Supabase: finds the database's connection string in the project's .env files or its local `supabase start` database. " +
458
458
  'When it reports it needs a sign-in, tell the user how to make an access token (it says where), ask them to paste it, then call this again with connection_string. ' +
@@ -640,7 +640,7 @@ export const register: Register = (on, options) => {
640
640
 
641
641
  // Idle: a quiet line naming what one press would take back. It's always
642
642
  // there, because the button has to be on screen for the key to reach it;
643
- // without it, ctrl+z falls through to the terminal and suspends Claude Code.
643
+ // with nothing to undo, a press says so rather than doing nothing.
644
644
  return (
645
645
  <Box>
646
646
  <Text color="success">↺ </Text>
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "backstep",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Undo for everything your agents touch: an MCP proxy that makes agent actions reversible.",
5
5
  "scripts": {
6
6
  "dev": "node src/cli.ts",
7
7
  "ui": "node --disable-warning=ExperimentalWarning src/cli.ts ui",
8
8
  "demo": "node --disable-warning=ExperimentalWarning examples/demo-db.ts",
9
- "build": "tsc -p tsconfig.json && cp src/ui/index.html dist/ui/index.html && chmod +x dist/cli.js",
9
+ "build": "tsc -p tsconfig.json && cp src/ui/index.html dist/ui/index.html && chmod +x dist/cli.js && node -e \"const f='mod/.claude-plugin/plugin.json',p=JSON.parse(require('fs').readFileSync(f));p.version=require('./package.json').version;require('fs').writeFileSync(f,JSON.stringify(p,null,2)+'\\\\n')\"",
10
10
  "test": "node --disable-warning=ExperimentalWarning --test test/*.test.ts",
11
11
  "typecheck": "tsc -p tsconfig.json --noEmit"
12
12
  },