backstep 0.1.31 → 0.1.33
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/CHANGES.json +41 -0
- package/README.md +1 -1
- package/dist/adapters/tree.js +5 -1
- package/dist/cli.js +20 -4
- package/dist/shell.js +2 -0
- package/dist/update.js +11 -1
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.test.tsx +15 -4
- package/mod/hooks/register.tsx +55 -17
- package/package.json +3 -2
package/CHANGES.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.1.33",
|
|
4
|
+
"notes": [
|
|
5
|
+
"After an update, Backstep shows what's new, like this."
|
|
6
|
+
]
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.1.32",
|
|
10
|
+
"notes": [
|
|
11
|
+
"Undoing a Notion change in auto mode now finishes properly when Claude makes the last step.",
|
|
12
|
+
"Opening a page, file or app is no longer listed as something that couldn't be undone.",
|
|
13
|
+
"When something can't be undone, Backstep says why in plain words."
|
|
14
|
+
]
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"version": "0.1.31",
|
|
18
|
+
"notes": [
|
|
19
|
+
"In auto mode, Notion undos are finished by Claude straight away, with no extra question and no red error."
|
|
20
|
+
]
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"version": "0.1.30",
|
|
24
|
+
"notes": [
|
|
25
|
+
"Updates now say when they're done and it's safe to close the terminal.",
|
|
26
|
+
"Notion undos work in auto mode."
|
|
27
|
+
]
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"version": "0.1.29",
|
|
31
|
+
"notes": [
|
|
32
|
+
"If you edited a file after Claude's turn, undo asks before taking your changes along. Keep my changes undoes only Claude's."
|
|
33
|
+
]
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"version": "0.1.28",
|
|
37
|
+
"notes": [
|
|
38
|
+
"Updates install the new version even when Claude Code is open in a project named backstep."
|
|
39
|
+
]
|
|
40
|
+
}
|
|
41
|
+
]
|
package/README.md
CHANGED
|
@@ -70,7 +70,7 @@ The undo key is ctrl+u (not ctrl+z: Claude Code suspends itself on ctrl+z in a t
|
|
|
70
70
|
|
|
71
71
|
In the Claude Code desktop app, keys don't reach Backstep, so the line above the message box has an **Undo last turn** button to click instead (it asks once, then undoes). Typing `/backstep-undo` works everywhere and undoes straight away.
|
|
72
72
|
|
|
73
|
-
Backstep checks npm for a newer version as each Claude Code session starts and asks before updating (Update, Not now, or Skip this version; `backstep update` does it by hand, `BACKSTEP_UPDATE_CHECK=0` turns the check off).
|
|
73
|
+
Backstep checks npm for a newer version as each Claude Code session starts and asks before updating (Update, Not now, or Skip this version; `backstep update` does it by hand, `BACKSTEP_UPDATE_CHECK=0` turns the check off). Once an update is in place, Backstep says what's new, from `CHANGES.json`.
|
|
74
74
|
|
|
75
75
|
Other commands: `backstep ui`, `backstep log`, `backstep undo <action-id>`, `backstep undo --run <run-id>`, `backstep connect [supabase|firebase]` (connects what the project uses, the same as asking Claude), `backstep disconnect supabase <ref>`.
|
|
76
76
|
|
package/dist/adapters/tree.js
CHANGED
|
@@ -195,6 +195,7 @@ function projectGit(root, actionId, pushes) {
|
|
|
195
195
|
// Returns null when the folder can't be snapshotted: the home folder or the
|
|
196
196
|
// whole disk, or a folder too big to stage in time.
|
|
197
197
|
export function snapshotTree(actionId, cwd, opts = {}) {
|
|
198
|
+
lastTreeError = null;
|
|
198
199
|
const root = projectRoot(cwd);
|
|
199
200
|
if (isDiskRoot(root) || root === homedir() || root === resolve(homeDir()))
|
|
200
201
|
return null;
|
|
@@ -206,10 +207,13 @@ export function snapshotTree(actionId, cwd, opts = {}) {
|
|
|
206
207
|
git(["update-ref", `refs/backstep/${actionId}`, commit], { gitDir, workTree: root });
|
|
207
208
|
return { adapter: "tree", root, gitDir, commit, git: projectGit(root, actionId, opts.pushes === true) };
|
|
208
209
|
}
|
|
209
|
-
catch {
|
|
210
|
+
catch (err) {
|
|
211
|
+
lastTreeError = (err instanceof Error ? err.stderr || err.message : String(err)).trim().split("\n").find(Boolean) ?? null;
|
|
210
212
|
return null;
|
|
211
213
|
}
|
|
212
214
|
}
|
|
215
|
+
// Why the last snapshot couldn't be made, in git's words: null when it was too big or the folder is off limits.
|
|
216
|
+
export let lastTreeError = null;
|
|
213
217
|
// Once the command has run: the folder as it left it, kept like the snapshot before it.
|
|
214
218
|
export function recordTreeAfter(u, actionId) {
|
|
215
219
|
const tree = stage(u.gitDir, u.root);
|
package/dist/cli.js
CHANGED
|
@@ -6,7 +6,7 @@ import { startUi } from "./ui/server.js";
|
|
|
6
6
|
import { Store, snapshotDir } from "./store.js";
|
|
7
7
|
import { pendingManual, pruneDatabaseCopies, undoAction, undoRun, undoSince } from "./undo.js";
|
|
8
8
|
import { insideDir, recordAfter, snapshotPaths } from "./adapters/fs.js";
|
|
9
|
-
import { notePushed, recordTreeAfter, snapshotSqlite, snapshotTree } from "./adapters/tree.js";
|
|
9
|
+
import { lastTreeError, notePushed, recordTreeAfter, snapshotSqlite, snapshotTree } from "./adapters/tree.js";
|
|
10
10
|
import { revertPush } from "./push.js";
|
|
11
11
|
import { ghMade, grantScope, openPage, undoGh } from "./github.js";
|
|
12
12
|
import { accessNow, accessOnline, currentNote, login, logout, requireAccess } from "./account.js";
|
|
@@ -57,7 +57,7 @@ import { connect } from "./connect.js";
|
|
|
57
57
|
import { check, printCheck } from "./check.js";
|
|
58
58
|
import { logo } from "./look.js";
|
|
59
59
|
import { keyLabel, setUndoKey, undoKeys } from "./keys.js";
|
|
60
|
-
import { checkUpdate, runUpdate, skipVersion } from "./update.js";
|
|
60
|
+
import { changesSince, checkUpdate, runUpdate, skipVersion } from "./update.js";
|
|
61
61
|
import { askConnectionString } from "./setup.js";
|
|
62
62
|
// Header values can name env vars, so tokens don't have to sit in agent config files.
|
|
63
63
|
function parseHeaders(list) {
|
|
@@ -124,6 +124,8 @@ const HELP = `backstep: undo for everything your agents touch
|
|
|
124
124
|
backstep undo --since <ms> [--until <ms>] [--json] [--via-claude-code] [--manual ask|undo|keep]
|
|
125
125
|
undo everything any agent did in that window; files changed since
|
|
126
126
|
(often by hand) are undone too, kept, or named first with ask
|
|
127
|
+
backstep changes --since <version> [--json]
|
|
128
|
+
what's new since that version
|
|
127
129
|
backstep after <action-id> note how a tool left the files, once it ran (used by the Claude Code mod)
|
|
128
130
|
backstep snap --session <id> --tool <name> [--cwd <dir>] -- <paths...>
|
|
129
131
|
save paths before Claude Code's own tools change them (used by the Claude Code mod)
|
|
@@ -340,6 +342,19 @@ async function main() {
|
|
|
340
342
|
return;
|
|
341
343
|
}
|
|
342
344
|
// `backstep update` installs the newest version; --skip <v> stops asking about that one.
|
|
345
|
+
// What's new since a version, for the mod to show once an update is in place.
|
|
346
|
+
if (cmd === "changes") {
|
|
347
|
+
const { values } = parseArgs({ args: rest, options: { since: { type: "string" }, json: { type: "boolean", default: false } } });
|
|
348
|
+
if (!values.since)
|
|
349
|
+
throw new Error("usage: backstep changes --since <version> [--json]");
|
|
350
|
+
const list = changesSince(values.since);
|
|
351
|
+
if (values.json)
|
|
352
|
+
console.log(JSON.stringify(list));
|
|
353
|
+
else
|
|
354
|
+
for (const c of list)
|
|
355
|
+
console.log(`${c.version}\n${c.notes.map((n) => ` - ${n}`).join("\n")}`);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
343
358
|
if (cmd === "update") {
|
|
344
359
|
const { values } = parseArgs({ args: rest, options: { skip: { type: "string" }, version: { type: "string" }, json: { type: "boolean", default: false } } });
|
|
345
360
|
if (values.skip) {
|
|
@@ -520,13 +535,14 @@ async function main() {
|
|
|
520
535
|
const kind = plan.kind === "outbound" && saved && /database|request/.test(plan.reason ?? "") ? "write" : plan.kind;
|
|
521
536
|
if (kind !== plan.kind)
|
|
522
537
|
store.updateAction(a.id, { kind: "reversible", reason: null });
|
|
538
|
+
const unsaved = lastTreeError ? `Backstep couldn't save a copy first (${lastTreeError})` : "Backstep couldn't save a copy first (the folder is too big, or it's your whole home folder)";
|
|
523
539
|
if (!undo && kind !== "outbound")
|
|
524
|
-
store.updateAction(a.id, { kind: "write", reason:
|
|
540
|
+
store.updateAction(a.id, { kind: "write", reason: unsaved });
|
|
525
541
|
if (missed.length)
|
|
526
542
|
store.updateAction(a.id, { reason: `couldn't save the database first: ${missed[0]}` });
|
|
527
543
|
if (dbs.length)
|
|
528
544
|
await pruneDatabaseCopies(store).catch(() => { });
|
|
529
|
-
console.log(JSON.stringify({ kind, reason: kind === plan.kind ? plan.reason : undefined, id: a.id, undoable: undo !== null, db: saved }));
|
|
545
|
+
console.log(JSON.stringify({ kind, reason: !undo && kind !== "outbound" ? unsaved : kind === plan.kind ? plan.reason : undefined, id: a.id, undoable: undo !== null, db: saved }));
|
|
530
546
|
return;
|
|
531
547
|
}
|
|
532
548
|
// Before a shell command, from the Claude Code mod: does it reach a Supabase project Backstep
|
package/dist/shell.js
CHANGED
|
@@ -10,6 +10,8 @@ const READ_PROGRAMS = new Set([
|
|
|
10
10
|
"cut", "tr", "column", "jq", "yq", "date", "whoami", "id", "uname", "env", "printenv", "ps", "top", "uptime",
|
|
11
11
|
"basename", "dirname", "realpath", "readlink", "md5", "md5sum", "shasum", "sha256sum", "true", "false", "test", "[",
|
|
12
12
|
"sleep", "man", "tldr", "history", "lsof", "hostname", "cal", "nproc",
|
|
13
|
+
// Opening a page, a file or an app in it changes nothing here by itself.
|
|
14
|
+
"open", "xdg-open",
|
|
13
15
|
]);
|
|
14
16
|
const READ_GIT = new Set([
|
|
15
17
|
"status", "log", "diff", "show", "blame", "grep", "ls-files", "ls-tree", "rev-parse", "describe", "shortlog",
|
package/dist/update.js
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
// Off with BACKSTEP_UPDATE_CHECK=0.
|
|
5
5
|
import { spawnSync } from "./spawn.js";
|
|
6
6
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
-
import { join } from "node:path";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
8
9
|
import { tmpdir } from "node:os";
|
|
9
10
|
import { homeDir } from "./store.js";
|
|
10
11
|
import { version } from "./telemetry.js";
|
|
@@ -48,6 +49,15 @@ export async function checkUpdate() {
|
|
|
48
49
|
const available = latest !== null && newer(latest, current) && settings().skipVersion !== latest;
|
|
49
50
|
return { current, latest, available };
|
|
50
51
|
}
|
|
52
|
+
export function changesSince(from, upTo = version()) {
|
|
53
|
+
try {
|
|
54
|
+
const all = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "CHANGES.json"), "utf8"));
|
|
55
|
+
return all.filter((c) => newer(c.version, from) && !newer(c.version, upTo)).sort((a, b) => (newer(a.version, b.version) ? -1 : 1));
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
}
|
|
51
61
|
export function skipVersion(v) {
|
|
52
62
|
mkdirSync(homeDir(), { recursive: true });
|
|
53
63
|
writeFileSync(file(), JSON.stringify({ ...settings(), skipVersion: v }, null, 2) + "\n");
|
|
@@ -373,9 +373,14 @@ test('a Notion move: the pages are read first, and undo runs every opposite call
|
|
|
373
373
|
await ui.unmount()
|
|
374
374
|
})
|
|
375
375
|
|
|
376
|
-
test('when the opposite call fails, the rewind says so', { options: { cli: CLI } }, async ($, on) => {
|
|
376
|
+
test('when the opposite call fails, the rewind says so, plainly', { options: { cli: CLI } }, async ($, on) => {
|
|
377
377
|
const clock = mock.clock(on, { now: 5000 })
|
|
378
|
-
const { runs } = connector(on, {
|
|
378
|
+
const { runs } = connector(on, {
|
|
379
|
+
start: { kind: 'reversible', id: 'w1' },
|
|
380
|
+
undoable: true,
|
|
381
|
+
mcpFails: true,
|
|
382
|
+
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'] }, label: 'Notion: moved the pages Claude made to Trash' } },
|
|
383
|
+
})
|
|
379
384
|
|
|
380
385
|
await $.prompt.submit({ text: 'make a page', wait: false, origin: { kind: 'composer' } })
|
|
381
386
|
await $.tool.call({ tool: 'mcp__claude_ai_Notion__create-page', title: 'Plan' } as never)
|
|
@@ -385,7 +390,8 @@ test('when the opposite call fails, the rewind says so', { options: { cli: CLI }
|
|
|
385
390
|
await clock.advance(1000)
|
|
386
391
|
await pressed
|
|
387
392
|
expect(runs.at(-1)?.argv.slice(3)).toEqual(['settle', 'w1', '--failed', 'no such page'])
|
|
388
|
-
expect((await ui.find({ type: 'Text', text: /no such page/ }))).
|
|
393
|
+
expect((await ui.find({ type: 'Text', text: /no such page/ }))?.text).toContain(`Couldn't move the pages Claude made to Trash: Notion said "no such page"`)
|
|
394
|
+
expect(await ui.find({ type: 'Text', text: /notion-delete-pages failed/ })).toBeFalsy()
|
|
389
395
|
await ui.unmount()
|
|
390
396
|
})
|
|
391
397
|
|
|
@@ -1271,6 +1277,8 @@ test('an update that reloads the mod partway is finished by the reloaded mod: it
|
|
|
1271
1277
|
}
|
|
1272
1278
|
const stdout = argv.includes('update-check')
|
|
1273
1279
|
? JSON.stringify({ current: started ? '0.2.0' : '0.1.2', latest: '0.2.0', available: !started })
|
|
1280
|
+
: argv.includes('changes')
|
|
1281
|
+
? JSON.stringify(argv.includes('0.1.2') ? [{ version: '0.2.0', notes: ['Undo is faster.', 'Notion undos finish in auto mode.'] }] : [])
|
|
1274
1282
|
: argv.includes('status')
|
|
1275
1283
|
? JSON.stringify({ items: [], suggest: [] })
|
|
1276
1284
|
: '[]'
|
|
@@ -1314,6 +1322,8 @@ test('an update that reloads the mod partway is finished by the reloaded mod: it
|
|
|
1314
1322
|
}
|
|
1315
1323
|
expect(statuses.at(-1)).toBeUndefined()
|
|
1316
1324
|
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.')
|
|
1325
|
+
// Then what changed, from the new version's own notes.
|
|
1326
|
+
expect(logs.join('\n')).toContain("What's new in 0.2.0:\n • Undo is faster.\n • Notion undos finish in auto mode.")
|
|
1317
1327
|
await ui.unmount()
|
|
1318
1328
|
})
|
|
1319
1329
|
|
|
@@ -1355,7 +1365,8 @@ test("when auto mode won't let Backstep undo a Notion change, Claude is asked to
|
|
|
1355
1365
|
// Claude makes the call: it isn't recorded as a change of its own, and the change is marked undone.
|
|
1356
1366
|
const before = ran.length
|
|
1357
1367
|
const watched = runs.filter((r) => r.argv.includes('watch-start')).length
|
|
1358
|
-
|
|
1368
|
+
// Claude adds an argument of its own (Notion's telemetry): still the same call.
|
|
1369
|
+
await $.tool.call({ tool: 'mcp__claude_ai_Notion__notion-delete-pages', page_ids: ['p1'], user_confirmed: true, telemetry: { intent: 'undo' }, tool_use_id: 'tu2' } as never)
|
|
1359
1370
|
expect(ran.length).toBe(before + 1)
|
|
1360
1371
|
expect(runs.filter((r) => r.argv.includes('watch-start')).length).toBe(watched)
|
|
1361
1372
|
expect(runs.at(-1)?.argv.slice(3)).toEqual(['settle', 'w1'])
|
package/mod/hooks/register.tsx
CHANGED
|
@@ -395,7 +395,7 @@ async function undoLastTurn($: EngineInterface, count = 1) {
|
|
|
395
395
|
}
|
|
396
396
|
if (failed) {
|
|
397
397
|
o.ok = false
|
|
398
|
-
o.detail =
|
|
398
|
+
o.detail = couldnt(run.label, failed.why, `undo ${o.tool}`)
|
|
399
399
|
await $.process.run(backstep(['settle', o.id, '--failed', failed.why])).catch(() => null)
|
|
400
400
|
break
|
|
401
401
|
}
|
|
@@ -415,7 +415,7 @@ async function undoLastTurn($: EngineInterface, count = 1) {
|
|
|
415
415
|
.filter(Boolean)
|
|
416
416
|
.map((line): RewindItem => (o.pending ? { label: undoneLabel(line, short), ok: true, pending: true, action: o.id } : { label: undoneLabel(line, short), ok: true })),
|
|
417
417
|
)
|
|
418
|
-
const stopped = outcomes.filter((o) => !o.ok).map((o) =>
|
|
418
|
+
const stopped = outcomes.filter((o) => !o.ok).map((o) => { const line = o.detail.split('\n')[0] ?? 'failed'; return { label: line.startsWith("Couldn't ") ? line : `${o.tool}: ${line}`, ok: false } })
|
|
419
419
|
// A push, or something made on GitHub, is offered back below, so it isn't listed as lost.
|
|
420
420
|
const offered = outcomes.some((o) => o.ok && o.pushed)
|
|
421
421
|
const offeredGh = outcomes.some((o) => o.ok && o.github?.length)
|
|
@@ -599,13 +599,31 @@ function undoneLabel(line: string, short: (p: string) => string): string {
|
|
|
599
599
|
return `${p} ${m[1]}`
|
|
600
600
|
}
|
|
601
601
|
|
|
602
|
-
// Opposite calls handed to Claude to make
|
|
603
|
-
//
|
|
604
|
-
//
|
|
605
|
-
|
|
602
|
+
// Opposite calls handed to Claude to make after the person pressed undo: the call
|
|
603
|
+
// and the action it undoes. Not recorded as Claude's own, and once all of an
|
|
604
|
+
// action's have run, it counts as undone.
|
|
605
|
+
type Handed = { tool: string; args: Record<string, unknown>; action: string }
|
|
606
|
+
const handedOver: Handed[] = []
|
|
607
|
+
|
|
608
|
+
// Claude's call is the one handed over when it's the same tool with the same
|
|
609
|
+
// values for every argument Backstep gave; Claude adds its own (Notion's telemetry, say).
|
|
610
|
+
function handedCall(tool: string, args: Record<string, unknown>): Handed | undefined {
|
|
611
|
+
return handedOver.find((h) => h.tool === tool && Object.entries(h.args).every(([k, v]) => undoCallKey('', v) === undoCallKey('', args[k])))
|
|
612
|
+
}
|
|
606
613
|
|
|
607
614
|
type Handoff = { id: string; server: string; calls: { tool: string; args: Record<string, unknown> }[]; label: string }
|
|
608
615
|
|
|
616
|
+
// A change that didn't come back, said plainly: "Notion: moved the pages Claude made to Trash"
|
|
617
|
+
// that failed reads "Couldn't move the pages Claude made to Trash: Notion said "no such page"".
|
|
618
|
+
const INFINITIVE: Record<string, string> = { moved: 'move', restored: 'restore', put: 'put', archived: 'archive', deleted: 'delete', removed: 'remove', ran: 'run', closed: 'close', reopened: 'reopen', set: 'set' }
|
|
619
|
+
function couldnt(label: string | undefined, why: string, fallback: string): string {
|
|
620
|
+
const reason = why.replace(/^(backstep|error):\s*/i, '').split(/(?<=\.)\s/)[0].slice(0, 140)
|
|
621
|
+
const m = label?.match(/^(?:([A-Z][\w.]*(?: [A-Z][\w.]*)*): )?(\w+)\s(.*)$/)
|
|
622
|
+
const verb = m ? INFINITIVE[m[2]] : undefined
|
|
623
|
+
const what = m && verb ? `${verb} ${m[3]}` : fallback
|
|
624
|
+
return m?.[1] ? `Couldn't ${what}: ${m[1]} said "${reason}"` : `Couldn't ${what}: ${reason}`
|
|
625
|
+
}
|
|
626
|
+
|
|
609
627
|
// Said after "Rewound …" while Claude still has some of it to finish.
|
|
610
628
|
function finishing(items: RewindItem[]): string {
|
|
611
629
|
const n = items.filter((i) => i.pending).length
|
|
@@ -620,7 +638,7 @@ async function handOff($: EngineInterface, list: Handoff[]) {
|
|
|
620
638
|
for (const h of list) {
|
|
621
639
|
const server = toolServer(h.server)
|
|
622
640
|
for (const c of h.calls) {
|
|
623
|
-
handedOver.
|
|
641
|
+
handedOver.push({ tool: `mcp__${server}__${c.tool}`, args: c.args, action: h.id })
|
|
624
642
|
steps.push(`- mcp__${server}__${c.tool} with ${JSON.stringify(c.args)}`)
|
|
625
643
|
}
|
|
626
644
|
}
|
|
@@ -638,7 +656,7 @@ async function handOff($: EngineInterface, list: Handoff[]) {
|
|
|
638
656
|
async function handedBack($: EngineInterface, action: string, ok: boolean, why?: string) {
|
|
639
657
|
await update($, rewind, (r) => {
|
|
640
658
|
if (!r) return r
|
|
641
|
-
const items = r.items.map((i) => (i.pending && i.action === action ? (ok ? { ...i, pending: false } : { label:
|
|
659
|
+
const items = r.items.map((i) => (i.pending && i.action === action ? (ok ? { ...i, pending: false } : { label: couldnt(i.label, why ?? 'it failed', 'finish undoing it'), ok: false }) : i))
|
|
642
660
|
const undone = items.filter((i) => i.ok)
|
|
643
661
|
// "Rewound 2 changes from the last turn. Claude is finishing it." loses its last sentence once Claude has.
|
|
644
662
|
const [rewound, ...rest] = r.message.replace(/ Claude is finishing [^.]*\./, '').split(/(?<=\.) /)
|
|
@@ -887,9 +905,26 @@ async function offerUpdate($: EngineInterface) {
|
|
|
887
905
|
await installUpdate($, u.current, u.latest)
|
|
888
906
|
}
|
|
889
907
|
|
|
890
|
-
function updated($: EngineInterface, u: Updating) {
|
|
908
|
+
async function updated($: EngineInterface, u: Updating) {
|
|
891
909
|
$.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
910
|
$.ui.toast(`Backstep is updated to ${u.to}. Restart Claude Code to use it.`)
|
|
911
|
+
if (u.from) await whatsNew($, u.from)
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// What changed since the version before, from the new version's own notes.
|
|
915
|
+
async function whatsNew($: EngineInterface, from: string) {
|
|
916
|
+
const ran = await $.process.run(backstep(['changes', '--since', from, '--json']), { timeoutMs: 15_000 }).catch(() => null)
|
|
917
|
+
let list: { version: string; notes: string[] }[] = []
|
|
918
|
+
try {
|
|
919
|
+
list = JSON.parse(ran?.stdout || '[]') as typeof list
|
|
920
|
+
} catch {
|
|
921
|
+
return
|
|
922
|
+
}
|
|
923
|
+
list = list.filter((c) => c.notes?.length)
|
|
924
|
+
if (!list.length) return
|
|
925
|
+
const one = list.length === 1
|
|
926
|
+
const lines = one ? list[0].notes.map((n) => ` • ${n}`) : list.flatMap((c) => [` ${c.version}`, ...c.notes.map((n) => ` • ${n}`)])
|
|
927
|
+
$.ui.log([`${MARK_TEXT} What's new${one ? ` in ${list[0].version}` : ` since ${from}`}:`, ...lines].join('\n'))
|
|
893
928
|
}
|
|
894
929
|
|
|
895
930
|
// After a reload in the middle of an update: once the new version is in place,
|
|
@@ -911,7 +946,7 @@ async function finishUpdate($: EngineInterface) {
|
|
|
911
946
|
await $.clock.sleep(5000)
|
|
912
947
|
$.ui.status(undefined)
|
|
913
948
|
await update($, updating, () => null)
|
|
914
|
-
updated($, u)
|
|
949
|
+
await updated($, u)
|
|
915
950
|
return
|
|
916
951
|
}
|
|
917
952
|
await $.clock.sleep(5000)
|
|
@@ -943,7 +978,7 @@ async function installUpdate($: EngineInterface, current: string | undefined, la
|
|
|
943
978
|
$.ui.status(undefined)
|
|
944
979
|
await update($, updating, () => null)
|
|
945
980
|
if (r.ok) {
|
|
946
|
-
updated($, { ...(u.current ? { from: u.current } : {}), to: latest, win })
|
|
981
|
+
await updated($, { ...(u.current ? { from: u.current } : {}), to: latest, win })
|
|
947
982
|
return true
|
|
948
983
|
} else {
|
|
949
984
|
$.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`)
|
|
@@ -1139,19 +1174,22 @@ export const register: Register = (on, options) => {
|
|
|
1139
1174
|
on('tool.call', async ($, e, next) => {
|
|
1140
1175
|
const tool = String(e.tool)
|
|
1141
1176
|
// An opposite call Claude is making for undo, as the person asked: not recorded as a change of its own.
|
|
1142
|
-
const handed = handedOver.
|
|
1143
|
-
|
|
1144
|
-
|
|
1177
|
+
const handed = handedOver.length ? handedCall(tool, Object.fromEntries(Object.entries(e).filter(([k]) => !RESERVED.has(k)))) : undefined
|
|
1178
|
+
if (handed) {
|
|
1179
|
+
const forAction = handed.action
|
|
1145
1180
|
const r = (await next(e)) as { deny?: string; isError?: boolean; text?: string }
|
|
1181
|
+
const drop = (which: (h: Handed) => boolean) => {
|
|
1182
|
+
for (let i = handedOver.length - 1; i >= 0; i--) if (which(handedOver[i])) handedOver.splice(i, 1)
|
|
1183
|
+
}
|
|
1146
1184
|
if (!r.deny && !r.isError) {
|
|
1147
|
-
|
|
1148
|
-
if (!
|
|
1185
|
+
drop((h) => h === handed)
|
|
1186
|
+
if (!handedOver.some((h) => h.action === forAction)) {
|
|
1149
1187
|
await $.process.run(backstep(['settle', forAction])).catch(() => null)
|
|
1150
1188
|
await handedBack($, forAction, true)
|
|
1151
1189
|
}
|
|
1152
1190
|
} else {
|
|
1153
1191
|
const why = (r.deny ?? r.text ?? 'it failed').trim().split('\n')[0]
|
|
1154
|
-
|
|
1192
|
+
drop((h) => h.action === forAction)
|
|
1155
1193
|
await $.process.run(backstep(['settle', forAction, '--failed', why])).catch(() => null)
|
|
1156
1194
|
await handedBack($, forAction, false, why)
|
|
1157
1195
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "backstep",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.33",
|
|
4
4
|
"description": "Undo for everything your agents touch: an MCP proxy that makes agent actions reversible.",
|
|
5
5
|
"homepage": "https://getbackstep.com",
|
|
6
6
|
"scripts": {
|
|
@@ -37,7 +37,8 @@
|
|
|
37
37
|
"files": [
|
|
38
38
|
"dist",
|
|
39
39
|
"mod",
|
|
40
|
-
"README.md"
|
|
40
|
+
"README.md",
|
|
41
|
+
"CHANGES.json"
|
|
41
42
|
],
|
|
42
43
|
"keywords": [
|
|
43
44
|
"mcp",
|