backstep 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,529 @@
1
+ // Backstep for Claude Code: snapshot every file Claude is about to change,
2
+ // and the whole project before each shell command, and rewind its whole last
3
+ // turn with one key (ctrl+z) and a short animation above the prompt. Database
4
+ // and tool changes made through servers wrapped by `backstep wrap`, and
5
+ // Supabase changes over a connected database, rewind with it, by the turn's
6
+ // time window.
7
+ import { atom, read, update } from 'claude-code'
8
+ import type { EngineInterface, Register } from 'claude-code'
9
+
10
+ import type { Rewind, RewindItem, Turn } from '../types'
11
+
12
+ const turns = atom({ plugin: 'backstep', key: 'turns' } as const, [] as Turn[])
13
+ const rewind = atom({ plugin: 'backstep', key: 'rewind' } as const, null as Rewind | null)
14
+ const note = atom({ plugin: 'backstep', key: 'note' } as const, null as string | null)
15
+
16
+ // The engine has no keybinding actions of its own for plugins yet, so the undo
17
+ // button borrows one that only the diff panel handles; ~/.claude/keybindings.json
18
+ // binds ctrl+z to it everywhere else.
19
+ const UNDO_ACTION = 'app:cycleDiffBase'
20
+ const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit'])
21
+ const KEEP_TURNS = 30
22
+ const BAR = 26
23
+
24
+ const hasWork = (t: Turn) => !t.undone && t.edits + t.mcp + (t.shell ?? 0) > 0
25
+ const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
26
+
27
+ function summary(t: Turn): string {
28
+ const parts: string[] = []
29
+ if (t.edits) parts.push(`${plural(t.edits, 'edit')} to ${plural(t.files.length, 'file')}`)
30
+ if (t.shell) parts.push(plural(t.shell, 'shell command'))
31
+ if (t.mcp) parts.push(plural(t.mcp, 'tool action'))
32
+ return parts.join(', ')
33
+ }
34
+
35
+ // A rewind bar: a run of arrows sliding right to left, like tape winding back.
36
+ function bar(frame: number): string {
37
+ const head = BAR - 1 - (frame % (BAR + 4))
38
+ return Array.from({ length: BAR }, (_, i) => (i >= head && i < head + 4 ? '◂' : '─')).join('')
39
+ }
40
+
41
+ type Pushed = { remote: string; branch: string; before: string | null; after: string; commits: number; host: string }
42
+ type Outcome = {
43
+ id: string
44
+ tool: string
45
+ ok: boolean
46
+ detail: string
47
+ run?: { server: string; tool: string; args: Record<string, unknown> }
48
+ pushed?: { root: string; pushes: Pushed[] }
49
+ }
50
+ const PUSH = /\bgit\b[^;&|]*\bpush\b/
51
+
52
+ const PUSH_ASK = { yes: 'Push the undo', no: 'Leave it as it is' }
53
+
54
+ // Undo rewound the code here; this asks before taking it back on GitHub too,
55
+ // as a new commit that undoes Claude's (never a force push).
56
+ async function offerPushUndo($: EngineInterface, outcomes: Outcome[]) {
57
+ for (const o of outcomes) {
58
+ if (!o.ok || !o.pushed) continue
59
+ for (const p of o.pushed.pushes) {
60
+ const what =
61
+ p.before === null
62
+ ? `Claude pushed a new branch, ${p.branch}, to ${p.host}. Delete it there too?`
63
+ : `Claude pushed ${p.commits === 1 ? '1 commit' : `${p.commits} commits`} to ${p.branch} on ${p.host}. Undo ${p.commits === 1 ? 'it' : 'them'} there too?`
64
+ const answer = await $.ui.ask(what, { header: 'Backstep', options: [p.before === null ? 'Delete the branch' : PUSH_ASK.yes, PUSH_ASK.no] }).catch(() => null)
65
+ if (!answer || answer === PUSH_ASK.no) continue
66
+ const ran = await $.process
67
+ .run(backstep(['revert-push', '--root', o.pushed.root]), { stdin: JSON.stringify(p), timeoutMs: 180_000 })
68
+ .catch((err: unknown) => ({ exitCode: 1, stdout: '', stderr: err instanceof Error ? err.message : String(err) }))
69
+ let r: { ok: boolean; detail: string } = { ok: false, detail: (ran.stderr || 'it failed').trim().split('\n').at(-1) ?? 'it failed' }
70
+ try {
71
+ r = JSON.parse(ran.stdout) as typeof r
72
+ } catch {
73
+ // keep the error above
74
+ }
75
+ $.ui.toast(r.ok ? `Backstep ${r.detail}.` : `Backstep ${r.detail}. Nothing was changed on ${p.host}.`)
76
+ await update($, note, (n) =>
77
+ n ? `${n}\n${r.ok ? `On ${p.host}: ${r.detail}.` : `${p.host} was left as it was: ${r.detail}.`}` : n,
78
+ )
79
+ }
80
+ }
81
+ }
82
+
83
+ async function undoLastTurn($: EngineInterface) {
84
+ if (await read($, rewind)) return
85
+ const target = (await read($, turns)).findLast(hasWork)
86
+ if (!target) return
87
+
88
+ await update($, rewind, (): Rewind => ({ phase: 'running', frame: 0, shown: 0, items: [], message: '' }))
89
+ const ticker = $.clock.every(55, () => {
90
+ void update($, rewind, (r) => (r && r.phase === 'running' ? { ...r, frame: r.frame + 1 } : r))
91
+ })
92
+
93
+ const cwd = await $.session.cwd()
94
+ const short = (p: string) => (p.startsWith(cwd + '/') ? p.slice(cwd.length + 1) : p)
95
+ const [ran] = await Promise.all([
96
+ $.process.run(backstep(['undo', '--since', String(target.startedAt), '--json', '--via-claude-code'])).catch((err: unknown) => ({
97
+ exitCode: 1,
98
+ stdout: '',
99
+ stderr: err instanceof Error ? err.message : String(err),
100
+ })),
101
+ // Long enough to read as a rewind, short enough to never feel slow.
102
+ $.clock.sleep(700),
103
+ ])
104
+ ticker.cancel()
105
+
106
+ let outcomes: Outcome[] = []
107
+ try {
108
+ outcomes = JSON.parse(ran.stdout || '[]') as Outcome[]
109
+ } catch {
110
+ outcomes = [{ id: '', tool: 'backstep', ok: false, detail: (ran.stderr || ran.stdout).trim().split('\n')[0] ?? 'failed' }]
111
+ }
112
+ if (ran.exitCode !== 0 && outcomes.length === 0) {
113
+ outcomes = [{ id: '', tool: 'backstep', ok: false, detail: ran.stderr.trim().split('\n').at(-1) ?? 'failed' }]
114
+ }
115
+
116
+ // Opposite calls on servers only Claude Code is connected to run here, newest first.
117
+ for (const o of outcomes) {
118
+ if (!o.ok || !o.run) continue
119
+ const done = await $.mcp.call(o.run.server, o.run.tool, o.run.args).catch((err: unknown) => ({
120
+ isError: true,
121
+ content: [{ type: 'text' as const, text: err instanceof Error ? err.message : String(err) }],
122
+ }))
123
+ if (done.isError) {
124
+ const why = done.content.map((c) => (c.type === 'text' ? c.text : '')).join(' ').trim().split('\n')[0] || 'failed'
125
+ o.ok = false
126
+ o.detail = `${o.run.tool} failed: ${why}`
127
+ await $.process.run(backstep(['settle', o.id, '--failed', why])).catch(() => null)
128
+ break
129
+ }
130
+ o.detail = `ran ${o.run.tool}`
131
+ await $.process.run(backstep(['settle', o.id])).catch(() => null)
132
+ }
133
+ // Stop the list at the first failure, as the CLI does.
134
+ const cut = outcomes.findIndex((o) => !o.ok)
135
+ if (cut !== -1) outcomes = outcomes.slice(0, cut + 1)
136
+
137
+ const items: RewindItem[] = outcomes.map((o) => {
138
+ const first = o.detail.split('\n')[0] ?? ''
139
+ const what = first.replace(/^(restored|removed) (.*)$/, (_, verb: string, p: string) => `${short(p)} ${verb}`)
140
+ return { label: o.ok ? what || o.tool : `${o.tool}: ${first}`, ok: o.ok }
141
+ })
142
+ const done = items.filter((i) => i.ok).length
143
+ const failed = items.some((i) => !i.ok)
144
+ // A push counted as lost when it ran, but undo can now offer to take it back there.
145
+ const offered = outcomes.filter((o) => o.ok && o.pushed).length
146
+ const lost = Math.max(0, (target.lost ?? 0) - offered)
147
+ const shell = lost ? ` ${plural(lost, 'action')} that reached outside couldn't be undone.` : ''
148
+
149
+ await update($, rewind, (): Rewind => ({
150
+ phase: failed ? 'failed' : items.length ? 'done' : 'empty',
151
+ frame: 0,
152
+ shown: 0,
153
+ items,
154
+ message: failed
155
+ ? `Stopped after ${plural(done, 'change')}: the next one couldn't be undone.`
156
+ : items.length
157
+ ? `Rewound ${plural(done, 'change')} from the last turn.${shell}`
158
+ : `Nothing in the last turn could be undone.${shell}`,
159
+ }))
160
+ await bump($, target.id, (t) => ({ ...t, undone: !failed }))
161
+
162
+ if (done) {
163
+ const list = items.filter((i) => i.ok).map((i) => `- ${i.label}`).join('\n')
164
+ await update($, note, () =>
165
+ `[Backstep] The user pressed undo (ctrl+z) on your previous turn. These changes were reverted:\n${list}\n` +
166
+ (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` : '') +
167
+ `Treat them as not having happened, re-read files before editing them, and don't redo them unless asked.`,
168
+ )
169
+ }
170
+
171
+ // Asked once the band shows what came back.
172
+ void offerPushUndo($, outcomes)
173
+
174
+ // Reveal the restored items one by one, then fold the band away.
175
+ const reveal = $.clock.every(90, () => {
176
+ void update($, rewind, (r) => (r && r.shown < r.items.length ? { ...r, shown: r.shown + 1 } : r))
177
+ })
178
+ $.clock.after(failed ? 9000 : 4200, () => {
179
+ reveal.cancel()
180
+ void update($, rewind, () => null)
181
+ })
182
+ }
183
+
184
+ type Next = (e: Record<string, unknown>) => Promise<{ deny?: string; isError?: true; result?: unknown; text?: string }>
185
+ type WatchPlan = {
186
+ kind: 'wrapped' | 'read' | 'irreversible' | 'write' | 'reversible'
187
+ id?: string
188
+ reason?: string
189
+ args?: Record<string, unknown>
190
+ hint?: 'supabase'
191
+ }
192
+
193
+ // Said once a session: a Supabase change went through that could have been undoable.
194
+ let hinted = false
195
+
196
+ function hint($: EngineInterface, plan: WatchPlan | null) {
197
+ if (plan?.hint !== 'supabase' || hinted) return
198
+ hinted = true
199
+ $.ui.toast("Backstep can't undo Supabase changes until it's connected to the database. Ask Claude to connect Backstep to Supabase.")
200
+ }
201
+
202
+ // The keys a tool.call carries that aren't the tool's own arguments.
203
+ const RESERVED = new Set(['tool', 'tool_use_id', 'consent', 'agentId', 'requestMeta'])
204
+
205
+ // An MCP call. Servers behind Backstep's proxy record themselves; for the rest
206
+ // (claude.ai connectors, servers Claude Code signs in to) Backstep records the
207
+ // call here and keeps an opposite call when an undo rule matches, which undo
208
+ // runs through Claude Code's own connection. Supabase SQL is snapshotted
209
+ // first, and a DROP comes back rewritten as a move to Backstep's trash.
210
+ async function watchMcp($: EngineInterface, e: Record<string, unknown>, next: Next) {
211
+ const tool = String(e.tool)
212
+ const t = await current($)
213
+ const args = Object.fromEntries(Object.entries(e).filter(([k]) => !RESERVED.has(k)))
214
+ const cwd = await $.session.cwd()
215
+ const sid = await $.session.id()
216
+ const started = await $.process
217
+ .run(backstep(['watch-start', '--session', sid, '--cwd', cwd, '--', tool]), { stdin: JSON.stringify(args) })
218
+ .catch(() => null)
219
+ let plan: WatchPlan | null = null
220
+ try {
221
+ plan = JSON.parse(started?.stdout || 'null') as WatchPlan | null
222
+ } catch {
223
+ plan = null
224
+ }
225
+ hint($, plan)
226
+ // Behind Backstep, or Backstep couldn't be reached: count it as before.
227
+ if (!plan || plan.kind === 'wrapped' || !plan.id) {
228
+ if (plan?.kind !== 'read') await bump($, t.id, (x) => ({ ...x, mcp: x.mcp + 1 }))
229
+ return next(e)
230
+ }
231
+ const r = await next(plan.args ? { ...e, ...plan.args } : e)
232
+ if (r.deny) {
233
+ await $.process.run(backstep(['cancel', plan.id])).catch(() => null)
234
+ return r
235
+ }
236
+ const ended = await $.process
237
+ .run(backstep(['watch-end', plan.id]), { stdin: JSON.stringify({ isError: r.isError ?? false, result: r.result, text: r.text }) })
238
+ .catch(() => null)
239
+ let undoable = false
240
+ try {
241
+ undoable = (JSON.parse(ended?.stdout || '{}') as { undoable?: boolean }).undoable === true
242
+ } catch {
243
+ undoable = false
244
+ }
245
+ if (!r.isError) await bump($, t.id, (x) => (undoable ? { ...x, mcp: x.mcp + 1 } : { ...x, lost: (x.lost ?? 0) + 1 }))
246
+ return r
247
+ }
248
+
249
+ type ShellPlan = { kind: 'read' | 'write' | 'outbound'; reason?: string; id?: string; undoable: boolean }
250
+
251
+ // Set from the plugin's options each time the module registers.
252
+ let cli = 'backstep'
253
+
254
+ function backstep(args: string[]): string[] {
255
+ return cli === 'backstep' ? ['backstep', ...args] : ['node', '--disable-warning=ExperimentalWarning', cli, ...args]
256
+ }
257
+
258
+ function newTurn(startedAt: number): Turn {
259
+ return { id: `t${startedAt}`, startedAt, edits: 0, files: [], mcp: 0, shell: 0, lost: 0, undone: false }
260
+ }
261
+
262
+ // The turn tool calls belong to; one opens if none is (a call before any prompt).
263
+ async function current($: EngineInterface): Promise<Turn> {
264
+ const last = (await read($, turns)).at(-1)
265
+ if (last && !last.undone) return last
266
+ const t = newTurn(await $.clock.now())
267
+ await update($, turns, (l) => [...(l ?? []), t].slice(-KEEP_TURNS))
268
+ return t
269
+ }
270
+
271
+ async function bump($: EngineInterface, id: string, fn: (t: Turn) => Turn): Promise<void> {
272
+ await update($, turns, (l) => (l ?? []).map((t) => (t.id === id ? fn(t) : t)))
273
+ }
274
+
275
+ // MCP servers added since setup go behind Backstep too. Claude Code has already
276
+ // started this session's servers, so the change takes effect from the next one.
277
+ async function wrapNewServers($: EngineInterface, cwd: string) {
278
+ const ran = await $.process.run(backstep(['wrap-servers', '--project', cwd, '--json']), { timeoutMs: 120_000 }).catch(() => null)
279
+ let changes: { name: string; done: boolean }[] = []
280
+ try {
281
+ changes = JSON.parse(ran?.stdout || '[]') as typeof changes
282
+ } catch {
283
+ return
284
+ }
285
+ const names = changes.filter((c) => c.done).map((c) => c.name)
286
+ if (names.length === 0) return
287
+ $.ui.toast(
288
+ `Backstep: ${names.join(', ')} ${names.length === 1 ? 'is' : 'are'} now behind Backstep, so ${names.length === 1 ? 'its' : 'their'} changes can be undone. Restart Claude Code to start using that.`,
289
+ )
290
+ }
291
+
292
+ // As a session starts: connects what the project's own settings allow, then
293
+ // says what Claude reaches that Backstep could undo once connected, so nobody
294
+ // presses ctrl+z expecting it to come back.
295
+ async function checkCoverage($: EngineInterface, cwd: string) {
296
+ // Supabase is only ever noticed here, never connected: that's the person's
297
+ // call. Firebase is added once someone is signed in to it.
298
+ const found = await $.process.run(backstep(['connect', '--dir', cwd, '--json', '--auto']), { timeoutMs: 120_000 }).catch(() => null)
299
+ try {
300
+ for (const c of JSON.parse(found?.stdout || '[]') as Connected[]) {
301
+ if (c.service === 'supabase' && c.state === 'ask') void askSupabase($, cwd)
302
+ 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.')
303
+ }
304
+ } catch {
305
+ // Backstep couldn't be reached: nothing to say.
306
+ }
307
+ const ran = await $.process.run(backstep(['status', '--quick', '--json', '--dir', cwd])).catch(() => null)
308
+ let items: { name: string; level: string; detail: string }[] = []
309
+ try {
310
+ items = (JSON.parse(ran?.stdout || '{}') as { items?: typeof items }).items ?? []
311
+ } catch {
312
+ return
313
+ }
314
+ // Supabase has its own question above, asked only where a project uses it.
315
+ const missing = [...new Set(items.filter((i) => i.level === 'connect' && !/supabase/i.test(i.name)).map((i) => i.name))]
316
+ if (missing.length === 0) return
317
+ $.ui.toast(`Backstep can't undo Claude's changes to ${missing.join(', ')} yet. They become undoable from the next Claude Code session.`)
318
+ }
319
+
320
+ const ASK = {
321
+ connect: 'Connect',
322
+ later: 'Not now',
323
+ never: "Don't ask for this project",
324
+ }
325
+
326
+ // Asked when Claude Code opens a project that uses Supabase and Backstep isn't
327
+ // connected to it; the answer is the only thing that connects it.
328
+ async function askSupabase($: EngineInterface, cwd: string) {
329
+ const answer = await $.ui
330
+ .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?", {
331
+ header: 'Backstep',
332
+ options: [ASK.connect, ASK.later, ASK.never],
333
+ })
334
+ .catch(() => null)
335
+ if (answer === ASK.never) {
336
+ await $.process.run(backstep(['connect', 'supabase', '--dir', cwd, '--dismiss'])).catch(() => null)
337
+ return
338
+ }
339
+ if (answer !== ASK.connect) return
340
+ const ran = await $.process.run(backstep(['connect', 'supabase', '--dir', cwd, '--json']), { timeoutMs: 120_000 }).catch(() => null)
341
+ let s: Connected | undefined
342
+ try {
343
+ s = (JSON.parse(ran?.stdout || '[]') as Connected[])[0]
344
+ } catch {
345
+ s = undefined
346
+ }
347
+ if (s?.state === 'connected' || s?.state === 'covered') {
348
+ $.ui.toast(`Backstep ${s.detail}. Claude's changes to its database can now be undone.`)
349
+ return
350
+ }
351
+ // Nothing in the project to connect with: a sign-in is needed, and Claude walks the person through it.
352
+ $.ui.toast('Backstep needs a Supabase sign-in for this. Ask Claude to "connect Backstep to Supabase" and it will walk you through it.')
353
+ }
354
+
355
+ const CONNECT = 'mcp__backstep__connect'
356
+
357
+ const CONNECT_TOOL = {
358
+ name: 'connect',
359
+ description:
360
+ "Connect Backstep (the undo for this session's changes) to the services the project uses, so changes Claude makes to them can be undone with ctrl+z. " +
361
+ 'Use it when the user asks to connect Backstep, or to make their database or Firebase undoable. ' +
362
+ "Supabase: finds the database's connection string in the project's .env files or its local `supabase start` database. " +
363
+ 'When it reports it needs a sign-in, tell the user how to make an access token (it says where), ask them to paste it, then call this again with connection_string. ' +
364
+ 'Never repeat a token or connection string back: they are secrets. ' +
365
+ "Firebase: adds Firebase's MCP server to the project when Claude can't reach Firebase yet; nothing else is needed.",
366
+ inputSchema: {
367
+ type: 'object',
368
+ properties: {
369
+ service: { type: 'string', enum: ['all', 'supabase', 'firebase'], description: 'what to connect; all when the user names nothing' },
370
+ connection_string: {
371
+ type: 'string',
372
+ description: 'what the user gave to connect Supabase: an access token (sbp_…, connects every project) or a database connection string (postgresql://…)',
373
+ },
374
+ project_ref: { type: 'string', description: 'the Supabase project ref to save it under, when the connection string has none' },
375
+ },
376
+ },
377
+ }
378
+
379
+ type Connected = { service: string; state: string; detail: string }
380
+
381
+ async function connectServices($: EngineInterface, input: { service?: string; connection_string?: string; project_ref?: string }) {
382
+ const cwd = await $.session.cwd()
383
+ const service = input.service === 'supabase' || input.service === 'firebase' ? [input.service] : []
384
+ const ref = input.project_ref ? ['--project', input.project_ref] : []
385
+ // The connection string goes on stdin, never in an argv other processes can read.
386
+ const ran = await $.process
387
+ .run(backstep(['connect', ...service, ...ref, '--dir', cwd, '--json', '--stdin']), { stdin: input.connection_string ?? '', timeoutMs: 180_000 })
388
+ .catch((err: unknown) => ({ exitCode: 1, stdout: '', stderr: err instanceof Error ? err.message : String(err) }))
389
+ let out: Connected[] = []
390
+ try {
391
+ out = JSON.parse(ran.stdout || '[]') as Connected[]
392
+ } catch {
393
+ out = []
394
+ }
395
+ if (out.length === 0) return `Backstep couldn't connect: ${(ran.stderr || ran.stdout).trim().split('\n').at(-1) || 'it failed'}`
396
+ if (out.some((s) => s.state === 'connected' || s.state === 'added')) $.ui.toast('Backstep: connected. Changes Claude makes there can now be undone.')
397
+ return out.map((s) => `${s.service === 'supabase' ? 'Supabase' : 'Firebase'} (${s.state}): ${s.detail}`).join('\n')
398
+ }
399
+
400
+ export const register: Register = (on, options) => {
401
+ cli = String(options.cli ?? 'backstep')
402
+
403
+ on('session.start', async ($, e, next) => {
404
+ await $.tool.register(CONNECT_TOOL)
405
+ const r = await next(e)
406
+ // In the background: a session never waits on either.
407
+ void wrapNewServers($, e.cwd)
408
+ void checkCoverage($, e.cwd)
409
+ void $.process.run(backstep(['ping'])).catch(() => null)
410
+ return r
411
+ })
412
+
413
+ // Each prompt of the person's starts a turn; a pending undo note rides along
414
+ // as context only the model reads, so Claude knows its work was taken back.
415
+ on('prompt.submit', async ($, e, next) => {
416
+ if (e.command || e.isMeta || (e.origin.kind !== 'composer' && e.origin.kind !== 'bridge')) return next(e)
417
+ const startedAt = await $.clock.now()
418
+ await update($, turns, (l) => [...(l ?? []), newTurn(startedAt)].slice(-KEEP_TURNS))
419
+ const pending = await read($, note)
420
+ if (!pending) return next(e)
421
+ await update($, note, () => null)
422
+ return next({ ...e, context: [...(e.context ?? []), pending] })
423
+ })
424
+
425
+ on('tool.call', { tool: CONNECT }, async ($, e) => ({ result: await connectServices($, e as { service?: string; connection_string?: string; project_ref?: string }) }))
426
+
427
+ on('tool.call', async ($, e, next) => {
428
+ const tool = String(e.tool)
429
+ if (tool === CONNECT) return next(e)
430
+ if (EDIT_TOOLS.has(tool)) {
431
+ const input = e as { file_path?: string; notebook_path?: string }
432
+ const path = input.file_path ?? input.notebook_path
433
+ if (path) {
434
+ const t = await current($)
435
+ const cwd = await $.session.cwd()
436
+ const sid = await $.session.id()
437
+ // A failed snapshot never blocks the edit; it just won't be undoable.
438
+ const snap = await $.process
439
+ .run(backstep(['snap', '--session', sid, '--tool', tool, '--cwd', cwd, '--', path]))
440
+ .catch(() => null)
441
+ if (snap?.exitCode === 0) {
442
+ await bump($, t.id, (x) => ({ ...x, edits: x.edits + 1, files: x.files.includes(path) ? x.files : [...x.files, path] }))
443
+ }
444
+ }
445
+ } else if (tool === 'Bash') {
446
+ const command = String((e as { command?: unknown }).command ?? '')
447
+ const t = await current($)
448
+ const cwd = await $.session.cwd()
449
+ const sid = await $.session.id()
450
+ // Never blocks the command: if Backstep can't be reached it just won't be undoable.
451
+ const ran = await $.process
452
+ .run(backstep(['shell', '--session', sid, '--cwd', cwd, '--', command]), { timeoutMs: 60_000 })
453
+ .catch(() => null)
454
+ let plan: ShellPlan | null = null
455
+ try {
456
+ plan = JSON.parse(ran?.stdout || 'null') as ShellPlan | null
457
+ } catch {
458
+ plan = null
459
+ }
460
+ if (plan?.kind === 'outbound') {
461
+ // What it changed here comes back with the project snapshot; what it sent out doesn't (a push is offered back after undo).
462
+ await bump($, t.id, (x) => ({ ...x, shell: (x.shell ?? 0) + (plan.undoable ? 1 : 0), lost: (x.lost ?? 0) + 1 }))
463
+ } else if (plan?.kind === 'write') {
464
+ await bump($, t.id, (x) => (plan.undoable ? { ...x, shell: (x.shell ?? 0) + 1 } : { ...x, lost: (x.lost ?? 0) + 1 }))
465
+ }
466
+ // A push: once it's run, note what it sent, so undo can offer to take it back there.
467
+ if (plan?.id && PUSH.test(command)) {
468
+ const r = await next(e)
469
+ await $.process.run(backstep(['shell-end', plan.id])).catch(() => null)
470
+ return r
471
+ }
472
+ } else if (tool.startsWith('mcp__')) {
473
+ return watchMcp($, e as unknown as Record<string, unknown>, next as unknown as Next)
474
+ }
475
+ return next(e)
476
+ })
477
+
478
+ on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
479
+ if (e.props.hasSurvey) return next(e)
480
+ const { Box, Button, Text } = $.ui.resolve(e)
481
+ const r = await read($, rewind)
482
+
483
+ if (r?.phase === 'running') {
484
+ return (
485
+ <Box>
486
+ <Text color="success" bold>↺ Rewinding </Text>
487
+ <Text color="success">{bar(r.frame)}</Text>
488
+ <Text dimColor> undoing the last turn…</Text>
489
+ </Box>
490
+ )
491
+ }
492
+
493
+ if (r) {
494
+ const tone = r.phase === 'failed' ? 'error' : r.phase === 'empty' ? 'warning' : 'success'
495
+ const glyph = r.phase === 'failed' ? '✗' : r.phase === 'empty' ? '!' : '✓'
496
+ const visible = r.items.slice(0, Math.min(r.shown, 6))
497
+ const more = r.items.length - 6
498
+ return (
499
+ <Box flexDirection="column">
500
+ <Text color={tone} bold>
501
+ {glyph} {r.message}
502
+ </Text>
503
+ {visible.map((i, n) => (
504
+ <Text key={`i${n}`} color={i.ok ? undefined : 'error'} dimColor={i.ok}>
505
+ {' '}
506
+ {i.ok ? '↺ ' : '✗ '}
507
+ {i.label}
508
+ </Text>
509
+ ))}
510
+ {r.shown >= 6 && more > 0 ? <Text dimColor>{` and ${more} more`}</Text> : null}
511
+ </Box>
512
+ )
513
+ }
514
+
515
+ // Idle: a quiet line naming what one press would take back. The button
516
+ // has to be on screen for the chord to reach it.
517
+ const target = (await read($, turns)).findLast(hasWork)
518
+ if (!target) return next(e)
519
+ return (
520
+ <Box>
521
+ <Text color="success">↺ </Text>
522
+ <Text dimColor>Last turn: {summary(target)} </Text>
523
+ <Button key="undo" action={UNDO_ACTION} plain dimColor onPress={() => void undoLastTurn($)}>
524
+ ctrl+z to undo
525
+ </Button>
526
+ </Box>
527
+ )
528
+ })
529
+ }
@@ -0,0 +1,30 @@
1
+ // One turn: everything Claude did between two of the person's prompts.
2
+ export type Turn = {
3
+ id: string
4
+ startedAt: number
5
+ edits: number
6
+ files: string[]
7
+ mcp: number
8
+ // Shell commands snapshotted before they ran, so undo takes them back.
9
+ shell: number
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
+ lost: number
12
+ undone: boolean
13
+ }
14
+
15
+ export type RewindItem = { label: string; ok: boolean }
16
+
17
+ // The animation above the prompt while a turn is being rewound.
18
+ export type Rewind = {
19
+ phase: 'running' | 'done' | 'failed' | 'empty'
20
+ frame: number
21
+ shown: number
22
+ items: RewindItem[]
23
+ message: string
24
+ }
25
+
26
+ declare module 'claude-code' {
27
+ interface PluginState {
28
+ backstep: { turns: Turn[]; rewind: Rewind | null; note: string | null }
29
+ }
30
+ }
package/package.json CHANGED
@@ -1,6 +1,50 @@
1
1
  {
2
2
  "name": "backstep",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Undo for everything your agents touch: an MCP proxy that makes agent actions reversible.",
5
+ "scripts": {
6
+ "dev": "node src/cli.ts",
7
+ "ui": "node --disable-warning=ExperimentalWarning src/cli.ts ui",
8
+ "demo": "node --disable-warning=ExperimentalWarning examples/demo-db.ts",
9
+ "build": "tsc -p tsconfig.json && cp src/ui/index.html dist/ui/index.html && chmod +x dist/cli.js",
10
+ "test": "node --disable-warning=ExperimentalWarning --test test/*.test.ts",
11
+ "typecheck": "tsc -p tsconfig.json --noEmit"
12
+ },
13
+ "license": "UNLICENSED",
14
+ "type": "module",
15
+ "dependencies": {
16
+ "@modelcontextprotocol/sdk": "^1.32.2",
17
+ "pg": "^8.23.1",
18
+ "zod": "^4.6.7"
19
+ },
20
+ "devDependencies": {
21
+ "@electric-sql/pglite": "^0.5.12",
22
+ "@electric-sql/pglite-socket": "^0.2.15",
23
+ "@modelcontextprotocol/server-filesystem": "^2.0.1",
24
+ "@types/node": "^26.6.5",
25
+ "@types/pg": "^8.23.1",
26
+ "typescript": "^7.0.2"
27
+ },
28
+ "bin": {
29
+ "backstep": "dist/cli.js"
30
+ },
31
+ "engines": {
32
+ "node": ">=22.13"
33
+ },
34
+ "files": [
35
+ "dist",
36
+ "mod",
37
+ "README.md"
38
+ ],
39
+ "keywords": [
40
+ "mcp",
41
+ "undo",
42
+ "claude-code",
43
+ "agents",
44
+ "rollback"
45
+ ],
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "git+https://github.com/bzells11/backstep.git"
49
+ }
50
+ }