backstep 0.1.31 → 0.1.32
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/adapters/tree.js +5 -1
- package/dist/cli.js +4 -3
- package/dist/shell.js +2 -0
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.test.tsx +11 -4
- package/mod/hooks/register.tsx +35 -14
- package/package.json +1 -1
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";
|
|
@@ -520,13 +520,14 @@ async function main() {
|
|
|
520
520
|
const kind = plan.kind === "outbound" && saved && /database|request/.test(plan.reason ?? "") ? "write" : plan.kind;
|
|
521
521
|
if (kind !== plan.kind)
|
|
522
522
|
store.updateAction(a.id, { kind: "reversible", reason: null });
|
|
523
|
+
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
524
|
if (!undo && kind !== "outbound")
|
|
524
|
-
store.updateAction(a.id, { kind: "write", reason:
|
|
525
|
+
store.updateAction(a.id, { kind: "write", reason: unsaved });
|
|
525
526
|
if (missed.length)
|
|
526
527
|
store.updateAction(a.id, { reason: `couldn't save the database first: ${missed[0]}` });
|
|
527
528
|
if (dbs.length)
|
|
528
529
|
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 }));
|
|
530
|
+
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
531
|
return;
|
|
531
532
|
}
|
|
532
533
|
// 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",
|
|
@@ -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
|
|
|
@@ -1355,7 +1361,8 @@ test("when auto mode won't let Backstep undo a Notion change, Claude is asked to
|
|
|
1355
1361
|
// Claude makes the call: it isn't recorded as a change of its own, and the change is marked undone.
|
|
1356
1362
|
const before = ran.length
|
|
1357
1363
|
const watched = runs.filter((r) => r.argv.includes('watch-start')).length
|
|
1358
|
-
|
|
1364
|
+
// Claude adds an argument of its own (Notion's telemetry): still the same call.
|
|
1365
|
+
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
1366
|
expect(ran.length).toBe(before + 1)
|
|
1360
1367
|
expect(runs.filter((r) => r.argv.includes('watch-start')).length).toBe(watched)
|
|
1361
1368
|
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(/(?<=\.) /)
|
|
@@ -1139,19 +1157,22 @@ export const register: Register = (on, options) => {
|
|
|
1139
1157
|
on('tool.call', async ($, e, next) => {
|
|
1140
1158
|
const tool = String(e.tool)
|
|
1141
1159
|
// 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
|
-
|
|
1160
|
+
const handed = handedOver.length ? handedCall(tool, Object.fromEntries(Object.entries(e).filter(([k]) => !RESERVED.has(k)))) : undefined
|
|
1161
|
+
if (handed) {
|
|
1162
|
+
const forAction = handed.action
|
|
1145
1163
|
const r = (await next(e)) as { deny?: string; isError?: boolean; text?: string }
|
|
1164
|
+
const drop = (which: (h: Handed) => boolean) => {
|
|
1165
|
+
for (let i = handedOver.length - 1; i >= 0; i--) if (which(handedOver[i])) handedOver.splice(i, 1)
|
|
1166
|
+
}
|
|
1146
1167
|
if (!r.deny && !r.isError) {
|
|
1147
|
-
|
|
1148
|
-
if (!
|
|
1168
|
+
drop((h) => h === handed)
|
|
1169
|
+
if (!handedOver.some((h) => h.action === forAction)) {
|
|
1149
1170
|
await $.process.run(backstep(['settle', forAction])).catch(() => null)
|
|
1150
1171
|
await handedBack($, forAction, true)
|
|
1151
1172
|
}
|
|
1152
1173
|
} else {
|
|
1153
1174
|
const why = (r.deny ?? r.text ?? 'it failed').trim().split('\n')[0]
|
|
1154
|
-
|
|
1175
|
+
drop((h) => h.action === forAction)
|
|
1155
1176
|
await $.process.run(backstep(['settle', forAction, '--failed', why])).catch(() => null)
|
|
1156
1177
|
await handedBack($, forAction, false, why)
|
|
1157
1178
|
}
|