backstep 0.1.9 → 0.1.13

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.
@@ -19,6 +19,8 @@ const keys = atom({ plugin: 'backstep', key: 'keys' } as const, ['ctrl+u'] as st
19
19
  // Backstep's mark, as on the logo: the front rewind triangle in the terminal's
20
20
  // own black or white, the one behind it in the logo's pink.
21
21
  const PINK = '#ee7bb6'
22
+ // The mark where only plain text goes (a transcript line).
23
+ const MARK_TEXT = '◀◀'
22
24
  // How long the "press again to undo" question waits before it lets go.
23
25
  const CONFIRM_MS = 5000
24
26
 
@@ -55,23 +57,76 @@ type Outcome = {
55
57
  detail: string
56
58
  run?: { server: string; tool: string; args: Record<string, unknown> }
57
59
  pushed?: { root: string; pushes: Pushed[] }
60
+ github?: GhMade[]
58
61
  }
59
62
  const PUSH = /\bgit\b[^;&|]*\bpush\b/
63
+ // A gh command that makes something on GitHub: noted once it runs, so undo can offer to take it away.
64
+ const GH_CREATE = /\bgh\s+(repo|issue|pr|release)\s+create\b/
65
+ type GhMade = { kind: 'repo' | 'issue' | 'pr' | 'release'; repo: string; number?: number; tag?: string; url: string }
66
+ const describeGh = (g: GhMade) =>
67
+ g.kind === 'repo' ? `the GitHub repository ${g.repo}` : g.kind === 'release' ? `the release ${g.tag} in ${g.repo}` : `${g.kind === 'pr' ? 'pull request' : 'issue'} #${g.number} in ${g.repo}`
68
+
69
+ // Runs a Backstep command that answers with { ok, detail }, however it fails.
70
+ async function jsonRun($: EngineInterface, args: string[], timeoutMs: number, stdin?: string): Promise<{ ok: boolean; detail: string; needs?: string }> {
71
+ const ran = await $.process
72
+ .run(backstep(args), { timeoutMs, ...(stdin !== undefined ? { stdin } : {}) })
73
+ .catch((err: unknown) => ({ exitCode: 1, stdout: '', stderr: err instanceof Error ? err.message : String(err) }))
74
+ try {
75
+ return JSON.parse(ran.stdout) as { ok: boolean; detail: string; needs?: string }
76
+ } catch {
77
+ return { ok: false, detail: (ran.stderr || 'it failed').trim().split('\n').at(-1) ?? 'it failed' }
78
+ }
79
+ }
80
+
81
+ const ghUndo = ($: EngineInterface, g: GhMade) => jsonRun($, ['gh-undo'], 120_000, JSON.stringify(g))
82
+
83
+ // Asks, for each thing Claude made on GitHub in the undone turns, whether to take it away there too.
84
+ // Returns the repositories deleted, whose pushes then need no undo of their own.
85
+ async function offerGithubUndo($: EngineInterface, outcomes: Outcome[]): Promise<Set<string>> {
86
+ const gone = new Set<string>()
87
+ for (const g of outcomes.flatMap((o) => (o.ok && o.github ? o.github : []))) {
88
+ const yes = g.kind === 'repo' ? 'Delete it' : g.kind === 'issue' || g.kind === 'pr' ? 'Close it' : 'Delete it'
89
+ const answer = await $.ui
90
+ .ask(`Claude created ${describeGh(g)}. ${yes === 'Close it' ? 'Close' : 'Delete'} it on GitHub too?`, { header: 'Backstep', options: [yes, 'Leave it'] })
91
+ .catch(() => null)
92
+ if (answer !== yes) {
93
+ $.ui.log(`${MARK_TEXT} Not undone on GitHub: ${describeGh(g)} stays (you chose to leave it).`)
94
+ continue
95
+ }
96
+ let r = await ghUndo($, g)
97
+ // gh's sign-in needs one more permission: get it on GitHub's own page, then try again.
98
+ 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)
102
+ r = granted.ok ? await ghUndo($, g) : { ok: false, detail: `GitHub didn't give the OK (${granted.detail}), so ${describeGh(g)} is still there` }
103
+ }
104
+ if (r.ok && g.kind === 'repo') gone.add(g.repo)
105
+ $.ui.log(r.ok ? `${MARK_TEXT} On GitHub: ${r.detail}.` : `${MARK_TEXT} Not undone on GitHub: ${r.detail}`)
106
+ $.ui.toast(r.ok ? `Backstep ${r.detail}.` : `Backstep ${r.detail}`)
107
+ }
108
+ return gone
109
+ }
60
110
 
61
111
  const PUSH_ASK = { yes: 'Push the undo', no: 'Leave it as it is' }
62
112
 
63
113
  // Undo rewound the code here; this asks before taking it back on GitHub too,
64
114
  // as a new commit that undoes Claude's (never a force push).
65
- async function offerPushUndo($: EngineInterface, outcomes: Outcome[]) {
115
+ async function offerPushUndo($: EngineInterface, outcomes: Outcome[], repoGone = false) {
66
116
  for (const o of outcomes) {
67
117
  if (!o.ok || !o.pushed) continue
118
+ // The repository it went to is deleted: nothing left there to undo.
119
+ if (repoGone) continue
68
120
  for (const p of o.pushed.pushes) {
69
121
  const what =
70
122
  p.before === null
71
123
  ? `Claude pushed a new branch, ${p.branch}, to ${p.host}. Delete it there too?`
72
124
  : `Claude pushed ${p.commits === 1 ? '1 commit' : `${p.commits} commits`} to ${p.branch} on ${p.host}. Undo ${p.commits === 1 ? 'it' : 'them'} there too?`
73
125
  const answer = await $.ui.ask(what, { header: 'Backstep', options: [p.before === null ? 'Delete the branch' : PUSH_ASK.yes, PUSH_ASK.no] }).catch(() => null)
74
- if (!answer || answer === PUSH_ASK.no) continue
126
+ if (!answer || answer === PUSH_ASK.no) {
127
+ $.ui.log(`${MARK_TEXT} Not undone on ${p.host}: Claude's ${p.before === null ? `branch ${p.branch}` : p.commits === 1 ? 'commit' : `${p.commits} commits`} stay there (you chose to leave it).`)
128
+ continue
129
+ }
75
130
  const ran = await $.process
76
131
  .run(backstep(['revert-push', '--root', o.pushed.root]), { stdin: JSON.stringify(p), timeoutMs: 180_000 })
77
132
  .catch((err: unknown) => ({ exitCode: 1, stdout: '', stderr: err instanceof Error ? err.message : String(err) }))
@@ -82,6 +137,7 @@ async function offerPushUndo($: EngineInterface, outcomes: Outcome[]) {
82
137
  // keep the error above
83
138
  }
84
139
  $.ui.toast(r.ok ? `Backstep ${r.detail}.` : `Backstep ${r.detail}. Nothing was changed on ${p.host}.`)
140
+ $.ui.log(r.ok ? `${MARK_TEXT} On ${p.host}: ${r.detail}.` : `${MARK_TEXT} Not undone on ${p.host}: ${r.detail}. Nothing was changed there.`)
85
141
  await update($, note, (n) =>
86
142
  n ? `${n}\n${r.ok ? `On ${p.host}: ${r.detail}.` : `${p.host} was left as it was: ${r.detail}.`}` : n,
87
143
  )
@@ -103,7 +159,8 @@ async function pressUndo($: EngineInterface) {
103
159
  const now = await $.clock.now()
104
160
  if (at !== null && now - at < CONFIRM_MS) {
105
161
  await update($, armed, () => null)
106
- return undoLastTurn($)
162
+ const { before } = await undoTargets($)
163
+ return undoLastTurn($, before ? 2 : 1)
107
164
  }
108
165
  await update($, armed, () => now)
109
166
  $.clock.after(CONFIRM_MS, () => {
@@ -111,9 +168,11 @@ async function pressUndo($: EngineInterface) {
111
168
  })
112
169
  }
113
170
 
114
- async function undoLastTurn($: EngineInterface) {
171
+ // Undoes the last `count` turns that changed something, newest first.
172
+ async function undoLastTurn($: EngineInterface, count = 1) {
115
173
  if (await read($, rewind)) return
116
- const target = (await read($, turns)).findLast(hasWork)
174
+ const taken = (await read($, turns)).filter(hasWork).slice(-count)
175
+ const target = taken[0]
117
176
  if (!target) return
118
177
 
119
178
  await update($, rewind, (): Rewind => ({ phase: 'running', frame: 0, shown: 0, items: [], message: '' }))
@@ -165,17 +224,29 @@ async function undoLastTurn($: EngineInterface) {
165
224
  const cut = outcomes.findIndex((o) => !o.ok)
166
225
  if (cut !== -1) outcomes = outcomes.slice(0, cut + 1)
167
226
 
168
- const items: RewindItem[] = outcomes.map((o) => {
169
- const first = o.detail.split('\n')[0] ?? ''
170
- const what = first.replace(/^(restored|removed) (.*)$/, (_, verb: string, p: string) => `${short(p)} ${verb}`)
171
- return { label: o.ok ? what || o.tool : `${o.tool}: ${first}`, ok: o.ok }
172
- })
173
- const done = items.filter((i) => i.ok).length
174
- const failed = items.some((i) => !i.ok)
175
- // A push counted as lost when it ran, but undo can now offer to take it back there.
176
- const offered = outcomes.filter((o) => o.ok && o.pushed).length
177
- const lost = Math.max(0, (target.lost ?? 0) - offered)
178
- const shell = lost ? ` ${plural(lost, 'action')} that reached outside couldn't be undone.` : ''
227
+ // Every change undone, one per line, and every one that wasn't, with why.
228
+ const undone: RewindItem[] = outcomes
229
+ .filter((o) => o.ok)
230
+ .flatMap((o) =>
231
+ (o.detail || o.tool)
232
+ .split('\n')
233
+ .filter(Boolean)
234
+ .map((line) => ({ label: line.replace(/^(restored|removed) (.*)$/, (_, verb: string, p: string) => `${short(p)} ${verb}`), ok: true })),
235
+ )
236
+ const stopped = outcomes.filter((o) => !o.ok).map((o) => ({ label: `${o.tool}: ${o.detail.split('\n')[0] ?? 'failed'}`, ok: false }))
237
+ // A push, or something made on GitHub, is offered back below, so it isn't listed as lost.
238
+ const offered = outcomes.some((o) => o.ok && o.pushed)
239
+ const offeredGh = outcomes.some((o) => o.ok && o.github?.length)
240
+ const kept = taken
241
+ .flatMap((t) => t.lostItems ?? [])
242
+ .filter((l) => !(offered && /pushes to a remote/.test(l)) && !(offeredGh && /changes GitHub/.test(l)))
243
+ .map((label) => ({ label, ok: false }))
244
+ const items: RewindItem[] = [...undone, ...stopped, ...kept]
245
+ const done = undone.length
246
+ const failed = stopped.length > 0
247
+ const lost = kept.length
248
+ const which = taken.length > 1 ? `the last ${taken.length} turns` : 'the last turn'
249
+ const shell = lost ? ` ${plural(lost, 'thing')} couldn't be undone: listed below.` : ''
179
250
 
180
251
  await update($, rewind, (): Rewind => ({
181
252
  phase: failed ? 'failed' : items.length ? 'done' : 'empty',
@@ -185,32 +256,38 @@ async function undoLastTurn($: EngineInterface) {
185
256
  message: failed
186
257
  ? `Stopped after ${plural(done, 'change')}: the next one couldn't be undone.`
187
258
  : items.length
188
- ? `Rewound ${plural(done, 'change')} from the last turn.${shell}`
189
- : `Nothing in the last turn could be undone.${shell}`,
259
+ ? `Rewound ${plural(done, 'change')} from ${which}.${shell}`
260
+ : `Nothing in ${which} could be undone.${shell}`,
190
261
  }))
191
- await bump($, target.id, (t) => ({ ...t, undone: !failed }))
262
+ for (const t of taken) await bump($, t.id, (x) => ({ ...x, undone: !failed }))
263
+
264
+ // The whole account, kept in the conversation, where the band's few rows fold away.
265
+ const report = [
266
+ `${MARK_TEXT} Backstep undid ${plural(done, 'change')} from ${which}${taken.length === 1 ? named(target) : ''}${done ? ':' : '.'}`,
267
+ ...undone.map((i) => ` ◀ ${i.label}`),
268
+ ...(stopped.length || kept.length ? ["Not undone:", ...[...stopped, ...kept].map((i) => ` ✗ ${i.label}`)] : []),
269
+ ]
270
+ $.ui.log(report.join('\n'))
192
271
 
193
272
  if (done) {
194
273
  const list = items.filter((i) => i.ok).map((i) => `- ${i.label}`).join('\n')
195
274
  const key = await keysLabel($)
196
275
  await update($, note, () =>
197
- `[Backstep] The user pressed undo (${key}) on your previous turn. These changes were reverted:\n${list}\n` +
276
+ `[Backstep] The user pressed undo (${key}) on your ${taken.length > 1 ? `last ${taken.length} turns` : 'previous turn'}. These changes were reverted:\n${list}\n` +
198
277
  (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` : '') +
199
278
  `Treat them as not having happened, re-read files before editing them, and don't redo them unless asked.`,
200
279
  )
201
280
  }
202
281
 
203
- // Asked once the band shows what came back.
204
- void offerPushUndo($, outcomes)
282
+ // Asked once the band shows what came back: GitHub things first, then pushes.
283
+ void offerGithubUndo($, outcomes).then((gone) => offerPushUndo($, outcomes, gone.size > 0))
205
284
 
206
- // Reveal the restored items one by one, then fold the band away.
285
+ // Reveal the items one by one. The list stays until the person's next
286
+ // message (it's in the conversation too), so nothing is missed.
207
287
  const reveal = $.clock.every(90, () => {
208
288
  void update($, rewind, (r) => (r && r.shown < r.items.length ? { ...r, shown: r.shown + 1 } : r))
209
289
  })
210
- $.clock.after(failed ? 9000 : 4200, () => {
211
- reveal.cancel()
212
- void update($, rewind, () => null)
213
- })
290
+ $.clock.after(90 * (items.length + 2), () => reveal.cancel())
214
291
  }
215
292
 
216
293
  type Next = (e: Record<string, unknown>) => Promise<{ deny?: string; isError?: true; result?: unknown; text?: string }>
@@ -257,7 +334,7 @@ async function watchMcp($: EngineInterface, e: Record<string, unknown>, next: Ne
257
334
  hint($, plan)
258
335
  // Behind Backstep, or Backstep couldn't be reached: count it as before.
259
336
  if (!plan || plan.kind === 'wrapped' || !plan.id) {
260
- if (plan?.kind !== 'read') await bump($, t.id, (x) => ({ ...x, mcp: x.mcp + 1 }))
337
+ if (plan?.kind !== 'read') await bump($, t.id, (x) => ({ ...x, gitOnly: false, mcp: x.mcp + 1 }))
261
338
  return next(e)
262
339
  }
263
340
  const r = await next(plan.args ? { ...e, ...plan.args } : e)
@@ -274,7 +351,9 @@ async function watchMcp($: EngineInterface, e: Record<string, unknown>, next: Ne
274
351
  } catch {
275
352
  undoable = false
276
353
  }
277
- if (!r.isError) await bump($, t.id, (x) => (undoable ? { ...x, mcp: x.mcp + 1 } : { ...x, lost: (x.lost ?? 0) + 1 }))
354
+ if (!r.isError) {
355
+ await bump($, t.id, (x) => (undoable ? { ...x, gitOnly: false, mcp: x.mcp + 1 } : lostOne({ ...x, gitOnly: false }, `${tool.replace(/^mcp__/, '').replace('__', ' ')}: ${plan.reason ?? 'there is no way to undo it'}`)))
356
+ }
278
357
  return r
279
358
  }
280
359
 
@@ -289,8 +368,41 @@ function backstep(args: string[]): string[] {
289
368
  return cli === 'backstep' ? ['backstep', ...args] : ['node', '--disable-warning=ExperimentalWarning', cli, ...args]
290
369
  }
291
370
 
292
- function newTurn(startedAt: number): Turn {
293
- return { id: `t${startedAt}`, startedAt, edits: 0, files: [], mcp: 0, shell: 0, lost: 0, undone: false }
371
+ function newTurn(startedAt: number, prompt?: string): Turn {
372
+ return { id: `t${startedAt}`, startedAt, edits: 0, files: [], mcp: 0, shell: 0, lost: 0, undone: false, ...(prompt ? { prompt } : {}) }
373
+ }
374
+
375
+ // A command as it reads in a list: without its leading `cd`, cut short.
376
+ const brief = (command: string) => {
377
+ const c = command.replace(/^\s*cd\s+(?:"[^"]*"|'[^']*'|\S+)\s*&&\s*/, '').replace(/\s+/g, ' ').trim()
378
+ return c.length > 60 ? `${c.slice(0, 59)}…` : c
379
+ }
380
+
381
+ // Something in a turn that can't be undone, named, so undo can list it.
382
+ const lostOne = (x: Turn, what: string): Turn => ({ ...x, lost: (x.lost ?? 0) + 1, lostItems: [...(x.lostItems ?? []), what].slice(-20) })
383
+
384
+ // A turn named by what the person asked: "commit and push it", cut to `max`.
385
+ const quoted = (t: Turn, max = 40) => {
386
+ const p = (t.prompt ?? '').replace(/\s+/g, ' ').trim()
387
+ return p ? ` "${p.length > max ? `${p.slice(0, max - 1)}…` : p}"` : ''
388
+ }
389
+ const named = (t: Turn) => (t.prompt ? ` (${quoted(t).trim()})` : '')
390
+
391
+ // Each part only saves to git (add, commit, push and friends), or only moves around.
392
+ const GIT_SAVE = /^(git\s+(add|commit|push|status|log|diff|show|branch|remote|fetch|rev-parse|config)\b|cd\s|echo\s|true$)/
393
+ const onlySavesToGit = (command: string) =>
394
+ command
395
+ .split(/&&|\|\||;|\n/)
396
+ .map((p) => p.trim())
397
+ .filter(Boolean)
398
+ .every((p) => GIT_SAVE.test(p))
399
+
400
+ // What one press would take back: the last turn, and, when that turn only
401
+ // committed and pushed, the turn whose work it saved.
402
+ async function undoTargets($: EngineInterface): Promise<{ target: Turn | undefined; before: Turn | undefined }> {
403
+ const list = (await read($, turns)).filter(hasWork)
404
+ const target = list.at(-1)
405
+ return { target, before: target?.gitOnly ? list.at(-2) : undefined }
294
406
  }
295
407
 
296
408
  // The turn tool calls belong to; one opens if none is (a call before any prompt).
@@ -332,7 +444,7 @@ async function checkCoverage($: EngineInterface, cwd: string) {
332
444
  const found = await $.process.run(backstep(['connect', '--dir', cwd, '--json', '--auto']), { timeoutMs: 120_000 }).catch(() => null)
333
445
  try {
334
446
  for (const c of JSON.parse(found?.stdout || '[]') as Connected[]) {
335
- if (c.service === 'supabase' && c.state === 'ask') await askSupabase($, c.dir ?? cwd)
447
+ if (c.state === 'ask') await askDatabase($, c.dir ?? cwd, c.service)
336
448
  if (c.state === 'added') $.ui.toast('Backstep added Firebase for this project, so Claude can reach it and its changes can be undone. Restart Claude Code to use it.')
337
449
  }
338
450
  } catch {
@@ -360,36 +472,45 @@ const ASK = {
360
472
  // Projects asked about this session: "Not now" means not again until the next one.
361
473
  const asked = new Set<string>()
362
474
 
363
- // Asked when Claude Code opens a project that uses Supabase, or Claude is about
364
- // to change one, and Backstep isn't connected to it; the answer is the only
365
- // thing that connects it.
366
- async function askSupabase($: EngineInterface, cwd: string) {
367
- if (asked.has(cwd)) return
368
- asked.add(cwd)
475
+ // How each database is named in the question, and what the answer connects.
476
+ const DB_NAME: Record<string, string> = { supabase: 'Supabase', postgres: 'a Postgres database', firebase: 'Firebase' }
477
+
478
+ // Asked when Claude Code opens a project that uses a database (Supabase, other
479
+ // Postgres, Firebase), or Claude is about to change one, and Backstep isn't
480
+ // connected to it; the answer is the only thing that connects it.
481
+ async function askDatabase($: EngineInterface, cwd: string, service = 'supabase') {
482
+ const key = `${service}:${cwd}`
483
+ if (asked.has(key)) return
484
+ asked.add(key)
485
+ const name = DB_NAME[service] ?? service
369
486
  const answer = await $.ui
370
- .ask("This project uses Supabase, but Backstep isn't connected to it, so Claude's changes to its database can't be undone. Connect it?", {
487
+ .ask(`This project uses ${name}, but Backstep isn't connected to it, so Claude's changes to it can't be undone. Connect it?`, {
371
488
  header: 'Backstep',
372
489
  options: [ASK.connect, ASK.later, ASK.never],
373
490
  })
374
491
  .catch(() => null)
375
492
  if (answer === ASK.never) {
376
- await $.process.run(backstep(['connect', 'supabase', '--dir', cwd, '--dismiss'])).catch(() => null)
493
+ await $.process.run(backstep(['connect', service, '--dir', cwd, '--dismiss'])).catch(() => null)
377
494
  return
378
495
  }
379
496
  if (answer !== ASK.connect) return
380
- const ran = await $.process.run(backstep(['connect', 'supabase', '--dir', cwd, '--json']), { timeoutMs: 120_000 }).catch(() => null)
381
- let s: Connected | undefined
497
+ const ran = await $.process.run(backstep(['connect', service, '--dir', cwd, '--json']), { timeoutMs: 120_000 }).catch(() => null)
498
+ let all: Connected[] = []
382
499
  try {
383
- s = (JSON.parse(ran?.stdout || '[]') as Connected[])[0]
500
+ all = JSON.parse(ran?.stdout || '[]') as Connected[]
384
501
  } catch {
385
- s = undefined
502
+ all = []
386
503
  }
387
- if (s?.state === 'connected' || s?.state === 'covered') {
388
- $.ui.toast(`Backstep ${s.detail}. Claude's changes to its database can now be undone.`)
504
+ const s = all.find((c) => c.state === 'connected' || c.state === 'covered') ?? all.find((c) => c.state === 'needs')
505
+ if (s && (s.state === 'connected' || s.state === 'covered')) {
506
+ $.ui.toast(`Backstep ${s.detail}.`)
507
+ $.ui.log(`${MARK_TEXT} Backstep ${s.detail}.`)
389
508
  return
390
509
  }
391
- // Nothing in the project to connect with: a sign-in is needed, and Claude walks the person through it.
392
- $.ui.toast('Backstep needs a Supabase sign-in for this. Ask Claude to "connect Backstep to Supabase" and it will walk you through it.')
510
+ // What's still needed, said where it stays.
511
+ const need = s?.detail ?? `ask Claude to "connect Backstep to ${service}"`
512
+ $.ui.log(`${MARK_TEXT} Not connected to ${name} yet: ${need}`)
513
+ $.ui.toast(service === 'supabase' ? 'Backstep needs a Supabase sign-in for this. Ask Claude to "connect Backstep to Supabase" and it will walk you through it.' : `Backstep isn't connected to ${name} yet: ${need}`)
393
514
  }
394
515
 
395
516
  const UPDATE = { yes: 'Update', later: 'Not now', skip: 'Skip this version' }
@@ -534,7 +655,10 @@ export const register: Register = (on, options) => {
534
655
  on('prompt.submit', async ($, e, next) => {
535
656
  if (e.command || e.isMeta || (e.origin.kind !== 'composer' && e.origin.kind !== 'bridge')) return next(e)
536
657
  const startedAt = await $.clock.now()
537
- await update($, turns, (l) => [...(l ?? []), newTurn(startedAt)].slice(-KEEP_TURNS))
658
+ // The last undo's list has been seen: the band goes back to the undo line.
659
+ const shown = await read($, rewind)
660
+ if (shown && shown.phase !== 'running') await update($, rewind, () => null)
661
+ await update($, turns, (l) => [...(l ?? []), newTurn(startedAt, e.text)].slice(-KEEP_TURNS))
538
662
  const pending = await read($, note)
539
663
  if (!pending) return next(e)
540
664
  await update($, note, () => null)
@@ -560,7 +684,9 @@ export const register: Register = (on, options) => {
560
684
  .run(backstep(['snap', '--session', sid, '--tool', tool, '--cwd', cwd, '--', path]))
561
685
  .catch(() => null)
562
686
  if (snap?.exitCode === 0) {
563
- await bump($, t.id, (x) => ({ ...x, edits: x.edits + 1, files: x.files.includes(path) ? x.files : [...x.files, path] }))
687
+ await bump($, t.id, (x) => ({ ...x, gitOnly: false, edits: x.edits + 1, files: x.files.includes(path) ? x.files : [...x.files, path] }))
688
+ } else {
689
+ await bump($, t.id, (x) => lostOne({ ...x, gitOnly: false }, `edit to ${path.split('/').pop()}: Backstep couldn't save a copy first`))
564
690
  }
565
691
  }
566
692
  } else if (tool === 'Bash') {
@@ -572,8 +698,8 @@ export const register: Register = (on, options) => {
572
698
  // so it can be saved before the command runs.
573
699
  const checked = await $.process.run(backstep(['db-check', '--cwd', cwd, '--', command])).catch(() => null)
574
700
  try {
575
- const ask = (JSON.parse(checked?.stdout || '{}') as { ask?: string | null }).ask
576
- if (ask) await askSupabase($, ask)
701
+ const { ask, service } = JSON.parse(checked?.stdout || '{}') as { ask?: string | null; service?: string | null }
702
+ if (ask) await askDatabase($, ask, service ?? 'supabase')
577
703
  } catch {
578
704
  // can't tell: run it as before
579
705
  }
@@ -590,14 +716,20 @@ export const register: Register = (on, options) => {
590
716
  }
591
717
  if (plan?.kind === 'outbound') {
592
718
  // What it changed here comes back with the project snapshot; what it sent out doesn't (a push is offered back after undo).
593
- await bump($, t.id, (x) => ({ ...x, shell: (x.shell ?? 0) + (plan.undoable ? 1 : 0), lost: (x.lost ?? 0) + 1 }))
719
+ const git = onlySavesToGit(command)
720
+ await bump($, t.id, (x) =>
721
+ lostOne({ ...x, gitOnly: x.gitOnly !== false && git, shell: (x.shell ?? 0) + (plan.undoable ? 1 : 0) }, `${brief(command)}: ${plan.reason ?? 'it reached outside this computer'}`),
722
+ )
594
723
  } else if (plan?.kind === 'write') {
595
- await bump($, t.id, (x) => (plan.undoable ? { ...x, shell: (x.shell ?? 0) + 1 } : { ...x, lost: (x.lost ?? 0) + 1 }))
724
+ const git = onlySavesToGit(command)
725
+ await bump($, t.id, (x) =>
726
+ plan.undoable ? { ...x, gitOnly: x.gitOnly !== false && git, shell: (x.shell ?? 0) + 1 } : lostOne({ ...x, gitOnly: false }, `${brief(command)}: ${plan.reason ?? "Backstep couldn't save a copy first"}`),
727
+ )
596
728
  }
597
729
  // A push: once it's run, note what it sent, so undo can offer to take it back there.
598
- if (plan?.id && PUSH.test(command)) {
730
+ if (plan?.id && (PUSH.test(command) || GH_CREATE.test(command))) {
599
731
  const r = await next(e)
600
- await $.process.run(backstep(['shell-end', plan.id])).catch(() => null)
732
+ await $.process.run(backstep(['shell-end', plan.id]), { stdin: r.text ?? '' }).catch(() => null)
601
733
  return r
602
734
  }
603
735
  } else if (tool.startsWith('mcp__')) {
@@ -626,41 +758,84 @@ export const register: Register = (on, options) => {
626
758
  if (r) {
627
759
  const tone = r.phase === 'failed' ? 'error' : r.phase === 'empty' ? 'warning' : undefined
628
760
  const glyph = r.phase === 'failed' ? '✗' : r.phase === 'empty' ? '!' : '✓'
629
- const visible = r.items.slice(0, Math.min(r.shown, 6))
630
- const more = r.items.length - 6
761
+ // What came back (the first few; the conversation has them all), then everything that didn't.
762
+ const shown = r.items.slice(0, r.shown)
763
+ const back = shown.filter((i) => i.ok)
764
+ const kept = shown.filter((i) => !i.ok)
765
+ const more = r.items.filter((i) => i.ok).length - 5
631
766
  return (
632
767
  <Box flexDirection="column">
633
768
  <Text color={tone} bold>
634
769
  {glyph} {r.message}
635
770
  </Text>
636
- {visible.map((i, n) => (
637
- <Text key={`i${n}`} color={i.ok ? undefined : 'error'} dimColor={i.ok}>
638
- {' '}
639
- {i.ok ? '◀ ' : '✗ '}
771
+ {back.slice(0, 5).map((i, n) => (
772
+ <Text key={`b${n}`} dimColor>
773
+ {' ◀ '}
774
+ {i.label}
775
+ </Text>
776
+ ))}
777
+ {back.length >= 5 && more > 0 ? <Text dimColor>{` and ${more} more (all listed in the conversation)`}</Text> : null}
778
+ {kept.length ? <Text bold>{' Not undone:'}</Text> : null}
779
+ {kept.slice(0, 6).map((i, n) => (
780
+ <Text key={`k${n}`} color="error">
781
+ {' ✗ '}
640
782
  {i.label}
641
783
  </Text>
642
784
  ))}
643
- {r.shown >= 6 && more > 0 ? <Text dimColor>{` and ${more} more`}</Text> : null}
644
785
  </Box>
645
786
  )
646
787
  }
647
788
 
648
- const target = (await read($, turns)).findLast(hasWork)
789
+ const { target, before } = await undoTargets($)
649
790
  const label = (await read($, keys)).map(keyLabel).join(' / ')
650
791
  if (target && (await read($, armed)) !== null) {
792
+ // Laid out a line at a time, so nothing gets squeezed into columns on a narrow terminal.
793
+ const cancel = (
794
+ <Button key="cancel" plain dimColor onPress={() => void update($, armed, () => null)}>
795
+ cancel
796
+ </Button>
797
+ )
798
+ // The last turn only committed and pushed: the work it saved is what the person most likely wants back.
799
+ if (before) {
800
+ return (
801
+ <Box flexDirection="column">
802
+ <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>
806
+ </Box>
807
+ <Text dimColor>{` Undo it and the turn before it${quoted(before, 50)}?`}</Text>
808
+ <Box>
809
+ <Text>{' '}</Text>
810
+ <Button key="undo" action={UNDO_ACTION} plain onPress={() => void pressUndo($)}>
811
+ {`${label} again to undo both`}
812
+ </Button>
813
+ <Text dimColor>{' · '}</Text>
814
+ <Button key="just" plain dimColor onPress={() => void update($, armed, () => null).then(() => undoLastTurn($, 1))}>
815
+ just the push
816
+ </Button>
817
+ <Text dimColor>{' · '}</Text>
818
+ {cancel}
819
+ </Box>
820
+ </Box>
821
+ )
822
+ }
651
823
  return (
652
- <Box>
653
- <Text bold>◀</Text>
654
- <Text color={PINK} bold>◀</Text>
655
- <Text color="warning" bold> Undo Claude's last turn? </Text>
656
- <Text dimColor>{summary(target)} </Text>
657
- <Button key="undo" action={UNDO_ACTION} plain onPress={() => void pressUndo($)}>
658
- {`${label} again to undo`}
659
- </Button>
660
- <Text dimColor> · </Text>
661
- <Button key="cancel" plain dimColor onPress={() => void update($, armed, () => null)}>
662
- cancel
663
- </Button>
824
+ <Box flexDirection="column">
825
+ <Box>
826
+ <Text bold>◀</Text>
827
+ <Text color={PINK} bold>◀</Text>
828
+ <Text color="warning" bold>{" Undo Claude's last turn?"}</Text>
829
+ </Box>
830
+ <Text dimColor>{` ${quoted(target, 50).trim() || 'That turn'} · ${summary(target)}`}</Text>
831
+ <Box>
832
+ <Text>{' '}</Text>
833
+ <Button key="undo" action={UNDO_ACTION} plain onPress={() => void pressUndo($)}>
834
+ {`${label} again to undo`}
835
+ </Button>
836
+ <Text dimColor>{' · '}</Text>
837
+ {cancel}
838
+ </Box>
664
839
  </Box>
665
840
  )
666
841
  }
@@ -673,7 +848,7 @@ export const register: Register = (on, options) => {
673
848
  <Text bold>◀</Text>
674
849
  <Text color={PINK} bold>◀</Text>
675
850
  <Text> </Text>
676
- <Text dimColor>{target ? `Last turn: ${summary(target)} ` : 'Backstep is on · nothing to undo yet '}</Text>
851
+ <Text dimColor>{target ? `Last turn:${quoted(target, 28)} · ${summary(target)} ` : 'Backstep is on · nothing to undo yet '}</Text>
677
852
  <Button key="undo" action={UNDO_ACTION} plain dimColor onPress={() => void pressUndo($)}>
678
853
  {`${label} to undo`}
679
854
  </Button>
@@ -10,6 +10,12 @@ export type Turn = {
10
10
  // Shell commands and tool calls whose effects can't be taken back: they reached outside the machine with no undo rule, or the project was too big to snapshot.
11
11
  lost: number
12
12
  undone: boolean
13
+ // What the person asked for, so undo can name the turn it would take back.
14
+ prompt?: string
15
+ // Every change was a git commit or push (saving the turn before's work), so undo offers to take both.
16
+ gitOnly?: boolean
17
+ // What in this turn can't be undone, and why, so undo can say so by name.
18
+ lostItems?: string[]
13
19
  }
14
20
 
15
21
  export type RewindItem = { label: string; ok: boolean }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backstep",
3
- "version": "0.1.9",
3
+ "version": "0.1.13",
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",