backstep 0.1.14 → 0.1.16

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.
@@ -24,6 +24,13 @@ function sizeOf(p, budget) {
24
24
  return total;
25
25
  }
26
26
  // Returns null when a path is too large to copy; the caller then can't promise undo.
27
+ // Whether a path sits in the folder Claude Code has open. Edits elsewhere belong to
28
+ // other projects: Backstep never records them, so undo here can never touch them.
29
+ export function insideDir(path, dir) {
30
+ const p = resolvePath(path, dir);
31
+ const d = resolve(dir).replace(/\/$/, "");
32
+ return p === d || p.startsWith(d + "/");
33
+ }
27
34
  export function snapshotPaths(actionId, paths, cwd) {
28
35
  const dir = snapshotDir(actionId);
29
36
  const seen = new Set();
package/dist/cli.js CHANGED
@@ -5,7 +5,7 @@ import { runProxy } from "./proxy.js";
5
5
  import { startUi } from "./ui/server.js";
6
6
  import { Store, snapshotDir } from "./store.js";
7
7
  import { pruneDatabaseCopies, undoAction, undoRun, undoSince } from "./undo.js";
8
- import { snapshotPaths } from "./adapters/fs.js";
8
+ import { insideDir, snapshotPaths } from "./adapters/fs.js";
9
9
  import { notePushed, snapshotSqlite, snapshotTree } from "./adapters/tree.js";
10
10
  import { revertPush } from "./push.js";
11
11
  import { ghMade, grantScope, undoGh } from "./github.js";
@@ -24,8 +24,16 @@ import { autoWrap, printChanges, unwrapServers, wrapServers } from "./servers.js
24
24
  import { disconnectSupabase, dismissSupabase, firebaseConn, postgresUrl, restKey } from "./supabase.js";
25
25
  // The Supabase projects a shell command could change: those in the folder it
26
26
  // works in (or a level or two below), when the command is one that reaches a database.
27
+ // A command that `cd`s into another project is out of scope: only the open folder is tracked.
28
+ function scopedDir(command, cwd) {
29
+ const d = commandDir(command, cwd);
30
+ return insideDir(d, cwd) ? d : null;
31
+ }
27
32
  function reachedProjects(command, cwd) {
28
- const dirs = supabaseDirs(commandDir(command, cwd));
33
+ const at = scopedDir(command, cwd);
34
+ if (!at)
35
+ return [];
36
+ const dirs = supabaseDirs(at);
29
37
  return touchesDatabase(command) || touchesSupabase(command, dirs.length > 0) ? dirs : [];
30
38
  }
31
39
  // Every database a shell command could change, by folder: Supabase, other Postgres, Firebase.
@@ -33,7 +41,8 @@ function reachedDatabases(command, cwd) {
33
41
  const out = [];
34
42
  for (const d of reachedProjects(command, cwd))
35
43
  out.push({ dir: d, service: "supabase" });
36
- for (const d of dbDirs(commandDir(command, cwd))) {
44
+ const at = scopedDir(command, cwd);
45
+ for (const d of at ? dbDirs(at) : []) {
37
46
  const u = projectUses(d);
38
47
  if (u.postgres && (touchesDatabase(command) || touchesSupabase(command, true)))
39
48
  out.push({ dir: d, service: "postgres" });
@@ -307,10 +316,17 @@ async function main() {
307
316
  args: sep === -1 ? rest : rest.slice(0, sep),
308
317
  options: { session: { type: "string" }, tool: { type: "string" }, cwd: { type: "string" } },
309
318
  });
310
- const paths = sep === -1 ? [] : rest.slice(sep + 1);
311
- if (!values.session || !values.tool || paths.length === 0) {
319
+ const asked = sep === -1 ? [] : rest.slice(sep + 1);
320
+ if (!values.session || !values.tool || asked.length === 0) {
312
321
  throw new Error("usage: backstep snap --session <id> --tool <name> [--cwd <dir>] -- <paths...>");
313
322
  }
323
+ // Claude Code names the folder it has open: a file in another project is left out entirely.
324
+ const scope = values.cwd;
325
+ const paths = scope ? asked.filter((p) => insideDir(p, scope)) : asked;
326
+ if (paths.length === 0) {
327
+ console.log(JSON.stringify({ outside: true, undoable: false }));
328
+ return;
329
+ }
314
330
  const store = new Store();
315
331
  const run = store.sessionRun("claude-code", `session:${values.session}`);
316
332
  const a = store.addAction({
@@ -541,11 +557,12 @@ async function main() {
541
557
  until: { type: "string" },
542
558
  json: { type: "boolean", default: false },
543
559
  "via-claude-code": { type: "boolean", default: false },
560
+ session: { type: "string" },
544
561
  },
545
562
  });
546
563
  const store = new Store();
547
564
  const outcomes = values.since
548
- ? await undoSince(store, Number(values.since), values.until ? Number(values.until) : undefined, { viaClaudeCode: values["via-claude-code"] })
565
+ ? await undoSince(store, Number(values.since), values.until ? Number(values.until) : undefined, { viaClaudeCode: values["via-claude-code"], session: values.session })
549
566
  : values.run
550
567
  ? await undoRun(store, values.run)
551
568
  : await undoAction(store, positionals[0] ?? "");
package/dist/undo.js CHANGED
@@ -201,9 +201,21 @@ export async function undoRun(store, runId) {
201
201
  // Everything any agent did in a window of time: what one turn of a chat did,
202
202
  // across Claude's own edits and every server wrapped by Backstep.
203
203
  export async function undoSince(store, since, until = Date.now(), opts = {}) {
204
- const inWindow = store.db
204
+ let inWindow = store.db
205
205
  .prepare("SELECT * FROM actions WHERE created_at >= ? AND created_at <= ? ORDER BY created_at, seq")
206
206
  .all(since, until);
207
+ if (opts.session) {
208
+ const mine = `session:${opts.session}`;
209
+ const commands = new Map();
210
+ inWindow = inWindow.filter((a) => {
211
+ if (!commands.has(a.run_id))
212
+ commands.set(a.run_id, store.getRun(a.run_id)?.command);
213
+ const command = commands.get(a.run_id);
214
+ // Another session's run: never ours to undo. Servers wrapped by `backstep wrap`
215
+ // record under their own runs and stay included, as before.
216
+ return !(command?.startsWith("session:") && command !== mine);
217
+ });
218
+ }
207
219
  return undoAll(store, inWindow.filter(undoable), opts);
208
220
  }
209
221
  async function undoAll(store, actions, opts = {}) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backstep",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
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": {
@@ -60,6 +60,39 @@ test('an edit is snapshotted and the band offers to undo the turn', { options: {
60
60
  }
61
61
  })
62
62
 
63
+ test('edits outside the open folder are never tracked', { options: { cli: CLI } }, async ($, on) => {
64
+ mock.clock(on, { now: 5000 })
65
+ const { runs } = engine(on, { exitCode: 0, stdout: '[]' })
66
+
67
+ await $.prompt.submit({ text: 'fix the other project', wait: false, origin: { kind: 'composer' } })
68
+ await $.tool.call({ tool: 'Write', file_path: '/other-project/src/new.ts', content: 'x' })
69
+ await $.tool.call({ tool: 'Edit', file_path: '/repo-sibling/a.ts', old_string: 'a', new_string: 'b' })
70
+ await $.tool.call({ tool: 'Edit', file_path: '/repo/../other-project/a.ts', old_string: 'a', new_string: 'b' })
71
+
72
+ expect(runs.filter((r) => r.includes('snap'))).toEqual([])
73
+ const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
74
+ expect((await ui.find({ type: 'Text', text: /nothing to undo yet/ }))?.text).toBeTruthy()
75
+ await ui.unmount()
76
+ })
77
+
78
+ test('a long last turn stays on one line with a fixed gap after the logo', { options: { cli: CLI } }, async ($, on) => {
79
+ mock.clock(on, { now: 5000 })
80
+ engine(on, { exitCode: 0, stdout: '[]' }, { kind: 'write', undoable: true })
81
+
82
+ await $.prompt.submit({ text: 'scrap that, lets try making the whole thing much longer than fits', wait: false, origin: { kind: 'composer' } })
83
+ for (const f of ['a', 'b', 'c']) await $.tool.call({ tool: 'Edit', file_path: `/repo/src/${f}.ts`, old_string: 'a', new_string: 'b' })
84
+ for (let i = 0; i < 8; i++) await $.tool.call({ tool: 'Bash', command: `touch x${i}` })
85
+
86
+ const ui = await $.ui.mount({ ...BAND, props: { ...BAND.props, bodyColumns: 60 }, surface: 'terminal' })
87
+ const line = await ui.find({ type: 'Text', text: /Last turn/ })
88
+ // No leading space to be trimmed on a wrap: the gap is the logo box's own margin.
89
+ expect(line?.text).toMatch(/^Last turn:/)
90
+ expect(line?.props.wrap).toBe('truncate-end')
91
+ expect(await ui.find({ type: 'Box', props: { flexShrink: 0, marginRight: 1 } })).toBeTruthy()
92
+ expect(await ui.find({ type: 'Button', key: 'undo' })).toBeTruthy()
93
+ await ui.unmount()
94
+ })
95
+
63
96
  test('pressing undo rewinds the turn and tells Claude on the next prompt', { options: { cli: CLI } }, async ($, on) => {
64
97
  const clock = mock.clock(on, { now: 5000 })
65
98
  const { runs, prompts } = engine(on, {
@@ -75,7 +108,7 @@ test('pressing undo rewinds the turn and tells Claude on the next prompt', { opt
75
108
  const pressed = ui.press({ key: 'undo' })
76
109
  await clock.settle()
77
110
  expect((await ui.find({ type: 'Text', text: /Rewinding/ }))?.text).toBeTruthy()
78
- expect(runs.at(-1)).toEqual(['node', '--disable-warning=ExperimentalWarning', CLI, 'undo', '--since', '5000', '--json', '--via-claude-code'])
111
+ expect(runs.at(-1)).toEqual(['node', '--disable-warning=ExperimentalWarning', CLI, 'undo', '--since', '5000', '--session', 's1', '--json', '--via-claude-code'])
79
112
 
80
113
  await clock.advance(1000)
81
114
  await pressed
@@ -141,7 +174,8 @@ test('with nothing to undo the band still holds the key, and a press says there
141
174
 
142
175
  const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
143
176
  expect((await ui.find({ type: 'Text', text: /nothing to undo yet/ }))).toBeTruthy()
144
- // The mark: the front triangle in the terminal's own color, the one behind in the logo's pink.
177
+ // The gap after the mark is its own fixed margin, so a long line can't squeeze it away.
178
+ expect((await ui.find({ type: 'Text', text: /nothing to undo yet/ }))?.text).toMatch(/^Backstep is on/) // The mark: the front triangle in the terminal's own color, the one behind in the logo's pink.
145
179
  expect((await ui.find({ type: 'Text', text: '◀', props: { color: '#ee7bb6' } }))).toBeTruthy()
146
180
  await ui.press({ key: 'undo' })
147
181
  expect(toasts[0]).toContain('Nothing to undo yet')
@@ -666,7 +700,7 @@ test('when the last turn only committed and pushed, undo offers to take back the
666
700
  await clock.advance(1000)
667
701
  await pressed
668
702
  // Both turns: everything since the first one started.
669
- expect(runs.find((r) => r.includes('undo'))?.slice(3)).toEqual(['undo', '--since', '5000', '--json', '--via-claude-code'])
703
+ expect(runs.find((r) => r.includes('undo'))?.slice(3)).toEqual(['undo', '--since', '5000', '--session', 's1', '--json', '--via-claude-code'])
670
704
  await ui.unmount()
671
705
  })
672
706
 
@@ -183,6 +183,19 @@ async function offerPushUndo($: EngineInterface, outcomes: Outcome[], repoGone =
183
183
  // The first press asks, right there above the prompt; a second press within a
184
184
  // few seconds undoes. A dialog would take the band off screen, and with it the
185
185
  // key, which would then suspend Claude Code in a terminal.
186
+ // Whether a file path is inside the folder this session has open.
187
+ function within(path: string, cwd: string): boolean {
188
+ const abs = path.startsWith('/') ? path : `${cwd}/${path}`
189
+ const parts: string[] = []
190
+ for (const seg of abs.split('/')) {
191
+ if (seg === '..') parts.pop()
192
+ else if (seg && seg !== '.') parts.push(seg)
193
+ }
194
+ const p = '/' + parts.join('/')
195
+ const d = cwd.replace(/\/+$/, '')
196
+ return p === d || p.startsWith(d + '/')
197
+ }
198
+
186
199
  async function pressUndo($: EngineInterface) {
187
200
  if (await read($, rewind)) return
188
201
  const target = (await read($, turns)).findLast(hasWork)
@@ -218,7 +231,7 @@ async function undoLastTurn($: EngineInterface, count = 1) {
218
231
  const cwd = await $.session.cwd()
219
232
  const short = (p: string) => (p.startsWith(cwd + '/') ? p.slice(cwd.length + 1) : p)
220
233
  const [ran] = await Promise.all([
221
- $.process.run(backstep(['undo', '--since', String(target.startedAt), '--json', '--via-claude-code'])).catch((err: unknown) => ({
234
+ $.process.run(backstep(['undo', '--since', String(target.startedAt), '--session', await $.session.id(), '--json', '--via-claude-code'])).catch((err: unknown) => ({
222
235
  exitCode: 1,
223
236
  stdout: '',
224
237
  stderr: err instanceof Error ? err.message : String(err),
@@ -712,9 +725,11 @@ export const register: Register = (on, options) => {
712
725
  if (EDIT_TOOLS.has(tool)) {
713
726
  const input = e as { file_path?: string; notebook_path?: string }
714
727
  const path = input.file_path ?? input.notebook_path
715
- if (path) {
728
+ const cwd = await $.session.cwd()
729
+ // Only the open folder is tracked: an edit in another project is never
730
+ // recorded, so undoing here can never delete or roll back files there.
731
+ if (path && within(path, cwd)) {
716
732
  const t = await current($)
717
- const cwd = await $.session.cwd()
718
733
  const sid = await $.session.id()
719
734
  // A failed snapshot never blocks the edit; it just won't be undoable.
720
735
  const snap = await $.process
@@ -778,14 +793,21 @@ export const register: Register = (on, options) => {
778
793
  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
779
794
  if (e.props.hasSurvey) return next(e)
780
795
  const { Box, Button, Text } = $.ui.resolve(e)
796
+ // The logo keeps its own box with a fixed gap after it: a space inside the
797
+ // text next to it gets trimmed when a long line wraps, crushing the two together.
798
+ const mark = () => (
799
+ <Box flexShrink={0} marginRight={1}>
800
+ <Text bold>◀</Text>
801
+ <Text color={PINK} bold>◀</Text>
802
+ </Box>
803
+ )
781
804
  const r = await read($, rewind)
782
805
 
783
806
  if (r?.phase === 'running') {
784
807
  return (
785
808
  <Box>
786
- <Text bold>◀</Text>
787
- <Text color={PINK} bold>◀</Text>
788
- <Text bold> Rewinding </Text>
809
+ {mark()}
810
+ <Text bold>Rewinding </Text>
789
811
  <Text>{bar(r.frame)}</Text>
790
812
  <Text dimColor> undoing the last turn…</Text>
791
813
  </Box>
@@ -797,9 +819,8 @@ export const register: Register = (on, options) => {
797
819
  const step = waiting ? (
798
820
  <Box flexDirection="column" marginTop={r ? 1 : 0}>
799
821
  <Box>
800
- <Text bold>◀</Text>
801
- <Text color={PINK} bold>◀</Text>
802
- <Text color="warning" bold>{` One more step to ${waiting.what}:`}</Text>
822
+ {mark()}
823
+ <Text color="warning" bold>{`One more step to ${waiting.what}:`}</Text>
803
824
  </Box>
804
825
  <Text>{' Paste this code on the GitHub page that just opened, then press Authorize:'}</Text>
805
826
  <Box>
@@ -858,9 +879,8 @@ export const register: Register = (on, options) => {
858
879
  return (
859
880
  <Box flexDirection="column">
860
881
  <Box>
861
- <Text bold>◀</Text>
862
- <Text color={PINK} bold>◀</Text>
863
- <Text color="warning" bold>{' That turn only saved and pushed to git.'}</Text>
882
+ {mark()}
883
+ <Text color="warning" bold>{'That turn only saved and pushed to git.'}</Text>
864
884
  </Box>
865
885
  <Text dimColor>{` Undo it and the turn before it${quoted(before, 50)}?`}</Text>
866
886
  <Box>
@@ -881,9 +901,8 @@ export const register: Register = (on, options) => {
881
901
  return (
882
902
  <Box flexDirection="column">
883
903
  <Box>
884
- <Text bold>◀</Text>
885
- <Text color={PINK} bold>◀</Text>
886
- <Text color="warning" bold>{" Undo Claude's last turn?"}</Text>
904
+ {mark()}
905
+ <Text color="warning" bold>{"Undo Claude's last turn?"}</Text>
887
906
  </Box>
888
907
  <Text dimColor>{` ${quoted(target, 50).trim() || 'That turn'} · ${summary(target)}`}</Text>
889
908
  <Box>
@@ -903,13 +922,16 @@ export const register: Register = (on, options) => {
903
922
  // with nothing to undo, a press says so rather than doing nothing.
904
923
  return (
905
924
  <Box>
906
- <Text bold>◀</Text>
907
- <Text color={PINK} bold>◀</Text>
908
- <Text> </Text>
909
- <Text dimColor>{target ? `Last turn:${quoted(target, 28)} · ${summary(target)} ` : 'Backstep is on · nothing to undo yet '}</Text>
910
- <Button key="undo" action={UNDO_ACTION} plain dimColor onPress={() => void pressUndo($)}>
911
- {`${label} to undo`}
912
- </Button>
925
+ {mark()}
926
+ {/* One line: a long summary is cut short so the key to press always shows. */}
927
+ <Box flexShrink={1} minWidth={0}>
928
+ <Text dimColor wrap="truncate-end">{target ? `Last turn:${quoted(target, 28)} · ${summary(target)}` : 'Backstep is on · nothing to undo yet'}</Text>
929
+ </Box>
930
+ <Box flexShrink={0} marginLeft={2}>
931
+ <Button key="undo" action={UNDO_ACTION} plain dimColor onPress={() => void pressUndo($)}>
932
+ {`${label} to undo`}
933
+ </Button>
934
+ </Box>
913
935
  </Box>
914
936
  )
915
937
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backstep",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
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",