backstep 0.1.30 → 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 +31 -14
- package/mod/hooks/register.tsx +105 -37
- package/mod/types/index.d.ts +2 -1
- 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
|
|
|
@@ -1317,24 +1323,23 @@ test('an update that reloads the mod partway is finished by the reloaded mod: it
|
|
|
1317
1323
|
await ui.unmount()
|
|
1318
1324
|
})
|
|
1319
1325
|
|
|
1320
|
-
|
|
1326
|
+
// Auto mode refuses Backstep's own call: pressing undo was the go-ahead, so Claude is asked to make it.
|
|
1327
|
+
async function refusedNotionUndo($: Parameters<Parameters<typeof test>[2]>[0], on: On) {
|
|
1321
1328
|
const clock = mock.clock(on, { now: 5000 })
|
|
1322
1329
|
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"
|
|
1323
1330
|
const asked: string[] = []
|
|
1324
1331
|
on('tool.call', { tool: 'AskUserQuestion' }, (_, e) => {
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
return { result: { questions, answers: { [questions[0].question]: 'Ask Claude to do it' } } }
|
|
1332
|
+
asked.push((e as unknown as { questions: { question: string }[] }).questions[0].question)
|
|
1333
|
+
return { result: {} }
|
|
1328
1334
|
})
|
|
1329
|
-
const
|
|
1335
|
+
const env = connector(on, {
|
|
1330
1336
|
start: { kind: 'reversible', id: 'w1' },
|
|
1331
1337
|
undoable: true,
|
|
1332
1338
|
mcpThrows: refusal,
|
|
1333
|
-
undo: { id: 'w1', tool: 'notion-create-pages', ok: true, detail: '
|
|
1339
|
+
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
1340
|
})
|
|
1335
1341
|
await $.prompt.submit({ text: 'make a page', wait: false, origin: { kind: 'composer' } })
|
|
1336
1342
|
await $.tool.call({ tool: 'mcp__claude_ai_Notion__notion-create-pages', pages: [{ title: 'Test' }], tool_use_id: 'tu1' } as never)
|
|
1337
|
-
|
|
1338
1343
|
const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
|
|
1339
1344
|
await ui.press({ key: 'undo' })
|
|
1340
1345
|
const pressed = ui.press({ key: 'undo' })
|
|
@@ -1342,16 +1347,28 @@ test("when auto mode won't let Backstep undo a Notion change, Claude is asked to
|
|
|
1342
1347
|
await pressed
|
|
1343
1348
|
await clock.advance(1000)
|
|
1344
1349
|
await settle()
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1350
|
+
return { ...env, asked, ui }
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
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) => {
|
|
1354
|
+
const { runs, ran, prompts, asked, ui } = await refusedNotionUndo($, on)
|
|
1355
|
+
expect(asked).toEqual([])
|
|
1356
|
+
expect(runs.some((r) => r.argv.includes('--failed'))).toBe(false)
|
|
1357
|
+
expect(prompts.at(-1)).toContain('mcp__claude_ai_Notion__notion-delete-pages with {"page_ids":["p1"],"user_confirmed":true}')
|
|
1358
|
+
expect((await ui.find({ type: 'Text', text: /Rewound/ }))?.text).toContain('Rewound 1 change from the last turn. Claude is finishing it.')
|
|
1359
|
+
expect(await ui.find({ type: 'Text', text: /Not undone/ })).toBeFalsy()
|
|
1348
1360
|
|
|
1349
1361
|
// Claude makes the call: it isn't recorded as a change of its own, and the change is marked undone.
|
|
1350
1362
|
const before = ran.length
|
|
1351
1363
|
const watched = runs.filter((r) => r.argv.includes('watch-start')).length
|
|
1352
|
-
|
|
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)
|
|
1353
1366
|
expect(ran.length).toBe(before + 1)
|
|
1354
1367
|
expect(runs.filter((r) => r.argv.includes('watch-start')).length).toBe(watched)
|
|
1355
1368
|
expect(runs.at(-1)?.argv.slice(3)).toEqual(['settle', 'w1'])
|
|
1369
|
+
const line = await ui.find({ type: 'Text', text: /Rewound/ })
|
|
1370
|
+
expect(line?.text).toContain('Rewound 1 change from the last turn.')
|
|
1371
|
+
expect(line?.text).not.toContain('Claude is finishing')
|
|
1372
|
+
expect(await ui.find({ type: 'Text', text: /Claude is finishing this/ })).toBeFalsy()
|
|
1356
1373
|
await ui.unmount()
|
|
1357
1374
|
})
|
package/mod/hooks/register.tsx
CHANGED
|
@@ -65,6 +65,8 @@ type Outcome = {
|
|
|
65
65
|
tool: string
|
|
66
66
|
ok: boolean
|
|
67
67
|
detail: string
|
|
68
|
+
// Handed to Claude to finish: auto mode wouldn't let Backstep make the call.
|
|
69
|
+
pending?: boolean
|
|
68
70
|
run?: { server: string; tool: string; args: Record<string, unknown>; more?: { tool: string; args: Record<string, unknown> }[]; label?: string }
|
|
69
71
|
pushed?: { root: string; pushes: Pushed[] }
|
|
70
72
|
github?: GhMade[]
|
|
@@ -361,32 +363,43 @@ async function undoLastTurn($: EngineInterface, count = 1) {
|
|
|
361
363
|
}
|
|
362
364
|
|
|
363
365
|
// Opposite calls on servers only Claude Code is connected to run here, newest first.
|
|
364
|
-
|
|
366
|
+
// Once auto mode refuses one, it and every one after it go to Claude, in order.
|
|
367
|
+
const handoff: Handoff[] = []
|
|
365
368
|
for (const o of outcomes) {
|
|
366
369
|
if (!o.ok || !o.run) continue
|
|
367
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
|
+
}
|
|
368
379
|
let failed: { tool: string; why: string } | null = null
|
|
369
380
|
// The first opposite call, then any more on the same server (a move back to several places).
|
|
370
|
-
const all = [{ tool: run.tool, args: run.args }, ...(run.more ?? [])]
|
|
371
381
|
for (const [i, call] of all.entries()) {
|
|
372
382
|
const done = await undoCall($, run.server, call.tool, call.args)
|
|
373
383
|
if (done.isError) {
|
|
374
384
|
const why = done.content.map((c) => (c.type === 'text' ? c.text : '')).join(' ').trim().split('\n')[0] || 'failed'
|
|
375
|
-
// Claude Code's auto mode won't let Backstep make it alone
|
|
376
|
-
if (REFUSED.test(why)) {
|
|
377
|
-
|
|
378
|
-
failed = { tool: call.tool, why: "Claude Code's auto mode didn't let Backstep make this change itself" }
|
|
379
|
-
} else failed = { tool: call.tool, why }
|
|
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 }
|
|
380
388
|
break
|
|
381
389
|
}
|
|
382
390
|
}
|
|
391
|
+
if (handoff.length) {
|
|
392
|
+
o.pending = true
|
|
393
|
+
o.detail = label
|
|
394
|
+
continue
|
|
395
|
+
}
|
|
383
396
|
if (failed) {
|
|
384
397
|
o.ok = false
|
|
385
|
-
o.detail =
|
|
398
|
+
o.detail = couldnt(run.label, failed.why, `undo ${o.tool}`)
|
|
386
399
|
await $.process.run(backstep(['settle', o.id, '--failed', failed.why])).catch(() => null)
|
|
387
400
|
break
|
|
388
401
|
}
|
|
389
|
-
o.detail =
|
|
402
|
+
o.detail = label
|
|
390
403
|
await $.process.run(backstep(['settle', o.id])).catch(() => null)
|
|
391
404
|
}
|
|
392
405
|
// Stop the list at the first failure, as the CLI does.
|
|
@@ -400,9 +413,9 @@ async function undoLastTurn($: EngineInterface, count = 1) {
|
|
|
400
413
|
(o.detail || o.tool)
|
|
401
414
|
.split('\n')
|
|
402
415
|
.filter(Boolean)
|
|
403
|
-
.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 })),
|
|
404
417
|
)
|
|
405
|
-
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 } })
|
|
406
419
|
// A push, or something made on GitHub, is offered back below, so it isn't listed as lost.
|
|
407
420
|
const offered = outcomes.some((o) => o.ok && o.pushed)
|
|
408
421
|
const offeredGh = outcomes.some((o) => o.ok && o.github?.length)
|
|
@@ -428,7 +441,7 @@ async function undoLastTurn($: EngineInterface, count = 1) {
|
|
|
428
441
|
message: failed
|
|
429
442
|
? `Stopped after ${plural(done, 'change')}: the next one couldn't be undone.`
|
|
430
443
|
: items.length
|
|
431
|
-
? `Rewound ${plural(done, 'change')} from ${which}.${shell}`
|
|
444
|
+
? `Rewound ${plural(done, 'change')} from ${which}.${finishing(undone)}${shell}`
|
|
432
445
|
: `Nothing in ${which} could be undone.${shell}`,
|
|
433
446
|
}))
|
|
434
447
|
for (const t of taken) await bump($, t.id, (x) => ({ ...x, undone: !failed }))
|
|
@@ -436,7 +449,7 @@ async function undoLastTurn($: EngineInterface, count = 1) {
|
|
|
436
449
|
// The whole account, kept in the conversation, where the band's few rows fold away.
|
|
437
450
|
const report = [
|
|
438
451
|
`${MARK_TEXT} Backstep undid ${plural(done, 'change')} from ${which}${taken.length === 1 ? named(target) : ''}${done ? ':' : '.'}`,
|
|
439
|
-
...undone.map((i) => ` ◀ ${i.label}`),
|
|
452
|
+
...undone.map((i) => ` ◀ ${i.label}${i.pending ? ' (Claude is finishing this)' : ''}`),
|
|
440
453
|
...(stopped.length || kept.length ? ["Not undone:", ...[...stopped, ...kept].map((i) => ` ✗ ${i.label}`)] : []),
|
|
441
454
|
]
|
|
442
455
|
$.ui.log(report.join('\n'))
|
|
@@ -453,7 +466,7 @@ async function undoLastTurn($: EngineInterface, count = 1) {
|
|
|
453
466
|
|
|
454
467
|
// Asked once the band shows what came back: GitHub things first, then pushes, then Vercel.
|
|
455
468
|
// A change auto mode wouldn't let Backstep make comes first of all: it's where undo stopped.
|
|
456
|
-
void (handoff ?
|
|
469
|
+
void (handoff.length ? handOff($, handoff) : Promise.resolve())
|
|
457
470
|
.then(() => offerGithubUndo($, outcomes))
|
|
458
471
|
.then((gone) => offerPushUndo($, outcomes, gone.size > 0))
|
|
459
472
|
.then(() => offerVercelUndo($, outcomes))
|
|
@@ -586,34 +599,77 @@ function undoneLabel(line: string, short: (p: string) => string): string {
|
|
|
586
599
|
return `${p} ${m[1]}`
|
|
587
600
|
}
|
|
588
601
|
|
|
589
|
-
// Opposite calls handed to Claude to make
|
|
590
|
-
//
|
|
591
|
-
//
|
|
592
|
-
|
|
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
|
+
}
|
|
593
613
|
|
|
594
614
|
type Handoff = { id: string; server: string; calls: { tool: string; args: Record<string, unknown> }[]; label: string }
|
|
595
|
-
|
|
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
|
+
|
|
627
|
+
// Said after "Rewound …" while Claude still has some of it to finish.
|
|
628
|
+
function finishing(items: RewindItem[]): string {
|
|
629
|
+
const n = items.filter((i) => i.pending).length
|
|
630
|
+
return n === 0 ? '' : n === items.length ? ' Claude is finishing it.' : ` Claude is finishing ${n} of them.`
|
|
631
|
+
}
|
|
596
632
|
|
|
597
633
|
// Auto mode's classifier answers only calls a request of Claude's asked for, so it
|
|
598
|
-
// refuses Backstep's own.
|
|
599
|
-
//
|
|
600
|
-
async function
|
|
601
|
-
const
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
634
|
+
// refuses Backstep's own. Pressing undo already said yes, so Claude is asked to make
|
|
635
|
+
// exactly these calls; Backstep marks each change undone once its calls have run.
|
|
636
|
+
async function handOff($: EngineInterface, list: Handoff[]) {
|
|
637
|
+
const steps: string[] = []
|
|
638
|
+
for (const h of list) {
|
|
639
|
+
const server = toolServer(h.server)
|
|
640
|
+
for (const c of h.calls) {
|
|
641
|
+
handedOver.push({ tool: `mcp__${server}__${c.tool}`, args: c.args, action: h.id })
|
|
642
|
+
steps.push(`- mcp__${server}__${c.tool} with ${JSON.stringify(c.args)}`)
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
const what = list.map((h) => h.label).join('; ')
|
|
608
646
|
await $.prompt
|
|
609
647
|
.submit({
|
|
610
|
-
text: `
|
|
648
|
+
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')}`,
|
|
611
649
|
wait: false,
|
|
612
650
|
origin: { kind: 'composer' },
|
|
613
651
|
} as never)
|
|
614
652
|
.catch(() => null)
|
|
615
653
|
}
|
|
616
654
|
|
|
655
|
+
// One of Claude's hand-over calls has run: the band's row for it says how it went.
|
|
656
|
+
async function handedBack($: EngineInterface, action: string, ok: boolean, why?: string) {
|
|
657
|
+
await update($, rewind, (r) => {
|
|
658
|
+
if (!r) return r
|
|
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))
|
|
660
|
+
const undone = items.filter((i) => i.ok)
|
|
661
|
+
// "Rewound 2 changes from the last turn. Claude is finishing it." loses its last sentence once Claude has.
|
|
662
|
+
const [rewound, ...rest] = r.message.replace(/ Claude is finishing [^.]*\./, '').split(/(?<=\.) /)
|
|
663
|
+
return {
|
|
664
|
+
...r,
|
|
665
|
+
items,
|
|
666
|
+
shown: items.length,
|
|
667
|
+
phase: ok ? r.phase : 'failed',
|
|
668
|
+
message: ok ? [rewound + finishing(undone), ...rest].join(' ') : `Rewound ${plural(undone.length, 'change')}. Claude couldn't finish one: listed below.`,
|
|
669
|
+
}
|
|
670
|
+
})
|
|
671
|
+
}
|
|
672
|
+
|
|
617
673
|
// One opposite call, through Claude Code's own connection to the server.
|
|
618
674
|
async function undoCall($: EngineInterface, server: string, tool: string, args: Record<string, unknown>): Promise<{ isError: boolean; content: { type: string; text?: string }[] }> {
|
|
619
675
|
return $.mcp.call(server, tool, args).catch((err: unknown) => ({
|
|
@@ -1101,13 +1157,24 @@ export const register: Register = (on, options) => {
|
|
|
1101
1157
|
on('tool.call', async ($, e, next) => {
|
|
1102
1158
|
const tool = String(e.tool)
|
|
1103
1159
|
// An opposite call Claude is making for undo, as the person asked: not recorded as a change of its own.
|
|
1104
|
-
const handed = handedOver.
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
const r = (await next(e)) as { deny?: string; isError?: boolean }
|
|
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
|
|
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
|
+
}
|
|
1108
1167
|
if (!r.deny && !r.isError) {
|
|
1109
|
-
|
|
1110
|
-
if (!
|
|
1168
|
+
drop((h) => h === handed)
|
|
1169
|
+
if (!handedOver.some((h) => h.action === forAction)) {
|
|
1170
|
+
await $.process.run(backstep(['settle', forAction])).catch(() => null)
|
|
1171
|
+
await handedBack($, forAction, true)
|
|
1172
|
+
}
|
|
1173
|
+
} else {
|
|
1174
|
+
const why = (r.deny ?? r.text ?? 'it failed').trim().split('\n')[0]
|
|
1175
|
+
drop((h) => h.action === forAction)
|
|
1176
|
+
await $.process.run(backstep(['settle', forAction, '--failed', why])).catch(() => null)
|
|
1177
|
+
await handedBack($, forAction, false, why)
|
|
1111
1178
|
}
|
|
1112
1179
|
return r as never
|
|
1113
1180
|
}
|
|
@@ -1251,8 +1318,9 @@ export const register: Register = (on, options) => {
|
|
|
1251
1318
|
</Text>
|
|
1252
1319
|
{back.slice(0, 5).map((i, n) => (
|
|
1253
1320
|
<Text key={`b${n}`} dimColor>
|
|
1254
|
-
{' ◀ '}
|
|
1321
|
+
{i.pending ? ' … ' : ' ◀ '}
|
|
1255
1322
|
{i.label}
|
|
1323
|
+
{i.pending ? ' (Claude is finishing this)' : ''}
|
|
1256
1324
|
</Text>
|
|
1257
1325
|
))}
|
|
1258
1326
|
{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,7 +18,8 @@ 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.
|
|
24
25
|
// An update under way, kept across the reload it causes, so the reloaded mod can say it's done.
|