backstep 0.1.29 → 0.1.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/setup.js +12 -8
- package/dist/update.js +1 -1
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.test.tsx +117 -3
- package/mod/hooks/register.tsx +173 -15
- package/mod/types/index.d.ts +6 -2
- package/package.json +1 -1
package/dist/setup.js
CHANGED
|
@@ -76,6 +76,15 @@ export async function setup() {
|
|
|
76
76
|
const cli = join(root, "dist", "cli.js");
|
|
77
77
|
// Without `backstep` on the PATH, the commands printed below go through npx.
|
|
78
78
|
const bs = onPath ? "backstep" : "npx -y backstep";
|
|
79
|
+
// Only a problem is worth a line here; the summary below says the rest.
|
|
80
|
+
const bound = ensureUndoKey();
|
|
81
|
+
if (bound)
|
|
82
|
+
console.log(` ${white("!")} ${bound}`);
|
|
83
|
+
// Servers point at the copy just put in place, which is whole by now.
|
|
84
|
+
autoWrap(true);
|
|
85
|
+
wrapServers(cli);
|
|
86
|
+
// The mod last: installing it makes Claude Code reload Backstep there, which
|
|
87
|
+
// ends the code that ran this update, so everything else is done first.
|
|
79
88
|
const install = () => run("claude", ["plugin", "install", "backstep", "--marketplace", join(root, "mod"), "--config", `cli=${cli}`]);
|
|
80
89
|
let r = install();
|
|
81
90
|
// Installed before from another copy (the file a person was given, or ~/.backstep before npm worked):
|
|
@@ -90,19 +99,14 @@ export async function setup() {
|
|
|
90
99
|
// was; re-reading the folder and updating brings the mod up to this version.
|
|
91
100
|
run("claude", ["plugin", "marketplace", "update", "backstep"]);
|
|
92
101
|
run("claude", ["plugin", "update", "backstep@backstep"]);
|
|
93
|
-
// Only a problem is worth a line here; the summary below says the rest.
|
|
94
|
-
const bound = ensureUndoKey();
|
|
95
|
-
if (bound)
|
|
96
|
-
console.log(` ${white("!")} ${bound}`);
|
|
97
|
-
// Wrap last, so a failed install never leaves servers pointing at a Backstep that isn't there.
|
|
98
|
-
autoWrap(true);
|
|
99
|
-
wrapServers(cli);
|
|
100
102
|
// What's covered and what isn't, said once here and kept up to date by `backstep status`.
|
|
101
103
|
const key = (undoKeys().length ? undoKeys() : defaultKeys()).map(keyLabel).join(" or ");
|
|
102
104
|
console.log(`\n ${pink("✓")} ${white("Backstep is ready.")} Restart Claude Code, then press ${white(key)} after any turn to undo it.`);
|
|
103
105
|
console.log(dim(` Want a different key? Just ask Claude: "change my Backstep undo key to ctrl+q"`));
|
|
104
106
|
console.log(dim(` To remove Backstep, type /uninstall-backstep in Claude Code, or ask Claude to uninstall it.`));
|
|
105
|
-
|
|
107
|
+
// An update runs this out of sight, so the summary (a few seconds of checks) is skipped there.
|
|
108
|
+
if (process.env.BACKSTEP_UPDATING !== "1")
|
|
109
|
+
printCheck(check(process.cwd()), bs);
|
|
106
110
|
if (telemetryOn()) {
|
|
107
111
|
console.log(dim(" Sends an anonymous install count. Turn it off with BACKSTEP_TELEMETRY=0.\n"));
|
|
108
112
|
await sendInstall();
|
package/dist/update.js
CHANGED
|
@@ -80,7 +80,7 @@ export function runUpdate(v, tries = 4) {
|
|
|
80
80
|
const r = spawnSync("npx", ["-y", "--prefer-online", `backstep@${v}`, "setup"], {
|
|
81
81
|
encoding: "utf8",
|
|
82
82
|
timeout: 5 * 60 * 1000,
|
|
83
|
-
env: { ...process.env, NO_COLOR: "1", BACKSTEP_TELEMETRY: "0" },
|
|
83
|
+
env: { ...process.env, NO_COLOR: "1", BACKSTEP_TELEMETRY: "0", BACKSTEP_UPDATING: "1" },
|
|
84
84
|
stdio: ["ignore", "pipe", "pipe"],
|
|
85
85
|
cwd: tmpdir(),
|
|
86
86
|
});
|
|
@@ -271,7 +271,8 @@ test('servers added since setup are wrapped as a session starts', { options: { c
|
|
|
271
271
|
|
|
272
272
|
// A claude.ai connector: Backstep can't sit in front of it, so the mod reports
|
|
273
273
|
// the call and, on undo, runs the rule's opposite call through Claude Code.
|
|
274
|
-
function connector(on: On, opts: { start: object; undoable: boolean; mcpFails?: boolean; undo?: object }) {
|
|
274
|
+
function connector(on: On, opts: { start: object; undoable: boolean; mcpFails?: boolean; mcpThrows?: string; undo?: object }) {
|
|
275
|
+
const prompts: string[] = []
|
|
275
276
|
const runs: { argv: string[]; stdin?: string }[] = []
|
|
276
277
|
const calls: { server: string; tool: string; args: Record<string, unknown> }[] = []
|
|
277
278
|
const ran: Record<string, unknown>[] = []
|
|
@@ -295,6 +296,7 @@ function connector(on: On, opts: { start: object; undoable: boolean; mcpFails?:
|
|
|
295
296
|
return { value: { exitCode: 0, stdout: out ? JSON.stringify(out) : '', stderr: '', isStdoutTruncated: false, isStderrTruncated: false } }
|
|
296
297
|
})
|
|
297
298
|
on('mcp.call', (_, e) => {
|
|
299
|
+
if (opts.mcpThrows) return { value: { content: [{ type: 'text', text: opts.mcpThrows }], isError: true } }
|
|
298
300
|
calls.push({ server: e.server, tool: e.tool, args: e.args })
|
|
299
301
|
if (e.tool === 'notion-fetch') return { value: { content: [{ type: 'text', text: `page ${String(e.args.id)} as it was` }], isError: false } }
|
|
300
302
|
return { value: { content: [{ type: 'text', text: opts.mcpFails ? 'no such page' : 'archived' }], isError: opts.mcpFails === true } }
|
|
@@ -303,12 +305,15 @@ function connector(on: On, opts: { start: object; undoable: boolean; mcpFails?:
|
|
|
303
305
|
ran.push({ ...e })
|
|
304
306
|
return { result: { content: [{ type: 'text', text: '{"id":"p1"}' }] }, text: '{"id":"p1"}' }
|
|
305
307
|
})
|
|
306
|
-
on('prompt.submit', (_, e) =>
|
|
308
|
+
on('prompt.submit', (_, e) => {
|
|
309
|
+
prompts.push(e.text)
|
|
310
|
+
return { text: e.text, context: e.context }
|
|
311
|
+
})
|
|
307
312
|
on('ui.render', ($, e) => {
|
|
308
313
|
const { Text } = $.ui.resolve(e)
|
|
309
314
|
return <Text>engine</Text>
|
|
310
315
|
})
|
|
311
|
-
return { runs, calls, ran, toasts }
|
|
316
|
+
return { runs, calls, ran, toasts, prompts }
|
|
312
317
|
}
|
|
313
318
|
|
|
314
319
|
test('a connector call with an undo rule is undone through Claude Code', { options: { cli: CLI } }, async ($, on) => {
|
|
@@ -1251,3 +1256,112 @@ test('dismissing the question about changed files undoes nothing', { options: {
|
|
|
1251
1256
|
expect(await ui.find({ type: 'Text', text: /Rewinding|Rewound/ })).toBeFalsy()
|
|
1252
1257
|
await ui.unmount()
|
|
1253
1258
|
})
|
|
1259
|
+
|
|
1260
|
+
test('an update that reloads the mod partway is finished by the reloaded mod: it says done and takes the warning down', { options: { cli: CLI } }, async ($, on) => {
|
|
1261
|
+
const clock = mock.clock(on, { now: 5000 })
|
|
1262
|
+
let started = false
|
|
1263
|
+
const logs: string[] = []
|
|
1264
|
+
const statuses: (string | undefined)[] = []
|
|
1265
|
+
on('process.run', (_, e) => {
|
|
1266
|
+
const argv = [...e.argv]
|
|
1267
|
+
// The update itself never answers: installing it reloaded the mod that was waiting on it.
|
|
1268
|
+
if (argv.includes('update') && argv.includes('--json')) {
|
|
1269
|
+
started = true
|
|
1270
|
+
return new Promise(() => {})
|
|
1271
|
+
}
|
|
1272
|
+
const stdout = argv.includes('update-check')
|
|
1273
|
+
? JSON.stringify({ current: started ? '0.2.0' : '0.1.2', latest: '0.2.0', available: !started })
|
|
1274
|
+
: argv.includes('status')
|
|
1275
|
+
? JSON.stringify({ items: [], suggest: [] })
|
|
1276
|
+
: '[]'
|
|
1277
|
+
return { value: { exitCode: 0, stdout, stderr: '', isStdoutTruncated: false, isStderrTruncated: false } }
|
|
1278
|
+
})
|
|
1279
|
+
on('ui.toast', () => ({ value: undefined }))
|
|
1280
|
+
on('ui.log', (_, e) => {
|
|
1281
|
+
logs.push(e.text)
|
|
1282
|
+
return { value: undefined }
|
|
1283
|
+
})
|
|
1284
|
+
on('ui.status', (_, e) => {
|
|
1285
|
+
statuses.push(e.text)
|
|
1286
|
+
return { value: undefined }
|
|
1287
|
+
})
|
|
1288
|
+
on('tool.register', (_, e) => ({ value: { tool: `mcp__backstep__${e.name}` } }))
|
|
1289
|
+
on('tool.call', { tool: 'AskUserQuestion' }, (_, e) => {
|
|
1290
|
+
const questions = (e as unknown as { questions: { question: string }[] }).questions
|
|
1291
|
+
return { result: { questions, answers: { [questions[0].question]: 'Update' } } }
|
|
1292
|
+
})
|
|
1293
|
+
on('session.start', (_, e) => ({ cwd: e.cwd }))
|
|
1294
|
+
on('session.cwd', () => ({ value: '/repo' }))
|
|
1295
|
+
on('session.id', () => ({ value: 's1' }))
|
|
1296
|
+
on('tool.call', () => ({ result: {} }))
|
|
1297
|
+
on('ui.render', ($, e) => {
|
|
1298
|
+
const { Text } = $.ui.resolve(e)
|
|
1299
|
+
return <Text>engine</Text>
|
|
1300
|
+
})
|
|
1301
|
+
|
|
1302
|
+
void $.session.start({ cwd: '/repo', surface: 'terminal', hasPerson: true } as never)
|
|
1303
|
+
for (let i = 0; i < 10 && !started; i++) {
|
|
1304
|
+
await settle()
|
|
1305
|
+
await clock.advance(1000)
|
|
1306
|
+
}
|
|
1307
|
+
expect(started).toBe(true)
|
|
1308
|
+
expect(statuses.at(-1)).toContain("don't close this terminal")
|
|
1309
|
+
|
|
1310
|
+
const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
|
|
1311
|
+
for (let i = 0; i < 5; i++) {
|
|
1312
|
+
await clock.advance(5000)
|
|
1313
|
+
await settle()
|
|
1314
|
+
}
|
|
1315
|
+
expect(statuses.at(-1)).toBeUndefined()
|
|
1316
|
+
expect(logs.join('\n')).toContain('Backstep is updated to 0.2.0 (you had 0.1.2). It\'s safe to close this terminal now.')
|
|
1317
|
+
await ui.unmount()
|
|
1318
|
+
})
|
|
1319
|
+
|
|
1320
|
+
// Auto mode refuses Backstep's own call: pressing undo was the go-ahead, so Claude is asked to make it.
|
|
1321
|
+
async function refusedNotionUndo($: Parameters<Parameters<typeof test>[2]>[0], on: On) {
|
|
1322
|
+
const clock = mock.clock(on, { now: 5000 })
|
|
1323
|
+
const refusal = "backstep: $.mcp.call(claude_ai_Notion, notion-delete-pages) refused: The server-side auto mode classifier gave no verdict for mcp__claude_ai_Notion__notion-delete-pages"
|
|
1324
|
+
const asked: string[] = []
|
|
1325
|
+
on('tool.call', { tool: 'AskUserQuestion' }, (_, e) => {
|
|
1326
|
+
asked.push((e as unknown as { questions: { question: string }[] }).questions[0].question)
|
|
1327
|
+
return { result: {} }
|
|
1328
|
+
})
|
|
1329
|
+
const env = connector(on, {
|
|
1330
|
+
start: { kind: 'reversible', id: 'w1' },
|
|
1331
|
+
undoable: true,
|
|
1332
|
+
mcpThrows: refusal,
|
|
1333
|
+
undo: { id: 'w1', tool: 'notion-create-pages', ok: true, detail: 'runs notion-delete-pages', run: { server: 'claude_ai_Notion', tool: 'notion-delete-pages', args: { page_ids: ['p1'], user_confirmed: true }, label: 'Notion: moved the pages Claude made to Trash' } },
|
|
1334
|
+
})
|
|
1335
|
+
await $.prompt.submit({ text: 'make a page', wait: false, origin: { kind: 'composer' } })
|
|
1336
|
+
await $.tool.call({ tool: 'mcp__claude_ai_Notion__notion-create-pages', pages: [{ title: 'Test' }], tool_use_id: 'tu1' } as never)
|
|
1337
|
+
const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
|
|
1338
|
+
await ui.press({ key: 'undo' })
|
|
1339
|
+
const pressed = ui.press({ key: 'undo' })
|
|
1340
|
+
await clock.advance(1000)
|
|
1341
|
+
await pressed
|
|
1342
|
+
await clock.advance(1000)
|
|
1343
|
+
await settle()
|
|
1344
|
+
return { ...env, asked, ui }
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
test("when auto mode won't let Backstep undo a Notion change, Claude is asked to make the call, with no question and nothing shown as failed", { options: { cli: CLI } }, async ($, on) => {
|
|
1348
|
+
const { runs, ran, prompts, asked, ui } = await refusedNotionUndo($, on)
|
|
1349
|
+
expect(asked).toEqual([])
|
|
1350
|
+
expect(runs.some((r) => r.argv.includes('--failed'))).toBe(false)
|
|
1351
|
+
expect(prompts.at(-1)).toContain('mcp__claude_ai_Notion__notion-delete-pages with {"page_ids":["p1"],"user_confirmed":true}')
|
|
1352
|
+
expect((await ui.find({ type: 'Text', text: /Rewound/ }))?.text).toContain('Rewound 1 change from the last turn. Claude is finishing it.')
|
|
1353
|
+
expect(await ui.find({ type: 'Text', text: /Not undone/ })).toBeFalsy()
|
|
1354
|
+
|
|
1355
|
+
// Claude makes the call: it isn't recorded as a change of its own, and the change is marked undone.
|
|
1356
|
+
const before = ran.length
|
|
1357
|
+
const watched = runs.filter((r) => r.argv.includes('watch-start')).length
|
|
1358
|
+
await $.tool.call({ tool: 'mcp__claude_ai_Notion__notion-delete-pages', page_ids: ['p1'], user_confirmed: true, tool_use_id: 'tu2' } as never)
|
|
1359
|
+
expect(ran.length).toBe(before + 1)
|
|
1360
|
+
expect(runs.filter((r) => r.argv.includes('watch-start')).length).toBe(watched)
|
|
1361
|
+
expect(runs.at(-1)?.argv.slice(3)).toEqual(['settle', 'w1'])
|
|
1362
|
+
const line = await ui.find({ type: 'Text', text: /Rewound/ })
|
|
1363
|
+
expect(line?.text).toContain('Rewound 1 change from the last turn.')
|
|
1364
|
+
expect(line?.text).not.toContain('Claude is finishing')
|
|
1365
|
+
expect(await ui.find({ type: 'Text', text: /Claude is finishing this/ })).toBeFalsy()
|
|
1366
|
+
await ui.unmount()
|
|
1367
|
+
})
|
package/mod/hooks/register.tsx
CHANGED
|
@@ -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 { GithubCode, Rewind, RewindItem, Turn } from '../types'
|
|
10
|
+
import type { GithubCode, Rewind, RewindItem, Turn, Updating } 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)
|
|
@@ -20,6 +20,12 @@ const keys = atom({ plugin: 'backstep', key: 'keys' } as const, ['ctrl+u'] as st
|
|
|
20
20
|
const githubCode = atom({ plugin: 'backstep', key: 'githubCode' } as const, null as GithubCode | null)
|
|
21
21
|
// Why ctrl+u can't run here, when getbackstep.com says so (a version too old, or no sign-in when one is needed).
|
|
22
22
|
const blocked = atom({ plugin: 'backstep', key: 'blocked' } as const, null as string | null)
|
|
23
|
+
// An update under way. Installing it reloads this mod partway through, which
|
|
24
|
+
// ends the code waiting on it, so the reloaded mod finishes the job: it says
|
|
25
|
+
// the update is done and takes the "don't close" line down.
|
|
26
|
+
const updating = atom({ plugin: 'backstep', key: 'updating' } as const, null as Updating | null)
|
|
27
|
+
// Whether this load of the mod has looked for an update the last one left running.
|
|
28
|
+
let resumed = false
|
|
23
29
|
// Backstep's mark, as on the logo: the front rewind triangle in the terminal's
|
|
24
30
|
// own black or white, the one behind it in the logo's pink.
|
|
25
31
|
const PINK = '#ee7bb6'
|
|
@@ -59,6 +65,8 @@ type Outcome = {
|
|
|
59
65
|
tool: string
|
|
60
66
|
ok: boolean
|
|
61
67
|
detail: string
|
|
68
|
+
// Handed to Claude to finish: auto mode wouldn't let Backstep make the call.
|
|
69
|
+
pending?: boolean
|
|
62
70
|
run?: { server: string; tool: string; args: Record<string, unknown>; more?: { tool: string; args: Record<string, unknown> }[]; label?: string }
|
|
63
71
|
pushed?: { root: string; pushes: Pushed[] }
|
|
64
72
|
github?: GhMade[]
|
|
@@ -355,29 +363,43 @@ async function undoLastTurn($: EngineInterface, count = 1) {
|
|
|
355
363
|
}
|
|
356
364
|
|
|
357
365
|
// Opposite calls on servers only Claude Code is connected to run here, newest first.
|
|
366
|
+
// Once auto mode refuses one, it and every one after it go to Claude, in order.
|
|
367
|
+
const handoff: Handoff[] = []
|
|
358
368
|
for (const o of outcomes) {
|
|
359
369
|
if (!o.ok || !o.run) continue
|
|
360
370
|
const run = o.run
|
|
371
|
+
const all = [{ tool: run.tool, args: run.args }, ...(run.more ?? [])]
|
|
372
|
+
const label = run.label ?? `ran ${run.tool}`
|
|
373
|
+
if (handoff.length) {
|
|
374
|
+
handoff.push({ id: o.id, server: run.server, calls: all, label })
|
|
375
|
+
o.pending = true
|
|
376
|
+
o.detail = label
|
|
377
|
+
continue
|
|
378
|
+
}
|
|
361
379
|
let failed: { tool: string; why: string } | null = null
|
|
362
380
|
// The first opposite call, then any more on the same server (a move back to several places).
|
|
363
|
-
for (const call of
|
|
364
|
-
const done = await
|
|
365
|
-
isError: true,
|
|
366
|
-
content: [{ type: 'text' as const, text: err instanceof Error ? err.message : String(err) }],
|
|
367
|
-
}))
|
|
381
|
+
for (const [i, call] of all.entries()) {
|
|
382
|
+
const done = await undoCall($, run.server, call.tool, call.args)
|
|
368
383
|
if (done.isError) {
|
|
369
384
|
const why = done.content.map((c) => (c.type === 'text' ? c.text : '')).join(' ').trim().split('\n')[0] || 'failed'
|
|
370
|
-
|
|
385
|
+
// Claude Code's auto mode won't let Backstep make it alone; the person's undo is the go-ahead, so Claude makes it.
|
|
386
|
+
if (REFUSED.test(why)) handoff.push({ id: o.id, server: run.server, calls: all.slice(i), label })
|
|
387
|
+
else failed = { tool: call.tool, why }
|
|
371
388
|
break
|
|
372
389
|
}
|
|
373
390
|
}
|
|
391
|
+
if (handoff.length) {
|
|
392
|
+
o.pending = true
|
|
393
|
+
o.detail = label
|
|
394
|
+
continue
|
|
395
|
+
}
|
|
374
396
|
if (failed) {
|
|
375
397
|
o.ok = false
|
|
376
398
|
o.detail = `${failed.tool} failed: ${failed.why}`
|
|
377
399
|
await $.process.run(backstep(['settle', o.id, '--failed', failed.why])).catch(() => null)
|
|
378
400
|
break
|
|
379
401
|
}
|
|
380
|
-
o.detail =
|
|
402
|
+
o.detail = label
|
|
381
403
|
await $.process.run(backstep(['settle', o.id])).catch(() => null)
|
|
382
404
|
}
|
|
383
405
|
// Stop the list at the first failure, as the CLI does.
|
|
@@ -391,7 +413,7 @@ async function undoLastTurn($: EngineInterface, count = 1) {
|
|
|
391
413
|
(o.detail || o.tool)
|
|
392
414
|
.split('\n')
|
|
393
415
|
.filter(Boolean)
|
|
394
|
-
.map((line) => ({ label: undoneLabel(line, short), ok: true })),
|
|
416
|
+
.map((line): RewindItem => (o.pending ? { label: undoneLabel(line, short), ok: true, pending: true, action: o.id } : { label: undoneLabel(line, short), ok: true })),
|
|
395
417
|
)
|
|
396
418
|
const stopped = outcomes.filter((o) => !o.ok).map((o) => ({ label: `${o.tool}: ${o.detail.split('\n')[0] ?? 'failed'}`, ok: false }))
|
|
397
419
|
// A push, or something made on GitHub, is offered back below, so it isn't listed as lost.
|
|
@@ -419,7 +441,7 @@ async function undoLastTurn($: EngineInterface, count = 1) {
|
|
|
419
441
|
message: failed
|
|
420
442
|
? `Stopped after ${plural(done, 'change')}: the next one couldn't be undone.`
|
|
421
443
|
: items.length
|
|
422
|
-
? `Rewound ${plural(done, 'change')} from ${which}.${shell}`
|
|
444
|
+
? `Rewound ${plural(done, 'change')} from ${which}.${finishing(undone)}${shell}`
|
|
423
445
|
: `Nothing in ${which} could be undone.${shell}`,
|
|
424
446
|
}))
|
|
425
447
|
for (const t of taken) await bump($, t.id, (x) => ({ ...x, undone: !failed }))
|
|
@@ -427,7 +449,7 @@ async function undoLastTurn($: EngineInterface, count = 1) {
|
|
|
427
449
|
// The whole account, kept in the conversation, where the band's few rows fold away.
|
|
428
450
|
const report = [
|
|
429
451
|
`${MARK_TEXT} Backstep undid ${plural(done, 'change')} from ${which}${taken.length === 1 ? named(target) : ''}${done ? ':' : '.'}`,
|
|
430
|
-
...undone.map((i) => ` ◀ ${i.label}`),
|
|
452
|
+
...undone.map((i) => ` ◀ ${i.label}${i.pending ? ' (Claude is finishing this)' : ''}`),
|
|
431
453
|
...(stopped.length || kept.length ? ["Not undone:", ...[...stopped, ...kept].map((i) => ` ✗ ${i.label}`)] : []),
|
|
432
454
|
]
|
|
433
455
|
$.ui.log(report.join('\n'))
|
|
@@ -443,7 +465,9 @@ async function undoLastTurn($: EngineInterface, count = 1) {
|
|
|
443
465
|
}
|
|
444
466
|
|
|
445
467
|
// Asked once the band shows what came back: GitHub things first, then pushes, then Vercel.
|
|
446
|
-
|
|
468
|
+
// A change auto mode wouldn't let Backstep make comes first of all: it's where undo stopped.
|
|
469
|
+
void (handoff.length ? handOff($, handoff) : Promise.resolve())
|
|
470
|
+
.then(() => offerGithubUndo($, outcomes))
|
|
447
471
|
.then((gone) => offerPushUndo($, outcomes, gone.size > 0))
|
|
448
472
|
.then(() => offerVercelUndo($, outcomes))
|
|
449
473
|
|
|
@@ -575,6 +599,81 @@ function undoneLabel(line: string, short: (p: string) => string): string {
|
|
|
575
599
|
return `${p} ${m[1]}`
|
|
576
600
|
}
|
|
577
601
|
|
|
602
|
+
// Opposite calls handed to Claude to make, after the person said yes: the call,
|
|
603
|
+
// by tool and arguments, to the action it undoes. Not recorded as Claude's own,
|
|
604
|
+
// and once all of an action's have run, it counts as undone.
|
|
605
|
+
const handedOver = new Map<string, string>()
|
|
606
|
+
|
|
607
|
+
type Handoff = { id: string; server: string; calls: { tool: string; args: Record<string, unknown> }[]; label: string }
|
|
608
|
+
|
|
609
|
+
// Said after "Rewound …" while Claude still has some of it to finish.
|
|
610
|
+
function finishing(items: RewindItem[]): string {
|
|
611
|
+
const n = items.filter((i) => i.pending).length
|
|
612
|
+
return n === 0 ? '' : n === items.length ? ' Claude is finishing it.' : ` Claude is finishing ${n} of them.`
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Auto mode's classifier answers only calls a request of Claude's asked for, so it
|
|
616
|
+
// refuses Backstep's own. Pressing undo already said yes, so Claude is asked to make
|
|
617
|
+
// exactly these calls; Backstep marks each change undone once its calls have run.
|
|
618
|
+
async function handOff($: EngineInterface, list: Handoff[]) {
|
|
619
|
+
const steps: string[] = []
|
|
620
|
+
for (const h of list) {
|
|
621
|
+
const server = toolServer(h.server)
|
|
622
|
+
for (const c of h.calls) {
|
|
623
|
+
handedOver.set(undoCallKey(`mcp__${server}__${c.tool}`, c.args), h.id)
|
|
624
|
+
steps.push(`- mcp__${server}__${c.tool} with ${JSON.stringify(c.args)}`)
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
const what = list.map((h) => h.label).join('; ')
|
|
628
|
+
await $.prompt
|
|
629
|
+
.submit({
|
|
630
|
+
text: `I pressed undo, and Backstep needs you to finish it (auto mode doesn't let Backstep make these calls itself): ${what}. Make exactly ${steps.length === 1 ? 'this call' : 'these calls, in order'}, and nothing else:\n${steps.join('\n')}`,
|
|
631
|
+
wait: false,
|
|
632
|
+
origin: { kind: 'composer' },
|
|
633
|
+
} as never)
|
|
634
|
+
.catch(() => null)
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// One of Claude's hand-over calls has run: the band's row for it says how it went.
|
|
638
|
+
async function handedBack($: EngineInterface, action: string, ok: boolean, why?: string) {
|
|
639
|
+
await update($, rewind, (r) => {
|
|
640
|
+
if (!r) return r
|
|
641
|
+
const items = r.items.map((i) => (i.pending && i.action === action ? (ok ? { ...i, pending: false } : { label: `${i.label}: Claude couldn't (${why ?? 'it failed'})`, ok: false }) : i))
|
|
642
|
+
const undone = items.filter((i) => i.ok)
|
|
643
|
+
// "Rewound 2 changes from the last turn. Claude is finishing it." loses its last sentence once Claude has.
|
|
644
|
+
const [rewound, ...rest] = r.message.replace(/ Claude is finishing [^.]*\./, '').split(/(?<=\.) /)
|
|
645
|
+
return {
|
|
646
|
+
...r,
|
|
647
|
+
items,
|
|
648
|
+
shown: items.length,
|
|
649
|
+
phase: ok ? r.phase : 'failed',
|
|
650
|
+
message: ok ? [rewound + finishing(undone), ...rest].join(' ') : `Rewound ${plural(undone.length, 'change')}. Claude couldn't finish one: listed below.`,
|
|
651
|
+
}
|
|
652
|
+
})
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// One opposite call, through Claude Code's own connection to the server.
|
|
656
|
+
async function undoCall($: EngineInterface, server: string, tool: string, args: Record<string, unknown>): Promise<{ isError: boolean; content: { type: string; text?: string }[] }> {
|
|
657
|
+
return $.mcp.call(server, tool, args).catch((err: unknown) => ({
|
|
658
|
+
isError: true,
|
|
659
|
+
content: [{ type: 'text' as const, text: err instanceof Error ? err.message : String(err) }],
|
|
660
|
+
}))
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Claude Code's permission check refused the call (auto mode's classifier
|
|
664
|
+
// answers only calls a request of Claude's asked for), not the server.
|
|
665
|
+
const REFUSED = /\$\.mcp\.call\([^)]*\) refused|auto mode classifier/i
|
|
666
|
+
|
|
667
|
+
// A server's name as tool names spell it: "claude.ai Notion" is claude_ai_Notion.
|
|
668
|
+
const toolServer = (server: string) => server.replace(/[^A-Za-z0-9_-]/g, '_')
|
|
669
|
+
|
|
670
|
+
// A call by tool and arguments, the arguments' keys in order, so the same call always reads the same.
|
|
671
|
+
function undoCallKey(tool: string, args: unknown): string {
|
|
672
|
+
const sorted = (v: unknown): unknown =>
|
|
673
|
+
Array.isArray(v) ? v.map(sorted) : v && typeof v === 'object' ? Object.fromEntries(Object.entries(v).sort(([a], [b]) => (a < b ? -1 : 1)).map(([k, x]) => [k, sorted(x)])) : v
|
|
674
|
+
return `${tool} ${JSON.stringify(sorted(args ?? {}))}`
|
|
675
|
+
}
|
|
676
|
+
|
|
578
677
|
// The action id `backstep snap` printed.
|
|
579
678
|
function savedId(stdout: string): string | null {
|
|
580
679
|
try {
|
|
@@ -788,6 +887,40 @@ async function offerUpdate($: EngineInterface) {
|
|
|
788
887
|
await installUpdate($, u.current, u.latest)
|
|
789
888
|
}
|
|
790
889
|
|
|
890
|
+
function updated($: EngineInterface, u: Updating) {
|
|
891
|
+
$.ui.log(`${MARK_TEXT} Backstep is updated to ${u.to}${u.from ? ` (you had ${u.from})` : ''}. It's safe to close ${u.win} now. Restart Claude Code to start using ${u.to}.`)
|
|
892
|
+
$.ui.toast(`Backstep is updated to ${u.to}. Restart Claude Code to use it.`)
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// After a reload in the middle of an update: once the new version is in place,
|
|
896
|
+
// say so and take the "don't close" line down. Setup may still be finishing,
|
|
897
|
+
// so it looks again for a while before giving up.
|
|
898
|
+
async function finishUpdate($: EngineInterface) {
|
|
899
|
+
const u = await read($, updating)
|
|
900
|
+
if (!u) return
|
|
901
|
+
for (let i = 0; i < 60; i++) {
|
|
902
|
+
const ran = await $.process.run(backstep(['update-check']), { timeoutMs: 15_000 }).catch(() => null)
|
|
903
|
+
let installed: string | undefined
|
|
904
|
+
try {
|
|
905
|
+
installed = (JSON.parse(ran?.stdout || '{}') as { current?: string }).current
|
|
906
|
+
} catch {
|
|
907
|
+
installed = undefined
|
|
908
|
+
}
|
|
909
|
+
if (installed === u.to) {
|
|
910
|
+
// Setup may still be wrapping servers: a moment more before saying it's safe to close.
|
|
911
|
+
await $.clock.sleep(5000)
|
|
912
|
+
$.ui.status(undefined)
|
|
913
|
+
await update($, updating, () => null)
|
|
914
|
+
updated($, u)
|
|
915
|
+
return
|
|
916
|
+
}
|
|
917
|
+
await $.clock.sleep(5000)
|
|
918
|
+
}
|
|
919
|
+
$.ui.status(undefined)
|
|
920
|
+
await update($, updating, () => null)
|
|
921
|
+
$.ui.log(`${MARK_TEXT} Backstep couldn't confirm the update to ${u.to} finished. If undo doesn't work after restarting Claude Code, run: npx backstep@latest setup`)
|
|
922
|
+
}
|
|
923
|
+
|
|
791
924
|
// Installs a version, saying not to close the terminal while it runs and which version it ended on.
|
|
792
925
|
async function installUpdate($: EngineInterface, current: string | undefined, latest: string, forced = false): Promise<boolean> {
|
|
793
926
|
const u = { current, latest }
|
|
@@ -797,6 +930,7 @@ async function installUpdate($: EngineInterface, current: string | undefined, la
|
|
|
797
930
|
$.ui.log(`${MARK_TEXT} Updating Backstep${from} to ${u.latest}… Don't close ${win} until it says it's done (about a minute).`)
|
|
798
931
|
$.ui.toast(`Updating Backstep to ${u.latest}. Don't close ${win} until it's done.`, { timeoutMs: 15_000 })
|
|
799
932
|
$.ui.status(`◀◀ Updating Backstep to ${u.latest}… don't close ${win}`)
|
|
933
|
+
await update($, updating, () => ({ ...(u.current ? { from: u.current } : {}), to: latest, win }))
|
|
800
934
|
const done = await $.process
|
|
801
935
|
.run(backstep(['update', '--version', u.latest, '--json']), { timeoutMs: 600_000 })
|
|
802
936
|
.catch((err: unknown) => ({ exitCode: 1, stdout: '', stderr: err instanceof Error ? err.message : String(err) }))
|
|
@@ -807,9 +941,9 @@ async function installUpdate($: EngineInterface, current: string | undefined, la
|
|
|
807
941
|
// keep the error above
|
|
808
942
|
}
|
|
809
943
|
$.ui.status(undefined)
|
|
944
|
+
await update($, updating, () => null)
|
|
810
945
|
if (r.ok) {
|
|
811
|
-
|
|
812
|
-
$.ui.toast(`Backstep is updated to ${u.latest}. Restart Claude Code to use it.`)
|
|
946
|
+
updated($, { ...(u.current ? { from: u.current } : {}), to: latest, win })
|
|
813
947
|
return true
|
|
814
948
|
} else {
|
|
815
949
|
$.ui.log(`${MARK_TEXT} Backstep couldn't update to ${u.latest}: ${r.detail}. You're still on ${u.current ?? 'the version you had'}${forced ? '' : ', which keeps working'}. To try again, run: npx backstep@latest setup`)
|
|
@@ -1004,6 +1138,25 @@ export const register: Register = (on, options) => {
|
|
|
1004
1138
|
|
|
1005
1139
|
on('tool.call', async ($, e, next) => {
|
|
1006
1140
|
const tool = String(e.tool)
|
|
1141
|
+
// An opposite call Claude is making for undo, as the person asked: not recorded as a change of its own.
|
|
1142
|
+
const handed = handedOver.size ? undoCallKey(tool, Object.fromEntries(Object.entries(e).filter(([k]) => !RESERVED.has(k)))) : ''
|
|
1143
|
+
const forAction = handed ? handedOver.get(handed) : undefined
|
|
1144
|
+
if (forAction) {
|
|
1145
|
+
const r = (await next(e)) as { deny?: string; isError?: boolean; text?: string }
|
|
1146
|
+
if (!r.deny && !r.isError) {
|
|
1147
|
+
handedOver.delete(handed)
|
|
1148
|
+
if (![...handedOver.values()].includes(forAction)) {
|
|
1149
|
+
await $.process.run(backstep(['settle', forAction])).catch(() => null)
|
|
1150
|
+
await handedBack($, forAction, true)
|
|
1151
|
+
}
|
|
1152
|
+
} else {
|
|
1153
|
+
const why = (r.deny ?? r.text ?? 'it failed').trim().split('\n')[0]
|
|
1154
|
+
for (const [k, a] of handedOver) if (a === forAction) handedOver.delete(k)
|
|
1155
|
+
await $.process.run(backstep(['settle', forAction, '--failed', why])).catch(() => null)
|
|
1156
|
+
await handedBack($, forAction, false, why)
|
|
1157
|
+
}
|
|
1158
|
+
return r as never
|
|
1159
|
+
}
|
|
1007
1160
|
if (tool === CONNECT || tool === KEY || tool === UNINSTALL) return next(e)
|
|
1008
1161
|
if (EDIT_TOOLS.has(tool)) {
|
|
1009
1162
|
const input = e as { file_path?: string; notebook_path?: string }
|
|
@@ -1085,6 +1238,10 @@ export const register: Register = (on, options) => {
|
|
|
1085
1238
|
})
|
|
1086
1239
|
|
|
1087
1240
|
on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
|
|
1241
|
+
if (!resumed) {
|
|
1242
|
+
resumed = true
|
|
1243
|
+
void finishUpdate($)
|
|
1244
|
+
}
|
|
1088
1245
|
if (e.props.hasSurvey) return next(e)
|
|
1089
1246
|
const { Box, Button, Text } = $.ui.resolve(e)
|
|
1090
1247
|
// The logo keeps its own box with a fixed gap after it: a space inside the
|
|
@@ -1140,8 +1297,9 @@ export const register: Register = (on, options) => {
|
|
|
1140
1297
|
</Text>
|
|
1141
1298
|
{back.slice(0, 5).map((i, n) => (
|
|
1142
1299
|
<Text key={`b${n}`} dimColor>
|
|
1143
|
-
{' ◀ '}
|
|
1300
|
+
{i.pending ? ' … ' : ' ◀ '}
|
|
1144
1301
|
{i.label}
|
|
1302
|
+
{i.pending ? ' (Claude is finishing this)' : ''}
|
|
1145
1303
|
</Text>
|
|
1146
1304
|
))}
|
|
1147
1305
|
{back.length >= 5 && more > 0 ? <Text dimColor>{` and ${more} more (all listed in the conversation)`}</Text> : null}
|
package/mod/types/index.d.ts
CHANGED
|
@@ -18,9 +18,13 @@ export type Turn = {
|
|
|
18
18
|
lostItems?: string[]
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
// `pending`: handed to Claude to finish (auto mode wouldn't let Backstep make the call); `action`: the change it undoes.
|
|
22
|
+
export type RewindItem = { label: string; ok: boolean; pending?: boolean; action?: string }
|
|
22
23
|
|
|
23
24
|
// The animation above the prompt while a turn is being rewound.
|
|
25
|
+
// An update under way, kept across the reload it causes, so the reloaded mod can say it's done.
|
|
26
|
+
export type Updating = { from?: string; to: string; win: string }
|
|
27
|
+
|
|
24
28
|
export type Rewind = {
|
|
25
29
|
phase: 'running' | 'done' | 'failed' | 'empty'
|
|
26
30
|
frame: number
|
|
@@ -38,6 +42,6 @@ declare module 'claude-code' {
|
|
|
38
42
|
// keys: the keys bound to undo, kept here so the band redraws when they change.
|
|
39
43
|
// githubCode: the code GitHub's approval page wants, shown on the band until GitHub answers.
|
|
40
44
|
// blocked: why ctrl+u can't run, when getbackstep.com says this version is too old or a sign-in is needed.
|
|
41
|
-
backstep: { turns: Turn[]; rewind: Rewind | null; note: string | null; armed: number | null; keys: string[]; githubCode: GithubCode | null; blocked: string | null }
|
|
45
|
+
backstep: { turns: Turn[]; rewind: Rewind | null; note: string | null; armed: number | null; keys: string[]; githubCode: GithubCode | null; blocked: string | null; updating: Updating | null }
|
|
42
46
|
}
|
|
43
47
|
}
|