backstep 0.1.13 → 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({
@@ -464,8 +480,9 @@ async function main() {
464
480
  }
465
481
  // Takes away something Claude made on GitHub (the thing, as undo reported it, on stdin). The mod runs this after asking.
466
482
  // Gets the person's OK on GitHub for another gh permission: code on the clipboard, approval page opened.
483
+ // The code is printed on a line of its own straight away, so the mod can show it; the answer is the last line.
467
484
  if (cmd === "gh-auth") {
468
- const out = await grantScope(rest[0] ?? "");
485
+ const out = await grantScope(rest[0] ?? "", (code) => console.log(JSON.stringify({ code, url: "https://github.com/login/device" })));
469
486
  console.log(JSON.stringify(out));
470
487
  if (!out.ok)
471
488
  process.exitCode = 1;
@@ -540,11 +557,12 @@ async function main() {
540
557
  until: { type: "string" },
541
558
  json: { type: "boolean", default: false },
542
559
  "via-claude-code": { type: "boolean", default: false },
560
+ session: { type: "string" },
543
561
  },
544
562
  });
545
563
  const store = new Store();
546
564
  const outcomes = values.since
547
- ? 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 })
548
566
  : values.run
549
567
  ? await undoRun(store, values.run)
550
568
  : await undoAction(store, positionals[0] ?? "");
package/dist/github.js CHANGED
@@ -80,8 +80,9 @@ function openPage(url) {
80
80
  // Gives gh's sign-in another permission (like delete_repo) with GitHub's own
81
81
  // approval page: the one-time code is put on the clipboard and the page opened,
82
82
  // so the person only pastes it and approves. Resolves once GitHub says yes,
83
- // or no, or nine minutes pass (Claude Code gives a command ten).
84
- export function grantScope(scope) {
83
+ // or no, or nine minutes pass (Claude Code gives a command ten). `onCode` hears
84
+ // the code as soon as gh prints it, so it can be shown while GitHub waits.
85
+ export function grantScope(scope, onCode) {
85
86
  return new Promise((resolve) => {
86
87
  // gh would open the page too; Backstep already does, so gh's browser is told to do nothing.
87
88
  const env = { ...process.env, NO_COLOR: "1", GH_BROWSER: process.platform === "win32" ? "cmd /c exit" : "true" };
@@ -96,6 +97,7 @@ export function grantScope(scope) {
96
97
  shown = true;
97
98
  copy(code);
98
99
  openPage(DEVICE);
100
+ onCode?.(code);
99
101
  // gh waits for Enter before it would open the page itself; it's already open.
100
102
  child.stdin.write("\n");
101
103
  }
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.13",
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
 
@@ -723,13 +757,25 @@ test('a GitHub repository Claude created is offered for deletion after undo, and
723
757
  runs.filter((r) => r.argv.includes('gh-undo')).length === 1
724
758
  ? { ok: false, needs: 'delete_repo', detail: 'GitHub needs your OK before bzells11/taskflow can be deleted' }
725
759
  : { ok: true, detail: 'deleted the repository bzells11/taskflow' }
726
- : e.argv.includes('gh-auth')
727
- ? { ok: true, detail: 'GitHub approved it' }
728
760
  : e.argv.includes('db-check')
729
761
  ? { ask: null }
730
762
  : null
731
763
  return { value: { exitCode: 0, stdout: out ? JSON.stringify(out) : '', stderr: '', isStdoutTruncated: false, isStderrTruncated: false } }
732
764
  })
765
+ // GitHub's approval: the code comes first, then the answer once the person approves (when `approve` is called).
766
+ let approve = () => {}
767
+ const approved = new Promise<void>((resolve) => (approve = resolve))
768
+ let codeShown: Promise<void> = Promise.resolve()
769
+ let gotCode = () => {}
770
+ codeShown = new Promise<void>((resolve) => (gotCode = resolve))
771
+ on('process.spawn', async function* (_, e) {
772
+ runs.push({ argv: [...e.argv] })
773
+ yield { stream: 'stdout' as const, text: '{"code":"ABCD-1234","url":"https://github.com/login/device"}\n' }
774
+ gotCode()
775
+ await approved
776
+ yield { stream: 'stdout' as const, text: '{"ok":true,"detail":"GitHub approved it"}\n' }
777
+ return { value: { code: 0, signal: null } }
778
+ })
733
779
  on('tool.call', { tool: 'AskUserQuestion' }, (_, e) => {
734
780
  const questions = (e as unknown as { questions: { question: string }[] }).questions
735
781
  asked.push(questions[0].question)
@@ -753,7 +799,17 @@ test('a GitHub repository Claude created is offered for deletion after undo, and
753
799
  const pressed = ui.press({ key: 'undo' })
754
800
  await clock.advance(1000)
755
801
  await pressed
802
+ await codeShown
803
+ await clock.settle()
804
+ // While GitHub waits, the band says it's a step to take and shows the code, not a failure.
805
+ expect(await ui.find({ type: 'Text', text: /One more step to delete the GitHub repository bzells11\/taskflow/ })).toBeTruthy()
806
+ expect(await ui.find({ type: 'Text', text: 'ABCD-1234' })).toBeTruthy()
807
+ // The push went to the repository being deleted, so it isn't listed as not undone.
808
+ expect(await ui.find({ type: 'Text', text: /Not undone/ })).toBeFalsy()
809
+ approve()
756
810
  await clock.settle()
811
+ expect(await ui.find({ type: 'Text', text: 'ABCD-1234' })).toBeFalsy()
812
+ expect(await ui.find({ type: 'Text', text: /on GitHub: deleted the repository bzells11\/taskflow/ })).toBeTruthy()
757
813
  expect(asked).toEqual(['Claude created the GitHub repository bzells11/taskflow. Delete it on GitHub too?'])
758
814
  expect(JSON.parse(runs.find((r) => r.argv.includes('gh-undo'))?.stdin ?? '{}')).toEqual(repo)
759
815
  // Missing permission: GitHub's OK is fetched by Backstep itself, then the delete is tried again.
@@ -762,7 +818,7 @@ test('a GitHub repository Claude created is offered for deletion after undo, and
762
818
  'gh-auth delete_repo',
763
819
  'gh-undo',
764
820
  ])
765
- expect(logs.find((l) => l.includes('opened the approval page and copied the code'))).toBeTruthy()
821
+ expect(logs.find((l) => l.includes('paste the code ABCD-1234 on the GitHub page'))).toBeTruthy()
766
822
  expect(logs.find((l) => l.includes('On GitHub: deleted the repository'))).toBeTruthy()
767
823
  // The repository is gone, so its push isn't asked about.
768
824
  expect(runs.some((r) => r.argv.includes('revert-push'))).toBe(false)
@@ -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 { Rewind, RewindItem, Turn } from '../types'
10
+ import type { GithubCode, Rewind, RewindItem, Turn } 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)
@@ -16,6 +16,8 @@ const armed = atom({ plugin: 'backstep', key: 'armed' } as const, null as number
16
16
  // The keys bound to undo, read as a session starts and when the person changes
17
17
  // them. Kept in state, not a variable, so the band redraws with the new key.
18
18
  const keys = atom({ plugin: 'backstep', key: 'keys' } as const, ['ctrl+u'] as string[])
19
+ // The one-time code GitHub's approval page wants, on the band while GitHub waits.
20
+ const githubCode = atom({ plugin: 'backstep', key: 'githubCode' } as const, null as GithubCode | null)
19
21
  // Backstep's mark, as on the logo: the front rewind triangle in the terminal's
20
22
  // own black or white, the one behind it in the logo's pink.
21
23
  const PINK = '#ee7bb6'
@@ -80,6 +82,39 @@ async function jsonRun($: EngineInterface, args: string[], timeoutMs: number, st
80
82
 
81
83
  const ghUndo = ($: EngineInterface, g: GhMade) => jsonRun($, ['gh-undo'], 120_000, JSON.stringify(g))
82
84
 
85
+ // Gets GitHub's OK for one more gh permission. The CLI prints the one-time code
86
+ // on a line of its own as soon as GitHub gives it, so the band shows it while
87
+ // GitHub waits; the answer is its last line.
88
+ async function ghAuth($: EngineInterface, scope: string, what: string): Promise<{ ok: boolean; detail: string }> {
89
+ let out = ''
90
+ try {
91
+ const child = $.process.spawn({ argv: backstep(['gh-auth', scope]) })
92
+ for await (const { stream, text } of child) {
93
+ if (stream !== 'stdout') continue
94
+ out += text
95
+ const code = out.match(/"code":"([A-Z0-9]{4}-[A-Z0-9]{4})"/)?.[1]
96
+ if (code && !(await read($, githubCode))) {
97
+ await update($, githubCode, () => ({ code, what }))
98
+ $.ui.log(`${MARK_TEXT} One more step to ${what}: paste the code ${code} on the GitHub page that just opened, then press Authorize. (It's already copied, so ⌘V / Ctrl+V works.)`)
99
+ }
100
+ }
101
+ } catch (err) {
102
+ out += `\n${JSON.stringify({ ok: false, detail: err instanceof Error ? err.message : String(err) })}`
103
+ } finally {
104
+ await update($, githubCode, () => null)
105
+ }
106
+ try {
107
+ return JSON.parse(out.trim().split('\n').at(-1) ?? '') as { ok: boolean; detail: string }
108
+ } catch {
109
+ return { ok: false, detail: "GitHub didn't answer" }
110
+ }
111
+ }
112
+
113
+ // Adds a line to the band's list once something offered after the rewind is settled.
114
+ async function addItem($: EngineInterface, item: RewindItem) {
115
+ await update($, rewind, (r) => (r ? { ...r, items: [...r.items, item], shown: r.shown + 1 } : r))
116
+ }
117
+
83
118
  // Asks, for each thing Claude made on GitHub in the undone turns, whether to take it away there too.
84
119
  // Returns the repositories deleted, whose pushes then need no undo of their own.
85
120
  async function offerGithubUndo($: EngineInterface, outcomes: Outcome[]): Promise<Set<string>> {
@@ -96,12 +131,12 @@ async function offerGithubUndo($: EngineInterface, outcomes: Outcome[]): Promise
96
131
  let r = await ghUndo($, g)
97
132
  // gh's sign-in needs one more permission: get it on GitHub's own page, then try again.
98
133
  if (!r.ok && r.needs) {
99
- $.ui.toast("GitHub needs your OK first. Backstep opened GitHub in your browser and copied the code: paste it there and press Authorize.", { timeoutMs: 15_000 })
100
- $.ui.log(`${MARK_TEXT} GitHub needs your OK first: Backstep opened the approval page and copied the code to your clipboard. Paste it and press Authorize.`)
101
- const granted = await jsonRun($, ['gh-auth', r.needs], 600_000)
134
+ $.ui.toast('One more step: paste the code shown above the prompt on the GitHub page that just opened, then press Authorize.', { timeoutMs: 15_000 })
135
+ const granted = await ghAuth($, r.needs, `${yes === 'Close it' ? 'close' : 'delete'} ${describeGh(g)}`)
102
136
  r = granted.ok ? await ghUndo($, g) : { ok: false, detail: `GitHub didn't give the OK (${granted.detail}), so ${describeGh(g)} is still there` }
103
137
  }
104
138
  if (r.ok && g.kind === 'repo') gone.add(g.repo)
139
+ await addItem($, r.ok ? { label: `on GitHub: ${r.detail}`, ok: true } : { label: `on GitHub: ${r.detail}`, ok: false })
105
140
  $.ui.log(r.ok ? `${MARK_TEXT} On GitHub: ${r.detail}.` : `${MARK_TEXT} Not undone on GitHub: ${r.detail}`)
106
141
  $.ui.toast(r.ok ? `Backstep ${r.detail}.` : `Backstep ${r.detail}`)
107
142
  }
@@ -148,6 +183,19 @@ async function offerPushUndo($: EngineInterface, outcomes: Outcome[], repoGone =
148
183
  // The first press asks, right there above the prompt; a second press within a
149
184
  // few seconds undoes. A dialog would take the band off screen, and with it the
150
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
+
151
199
  async function pressUndo($: EngineInterface) {
152
200
  if (await read($, rewind)) return
153
201
  const target = (await read($, turns)).findLast(hasWork)
@@ -183,7 +231,7 @@ async function undoLastTurn($: EngineInterface, count = 1) {
183
231
  const cwd = await $.session.cwd()
184
232
  const short = (p: string) => (p.startsWith(cwd + '/') ? p.slice(cwd.length + 1) : p)
185
233
  const [ran] = await Promise.all([
186
- $.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) => ({
187
235
  exitCode: 1,
188
236
  stdout: '',
189
237
  stderr: err instanceof Error ? err.message : String(err),
@@ -237,9 +285,11 @@ async function undoLastTurn($: EngineInterface, count = 1) {
237
285
  // A push, or something made on GitHub, is offered back below, so it isn't listed as lost.
238
286
  const offered = outcomes.some((o) => o.ok && o.pushed)
239
287
  const offeredGh = outcomes.some((o) => o.ok && o.github?.length)
288
+ // Deleting the repository takes whatever was pushed to it along.
289
+ const offeredRepo = outcomes.some((o) => o.ok && o.github?.some((g) => g.kind === 'repo'))
240
290
  const kept = taken
241
291
  .flatMap((t) => t.lostItems ?? [])
242
- .filter((l) => !(offered && /pushes to a remote/.test(l)) && !(offeredGh && /changes GitHub/.test(l)))
292
+ .filter((l) => !((offered || offeredRepo) && /pushes to a remote/.test(l)) && !(offeredGh && /changes GitHub/.test(l)))
243
293
  .map((label) => ({ label, ok: false }))
244
294
  const items: RewindItem[] = [...undone, ...stopped, ...kept]
245
295
  const done = undone.length
@@ -675,9 +725,11 @@ export const register: Register = (on, options) => {
675
725
  if (EDIT_TOOLS.has(tool)) {
676
726
  const input = e as { file_path?: string; notebook_path?: string }
677
727
  const path = input.file_path ?? input.notebook_path
678
- 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)) {
679
732
  const t = await current($)
680
- const cwd = await $.session.cwd()
681
733
  const sid = await $.session.id()
682
734
  // A failed snapshot never blocks the edit; it just won't be undoable.
683
735
  const snap = await $.process
@@ -741,20 +793,44 @@ export const register: Register = (on, options) => {
741
793
  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
742
794
  if (e.props.hasSurvey) return next(e)
743
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
+ )
744
804
  const r = await read($, rewind)
745
805
 
746
806
  if (r?.phase === 'running') {
747
807
  return (
748
808
  <Box>
749
- <Text bold>◀</Text>
750
- <Text color={PINK} bold>◀</Text>
751
- <Text bold> Rewinding </Text>
809
+ {mark()}
810
+ <Text bold>Rewinding </Text>
752
811
  <Text>{bar(r.frame)}</Text>
753
812
  <Text dimColor> undoing the last turn…</Text>
754
813
  </Box>
755
814
  )
756
815
  }
757
816
 
817
+ const waiting = await read($, githubCode)
818
+ // GitHub is waiting on the person: the code, big and plain, says this is a step to take, not a failure.
819
+ const step = waiting ? (
820
+ <Box flexDirection="column" marginTop={r ? 1 : 0}>
821
+ <Box>
822
+ {mark()}
823
+ <Text color="warning" bold>{`One more step to ${waiting.what}:`}</Text>
824
+ </Box>
825
+ <Text>{' Paste this code on the GitHub page that just opened, then press Authorize:'}</Text>
826
+ <Box>
827
+ <Text>{' '}</Text>
828
+ <Text color={PINK} bold>{waiting.code}</Text>
829
+ <Text dimColor>{' (already copied: ⌘V / Ctrl+V)'}</Text>
830
+ </Box>
831
+ </Box>
832
+ ) : null
833
+
758
834
  if (r) {
759
835
  const tone = r.phase === 'failed' ? 'error' : r.phase === 'empty' ? 'warning' : undefined
760
836
  const glyph = r.phase === 'failed' ? '✗' : r.phase === 'empty' ? '!' : '✓'
@@ -782,10 +858,13 @@ export const register: Register = (on, options) => {
782
858
  {i.label}
783
859
  </Text>
784
860
  ))}
861
+ {step}
785
862
  </Box>
786
863
  )
787
864
  }
788
865
 
866
+ if (step) return step
867
+
789
868
  const { target, before } = await undoTargets($)
790
869
  const label = (await read($, keys)).map(keyLabel).join(' / ')
791
870
  if (target && (await read($, armed)) !== null) {
@@ -800,9 +879,8 @@ export const register: Register = (on, options) => {
800
879
  return (
801
880
  <Box flexDirection="column">
802
881
  <Box>
803
- <Text bold>◀</Text>
804
- <Text color={PINK} bold>◀</Text>
805
- <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>
806
884
  </Box>
807
885
  <Text dimColor>{` Undo it and the turn before it${quoted(before, 50)}?`}</Text>
808
886
  <Box>
@@ -823,9 +901,8 @@ export const register: Register = (on, options) => {
823
901
  return (
824
902
  <Box flexDirection="column">
825
903
  <Box>
826
- <Text bold>◀</Text>
827
- <Text color={PINK} bold>◀</Text>
828
- <Text color="warning" bold>{" Undo Claude's last turn?"}</Text>
904
+ {mark()}
905
+ <Text color="warning" bold>{"Undo Claude's last turn?"}</Text>
829
906
  </Box>
830
907
  <Text dimColor>{` ${quoted(target, 50).trim() || 'That turn'} · ${summary(target)}`}</Text>
831
908
  <Box>
@@ -845,13 +922,16 @@ export const register: Register = (on, options) => {
845
922
  // with nothing to undo, a press says so rather than doing nothing.
846
923
  return (
847
924
  <Box>
848
- <Text bold>◀</Text>
849
- <Text color={PINK} bold>◀</Text>
850
- <Text> </Text>
851
- <Text dimColor>{target ? `Last turn:${quoted(target, 28)} · ${summary(target)} ` : 'Backstep is on · nothing to undo yet '}</Text>
852
- <Button key="undo" action={UNDO_ACTION} plain dimColor onPress={() => void pressUndo($)}>
853
- {`${label} to undo`}
854
- </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>
855
935
  </Box>
856
936
  )
857
937
  })
@@ -29,10 +29,14 @@ export type Rewind = {
29
29
  message: string
30
30
  }
31
31
 
32
+ // GitHub waiting on the person's OK for one more permission: the one-time code to paste, and what it's for.
33
+ export type GithubCode = { code: string; what: string }
34
+
32
35
  declare module 'claude-code' {
33
36
  interface PluginState {
34
37
  // armed: when the undo key was first pressed; a second press within a few seconds undoes.
35
38
  // keys: the keys bound to undo, kept here so the band redraws when they change.
36
- backstep: { turns: Turn[]; rewind: Rewind | null; note: string | null; armed: number | null; keys: string[] }
39
+ // githubCode: the code GitHub's approval page wants, shown on the band until GitHub answers.
40
+ backstep: { turns: Turn[]; rewind: Rewind | null; note: string | null; armed: number | null; keys: string[]; githubCode: GithubCode | null }
37
41
  }
38
42
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backstep",
3
- "version": "0.1.13",
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",