backstep 0.1.26 → 0.1.28

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
@@ -67,6 +67,8 @@ With Claude Code: `claude mcp add files -- backstep wrap --name files -- <the se
67
67
 
68
68
  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.
69
69
 
70
+ In the Claude Code desktop app, keys don't reach Backstep, so the line above the message box has an **Undo last turn** button to click instead (it asks once, then undoes). Typing `/backstep-undo` works everywhere and undoes straight away.
71
+
70
72
  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).
71
73
 
72
74
  Other commands: `backstep ui`, `backstep log`, `backstep undo <action-id>`, `backstep undo --run <run-id>`, `backstep connect [supabase|firebase]` (connects what the project uses, the same as asking Claude), `backstep disconnect supabase <ref>`.
@@ -119,7 +121,7 @@ Everything is journaled to `~/.backstep/journal.db` (SQLite, mode 0600; override
119
121
 
120
122
  ## Telemetry
121
123
 
122
- `backstep setup` sends one anonymous `install` event, Claude Code with the Backstep mod sends one `active` event a day, and `backstep uninstall` sends one `uninstall` event. Each carries a random id made on your machine (`~/.backstep/install-id`), the Backstep version, your OS and CPU type, and the Node major version. Nothing else: no paths, commands, server names, IP-based location, or anything from the journal. The code is `src/telemetry.ts`.
124
+ `backstep setup` sends one anonymous `install` event the first time on a computer (and an `update` event each time after, as updates run setup again), Claude Code with the Backstep mod sends one `active` event a day, and `backstep uninstall` sends one `uninstall` event. Each carries a random id made on your machine (`~/.backstep/install-id`), the Backstep version, your OS and CPU type, and the Node major version. Nothing else: no paths, commands, server names, IP-based location, or anything from the journal. The code is `src/telemetry.ts`.
123
125
 
124
126
  Turn it off with `BACKSTEP_TELEMETRY=0`, or the standard `DO_NOT_TRACK=1`.
125
127
 
package/dist/setup.js CHANGED
@@ -86,6 +86,10 @@ export async function setup() {
86
86
  }
87
87
  if (!r.ok)
88
88
  throw new Error(`claude plugin install failed:\n${r.out}`);
89
+ // Already installed, install keeps Claude Code's cached copy of the mod as it
90
+ // was; re-reading the folder and updating brings the mod up to this version.
91
+ run("claude", ["plugin", "marketplace", "update", "backstep"]);
92
+ run("claude", ["plugin", "update", "backstep@backstep"]);
89
93
  // Only a problem is worth a line here; the summary below says the rest.
90
94
  const bound = ensureUndoKey();
91
95
  if (bound)
package/dist/telemetry.js CHANGED
@@ -1,4 +1,4 @@
1
- // An anonymous count of installs and of installs still in use, so we know how
1
+ // An anonymous count of installs, updates and installs still in use, so we know how
2
2
  // many people use Backstep. Each event carries a random id made on this
3
3
  // machine, the Backstep version, the OS and the Node version. Never paths,
4
4
  // commands, server names or anything from the journal.
@@ -62,7 +62,10 @@ async function send(event) {
62
62
  // Offline, blocked, or slow: skip it.
63
63
  }
64
64
  }
65
- export const sendInstall = () => send("install");
65
+ // Setup runs on a first install and again on every update. Only the first, on a
66
+ // computer that has never sent anything (no install-id yet), counts as an
67
+ // install; every later setup is an update, so installs stay new installs.
68
+ export const sendInstall = () => send(existsSync(join(homeDir(), "install-id")) ? "update" : "install");
66
69
  export const sendUninstall = () => send("uninstall");
67
70
  // At most once a day, as Claude Code starts with Backstep's mod.
68
71
  export async function sendActive() {
package/dist/update.js CHANGED
@@ -5,6 +5,7 @@
5
5
  import { spawnSync } from "./spawn.js";
6
6
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
7
  import { join } from "node:path";
8
+ import { tmpdir } from "node:os";
8
9
  import { homeDir } from "./store.js";
9
10
  import { version } from "./telemetry.js";
10
11
  const REGISTRY = process.env.BACKSTEP_REGISTRY ?? "https://registry.npmjs.org";
@@ -68,6 +69,9 @@ const pause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0,
68
69
  // at it and re-wraps servers, exactly as a fresh install would. npm is asked
69
70
  // online, never from its cache, which can be older than a version just
70
71
  // published; and asked again for a while if it still hasn't heard of it.
72
+ // It runs from a neutral folder: inside a project that is itself named
73
+ // backstep (or depends on it), npx runs that copy instead of fetching the one
74
+ // asked for, and the "update" reinstalls the old version.
71
75
  export function runUpdate(v, tries = 4) {
72
76
  let out = "";
73
77
  for (let i = 0; i < tries; i++) {
@@ -78,10 +82,15 @@ export function runUpdate(v, tries = 4) {
78
82
  timeout: 5 * 60 * 1000,
79
83
  env: { ...process.env, NO_COLOR: "1", BACKSTEP_TELEMETRY: "0" },
80
84
  stdio: ["ignore", "pipe", "pipe"],
85
+ cwd: tmpdir(),
81
86
  });
82
- if (r.status === 0)
83
- return { ok: true, detail: `updated to ${v}` };
84
87
  out = `${r.stderr ?? ""}\n${r.stdout ?? ""}`;
88
+ // Setup says which version it put in place; anything else means npx ran some other copy.
89
+ if (r.status === 0) {
90
+ if (out.includes(`backstep@${v}`))
91
+ return { ok: true, detail: `updated to ${v}` };
92
+ return { ok: false, detail: `npx ran a different copy of Backstep instead of ${v}. Run this from your home folder: npx backstep@${v} setup` };
93
+ }
85
94
  if (!/ETARGET|notarget|No matching version/i.test(out))
86
95
  break;
87
96
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backstep",
3
- "version": "0.1.26",
3
+ "version": "0.1.28",
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": {
@@ -1129,3 +1129,50 @@ test('when signing in is required and nobody is, ctrl+u says how to sign in inst
1129
1129
  expect(toasts.at(-1)).toContain('npx backstep login')
1130
1130
  await ui.unmount()
1131
1131
  })
1132
+
1133
+ test('in the desktop app the band says Undo (a click), since ctrl+u never reaches it there', { options: { cli: CLI } }, async ($, on) => {
1134
+ mock.clock(on, { now: 5000 })
1135
+ engine(on, { exitCode: 0, stdout: '[]' })
1136
+ await $.prompt.submit({ text: 'fix it', wait: false, origin: { kind: 'composer' } })
1137
+ await $.tool.call({ tool: 'Edit', file_path: '/repo/src/a.ts', old_string: 'a', new_string: 'b' })
1138
+
1139
+ const desk = await $.ui.mount({ ...BAND, surface: 'desktop' })
1140
+ expect((await desk.find({ type: 'Button', key: 'undo' }))?.text).toBe('◀ Undo last turn')
1141
+ expect(JSON.stringify(await desk.find({ type: 'Box', text: /Undo last turn/ }))).not.toContain('ctrl')
1142
+ await desk.press({ key: 'undo' })
1143
+ expect(await desk.find({ type: 'Text', text: /Undo Claude's last turn\?/ })).toBeTruthy()
1144
+ expect((await desk.find({ type: 'Button', key: 'undo' }))?.text).toBe('Yes, undo it')
1145
+ expect((await desk.find({ type: 'Button', key: 'cancel' }))?.text).toBe('Cancel')
1146
+ await desk.unmount()
1147
+
1148
+ const term = await $.ui.mount({ ...BAND, surface: 'terminal' })
1149
+ expect((await term.find({ type: 'Button', key: 'undo' }))?.text).toContain('again to undo')
1150
+ await term.unmount()
1151
+ })
1152
+
1153
+ test('/backstep-undo undoes the last turn straight away, and says when there is nothing to undo', { options: { cli: CLI } }, async ($, on) => {
1154
+ const clock = mock.clock(on, { now: 5000 })
1155
+ const { runs } = engine(on, { exitCode: 0, stdout: JSON.stringify([{ id: 'c1', tool: 'Edit', ok: true, detail: 'restored /repo/src/a.ts' }]) })
1156
+
1157
+ expect(JSON.stringify(await $.command.run({ command: 'backstep-undo' } as never))).toContain('Nothing to undo yet')
1158
+
1159
+ await $.prompt.submit({ text: 'fix it', wait: false, origin: { kind: 'composer' } })
1160
+ await $.tool.call({ tool: 'Edit', file_path: '/repo/src/a.ts', old_string: 'a', new_string: 'b' })
1161
+ const ran = $.command.run({ command: 'backstep-undo' } as never)
1162
+ await clock.advance(1000)
1163
+ const r = await ran
1164
+ expect(runs.some((a) => a[3] === 'undo')).toBe(true)
1165
+ expect(JSON.stringify(r)).toContain('Rewound 1 change from the last turn.')
1166
+ })
1167
+
1168
+ test('in the desktop app, the update is offered the same way and says not to close Claude Code', { options: { cli: CLI } }, async ($, on) => {
1169
+ const { asked, logs, statuses } = updates(on, 'Update')
1170
+ await $.session.start({ cwd: '/repo', surface: 'desktop', hasPerson: true } as never)
1171
+ await settle()
1172
+ expect(asked).toEqual(['Backstep 0.2.0 is out (you have 0.1.2). Update now?'])
1173
+ const said = logs.join('\n')
1174
+ expect(said).toContain('type /backstep-undo')
1175
+ expect(said).toContain("Don't close Claude Code until it says it's done")
1176
+ expect(statuses[0]).toContain("don't close Claude Code")
1177
+ expect(said).not.toContain('terminal')
1178
+ })
@@ -281,6 +281,18 @@ async function pressUndo($: EngineInterface) {
281
281
  })
282
282
  }
283
283
 
284
+ // /backstep-undo: the command is the confirmation, so it undoes straight away.
285
+ async function undoByCommand($: EngineInterface): Promise<string> {
286
+ if (await read($, rewind)) return 'Backstep is already undoing a turn.'
287
+ const off = await read($, blocked)
288
+ if (off) return `${MARK_TEXT} ${off}`
289
+ if (!(await read($, turns)).some(hasWork)) return "Nothing to undo yet: Claude hasn't changed anything Backstep can take back."
290
+ await update($, armed, () => null)
291
+ const { before } = await undoTargets($)
292
+ await undoLastTurn($, before ? 2 : 1)
293
+ return (await read($, rewind))?.message ?? 'Done.'
294
+ }
295
+
284
296
  // Undoes the last `count` turns that changed something, newest first.
285
297
  async function undoLastTurn($: EngineInterface, count = 1) {
286
298
  if (await read($, rewind)) return
@@ -396,7 +408,7 @@ async function undoLastTurn($: EngineInterface, count = 1) {
396
408
 
397
409
  if (done) {
398
410
  const list = items.filter((i) => i.ok).map((i) => `- ${i.label}`).join('\n')
399
- const key = await keysLabel($)
411
+ const key = surface === 'terminal' ? await keysLabel($) : 'the Undo button or /backstep-undo'
400
412
  await update($, note, () =>
401
413
  `[Backstep] The user pressed undo (${key}) on your ${taken.length > 1 ? `last ${taken.length} turns` : 'previous turn'}. These changes were reverted:\n${list}\n` +
402
414
  (lost ? `${plural(lost, 'action')} from that turn couldn't be undone (they reached outside this machine with no way back, or the project was too big to snapshot).\n` : '') +
@@ -686,7 +698,7 @@ async function checkAccess($: EngineInterface): Promise<boolean> {
686
698
  // the minimum, then
687
699
  }
688
700
  const answer = await $.ui
689
- .ask(`This version of Backstep (${a.current}) is no longer supported, so ${await keysLabel($)} is off until it updates. Update to ${latest} now?`, {
701
+ .ask(`This version of Backstep (${a.current}) is no longer supported, so ${surface === 'terminal' ? await keysLabel($) : 'undo'} is off until it updates. Update to ${latest} now?`, {
690
702
  header: 'Backstep',
691
703
  options: ['Update now', 'Not now'],
692
704
  })
@@ -721,9 +733,10 @@ async function installUpdate($: EngineInterface, current: string | undefined, la
721
733
  const u = { current, latest }
722
734
  // Said where it stays (the conversation) and where it's seen while it runs (the status line).
723
735
  const from = u.current ? ` from ${u.current}` : ''
724
- $.ui.log(`${MARK_TEXT} Updating Backstep${from} to ${u.latest}… Don't close this terminal until it says it's done (about a minute).`)
725
- $.ui.toast(`Updating Backstep to ${u.latest}. Don't close this terminal until it's done.`, { timeoutMs: 15_000 })
726
- $.ui.status(`◀◀ Updating Backstep to ${u.latest}… don't close this terminal`)
736
+ const win = surface === 'terminal' ? 'this terminal' : 'Claude Code'
737
+ $.ui.log(`${MARK_TEXT} Updating Backstep${from} to ${u.latest}… Don't close ${win} until it says it's done (about a minute).`)
738
+ $.ui.toast(`Updating Backstep to ${u.latest}. Don't close ${win} until it's done.`, { timeoutMs: 15_000 })
739
+ $.ui.status(`◀◀ Updating Backstep to ${u.latest}… don't close ${win}`)
727
740
  const done = await $.process
728
741
  .run(backstep(['update', '--version', u.latest, '--json']), { timeoutMs: 600_000 })
729
742
  .catch((err: unknown) => ({ exitCode: 1, stdout: '', stderr: err instanceof Error ? err.message : String(err) }))
@@ -735,7 +748,7 @@ async function installUpdate($: EngineInterface, current: string | undefined, la
735
748
  }
736
749
  $.ui.status(undefined)
737
750
  if (r.ok) {
738
- $.ui.log(`${MARK_TEXT} Backstep is updated to ${u.latest}${u.current ? ` (you had ${u.current})` : ''}. It's safe to close the terminal now. Restart Claude Code to start using ${u.latest}.`)
751
+ $.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}.`)
739
752
  $.ui.toast(`Backstep is updated to ${u.latest}. Restart Claude Code to use it.`)
740
753
  return true
741
754
  } else {
@@ -866,15 +879,21 @@ async function uninstallBackstep($: EngineInterface): Promise<string> {
866
879
  return `Backstep is removed. Restart Claude Code to finish.\n${lines}`
867
880
  }
868
881
 
882
+ // Where this session is drawn: the terminal, or an app (the desktop app, VS Code) where there's no terminal to mention.
883
+ let surface = 'terminal'
884
+
869
885
  export const register: Register = (on, options) => {
870
886
  cli = String(options.cli ?? 'backstep')
871
887
 
872
888
  on('session.start', async ($, e, next) => {
889
+ surface = e.surface ?? 'terminal'
873
890
  await $.tool.register(CONNECT_TOOL)
874
891
  await $.tool.register(KEY_TOOL)
875
892
  await $.tool.register(UNINSTALL_TOOL)
876
893
  // A command that can't be added never holds up the session: asking Claude still uninstalls.
877
894
  await $.command.register({ name: 'uninstall-backstep', description: 'Remove Backstep and everything it set up from this computer' }).catch(() => null)
895
+ // Undo by command too: the desktop app has no way for ctrl+u to reach Backstep yet.
896
+ await $.command.register({ name: 'backstep-undo', description: "Undo Claude's last turn with Backstep" }).catch(() => null)
878
897
  const r = await next(e)
879
898
  const boundOut = await $.process.run(backstep(['key', '--json'])).catch(() => null)
880
899
  try {
@@ -884,7 +903,11 @@ export const register: Register = (on, options) => {
884
903
  // keep the default
885
904
  }
886
905
  // Said first thing, so nobody has to wonder whether it's running.
887
- $.ui.log(`✓ Backstep connected · press ${await keysLabel($)} to undo Claude's last turn`)
906
+ $.ui.log(
907
+ e.surface === 'terminal'
908
+ ? `✓ Backstep connected · press ${await keysLabel($)} to undo Claude's last turn`
909
+ : "✓ Backstep connected · click Undo last turn above the message box, or type /backstep-undo, to undo Claude's last turn",
910
+ )
888
911
  // In the background: a session never waits on either.
889
912
  void wrapNewServers($, e.cwd)
890
913
  // One question at a time: Supabase first, then whether this version may run, then any update.
@@ -917,6 +940,7 @@ export const register: Register = (on, options) => {
917
940
  on('tool.call', { tool: UNINSTALL }, async ($) => ({ result: await uninstallBackstep($) }))
918
941
 
919
942
  on('command.run', { command: 'uninstall-backstep' }, async ($) => ({ text: await uninstallBackstep($) }))
943
+ on('command.run', { command: 'backstep-undo' }, async ($) => ({ text: await undoByCommand($) }))
920
944
 
921
945
  on('tool.call', async ($, e, next) => {
922
946
  const tool = String(e.tool)
@@ -1066,6 +1090,57 @@ export const register: Register = (on, options) => {
1066
1090
 
1067
1091
  const { target, before } = await undoTargets($)
1068
1092
  const label = (await read($, keys)).map(keyLabel).join(' / ')
1093
+
1094
+ // The desktop app (and any app that isn't the terminal): ctrl+u never reaches
1095
+ // Backstep there, so it's real buttons to click and no key is mentioned.
1096
+ const appBand = async (target: Turn | undefined, before: Turn | undefined) => {
1097
+ const cancel = (
1098
+ <Button key="cancel" variant="secondary" onPress={() => void update($, armed, () => null)}>
1099
+ Cancel
1100
+ </Button>
1101
+ )
1102
+ if (target && (await read($, armed)) !== null) {
1103
+ return (
1104
+ <Box flexDirection="column">
1105
+ <Box>
1106
+ {mark()}
1107
+ <Text color="warning" bold>{before ? 'That turn only saved and pushed to git.' : "Undo Claude's last turn?"}</Text>
1108
+ </Box>
1109
+ <Text dimColor>{before ? ` Undo it and the turn before it${quoted(before, 50)}?` : ` ${quoted(target, 50).trim() || 'That turn'} · ${summary(target)}`}</Text>
1110
+ <Box marginTop={1}>
1111
+ <Text>{' '}</Text>
1112
+ <Button key="undo" variant="primary" onPress={() => void pressUndo($)}>
1113
+ {before ? 'Yes, undo both' : 'Yes, undo it'}
1114
+ </Button>
1115
+ <Text>{' '}</Text>
1116
+ {before ? (
1117
+ <Button key="just" variant="secondary" onPress={() => void update($, armed, () => null).then(() => undoLastTurn($, 1))}>
1118
+ Just the push
1119
+ </Button>
1120
+ ) : null}
1121
+ {before ? <Text>{' '}</Text> : null}
1122
+ {cancel}
1123
+ </Box>
1124
+ </Box>
1125
+ )
1126
+ }
1127
+ return (
1128
+ <Box>
1129
+ {mark()}
1130
+ <Box flexShrink={1} minWidth={0}>
1131
+ <Text dimColor wrap="truncate-end">{target ? `Last turn:${quoted(target, 28)} · ${summary(target)}` : 'Backstep is running · nothing to undo yet'}</Text>
1132
+ </Box>
1133
+ <Box flexShrink={0} marginLeft={2}>
1134
+ <Button key="undo" variant="primary" onPress={() => void pressUndo($)}>
1135
+ ◀ Undo last turn
1136
+ </Button>
1137
+ </Box>
1138
+ </Box>
1139
+ )
1140
+ }
1141
+ // Only the terminal hands ctrl+u to the button; anywhere else (the desktop app) it's a click.
1142
+ const keyed = e.surface === 'terminal'
1143
+ if (!keyed) return appBand(target, before)
1069
1144
  if (target && (await read($, armed)) !== null) {
1070
1145
  // Laid out a line at a time, so nothing gets squeezed into columns on a narrow terminal.
1071
1146
  const cancel = (
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backstep",
3
- "version": "0.1.26",
3
+ "version": "0.1.28",
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": {