backstep 0.1.30 → 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/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.test.tsx +21 -11
- package/mod/hooks/register.tsx +74 -27
- package/mod/types/index.d.ts +2 -1
- package/package.json +1 -1
|
@@ -1317,24 +1317,23 @@ test('an update that reloads the mod partway is finished by the reloaded mod: it
|
|
|
1317
1317
|
await ui.unmount()
|
|
1318
1318
|
})
|
|
1319
1319
|
|
|
1320
|
-
|
|
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) {
|
|
1321
1322
|
const clock = mock.clock(on, { now: 5000 })
|
|
1322
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"
|
|
1323
1324
|
const asked: string[] = []
|
|
1324
1325
|
on('tool.call', { tool: 'AskUserQuestion' }, (_, e) => {
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
return { result: { questions, answers: { [questions[0].question]: 'Ask Claude to do it' } } }
|
|
1326
|
+
asked.push((e as unknown as { questions: { question: string }[] }).questions[0].question)
|
|
1327
|
+
return { result: {} }
|
|
1328
1328
|
})
|
|
1329
|
-
const
|
|
1329
|
+
const env = connector(on, {
|
|
1330
1330
|
start: { kind: 'reversible', id: 'w1' },
|
|
1331
1331
|
undoable: true,
|
|
1332
1332
|
mcpThrows: refusal,
|
|
1333
|
-
undo: { id: 'w1', tool: 'notion-create-pages', ok: true, detail: '
|
|
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
1334
|
})
|
|
1335
1335
|
await $.prompt.submit({ text: 'make a page', wait: false, origin: { kind: 'composer' } })
|
|
1336
1336
|
await $.tool.call({ tool: 'mcp__claude_ai_Notion__notion-create-pages', pages: [{ title: 'Test' }], tool_use_id: 'tu1' } as never)
|
|
1337
|
-
|
|
1338
1337
|
const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
|
|
1339
1338
|
await ui.press({ key: 'undo' })
|
|
1340
1339
|
const pressed = ui.press({ key: 'undo' })
|
|
@@ -1342,16 +1341,27 @@ test("when auto mode won't let Backstep undo a Notion change, Claude is asked to
|
|
|
1342
1341
|
await pressed
|
|
1343
1342
|
await clock.advance(1000)
|
|
1344
1343
|
await settle()
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
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()
|
|
1348
1354
|
|
|
1349
1355
|
// Claude makes the call: it isn't recorded as a change of its own, and the change is marked undone.
|
|
1350
1356
|
const before = ran.length
|
|
1351
1357
|
const watched = runs.filter((r) => r.argv.includes('watch-start')).length
|
|
1352
|
-
await $.tool.call({ tool: 'mcp__claude_ai_Notion__notion-delete-pages', page_ids: ['p1'], tool_use_id: 'tu2' } as never)
|
|
1358
|
+
await $.tool.call({ tool: 'mcp__claude_ai_Notion__notion-delete-pages', page_ids: ['p1'], user_confirmed: true, tool_use_id: 'tu2' } as never)
|
|
1353
1359
|
expect(ran.length).toBe(before + 1)
|
|
1354
1360
|
expect(runs.filter((r) => r.argv.includes('watch-start')).length).toBe(watched)
|
|
1355
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()
|
|
1356
1366
|
await ui.unmount()
|
|
1357
1367
|
})
|
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
398
|
o.detail = `${failed.tool} failed: ${failed.why}`
|
|
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,7 +413,7 @@ 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
418
|
const stopped = outcomes.filter((o) => !o.ok).map((o) => ({ label: `${o.tool}: ${o.detail.split('\n')[0] ?? 'failed'}`, ok: false }))
|
|
406
419
|
// A push, or something made on GitHub, is offered back below, so it isn't listed as lost.
|
|
@@ -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))
|
|
@@ -592,28 +605,53 @@ function undoneLabel(line: string, short: (p: string) => string): string {
|
|
|
592
605
|
const handedOver = new Map<string, string>()
|
|
593
606
|
|
|
594
607
|
type Handoff = { id: string; server: string; calls: { tool: string; args: Record<string, unknown> }[]; label: string }
|
|
595
|
-
|
|
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
|
+
}
|
|
596
614
|
|
|
597
615
|
// 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
|
-
|
|
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('; ')
|
|
608
628
|
await $.prompt
|
|
609
629
|
.submit({
|
|
610
|
-
text: `
|
|
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')}`,
|
|
611
631
|
wait: false,
|
|
612
632
|
origin: { kind: 'composer' },
|
|
613
633
|
} as never)
|
|
614
634
|
.catch(() => null)
|
|
615
635
|
}
|
|
616
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
|
+
|
|
617
655
|
// One opposite call, through Claude Code's own connection to the server.
|
|
618
656
|
async function undoCall($: EngineInterface, server: string, tool: string, args: Record<string, unknown>): Promise<{ isError: boolean; content: { type: string; text?: string }[] }> {
|
|
619
657
|
return $.mcp.call(server, tool, args).catch((err: unknown) => ({
|
|
@@ -1104,10 +1142,18 @@ export const register: Register = (on, options) => {
|
|
|
1104
1142
|
const handed = handedOver.size ? undoCallKey(tool, Object.fromEntries(Object.entries(e).filter(([k]) => !RESERVED.has(k)))) : ''
|
|
1105
1143
|
const forAction = handed ? handedOver.get(handed) : undefined
|
|
1106
1144
|
if (forAction) {
|
|
1107
|
-
const r = (await next(e)) as { deny?: string; isError?: boolean }
|
|
1145
|
+
const r = (await next(e)) as { deny?: string; isError?: boolean; text?: string }
|
|
1108
1146
|
if (!r.deny && !r.isError) {
|
|
1109
1147
|
handedOver.delete(handed)
|
|
1110
|
-
if (![...handedOver.values()].includes(forAction))
|
|
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)
|
|
1111
1157
|
}
|
|
1112
1158
|
return r as never
|
|
1113
1159
|
}
|
|
@@ -1251,8 +1297,9 @@ export const register: Register = (on, options) => {
|
|
|
1251
1297
|
</Text>
|
|
1252
1298
|
{back.slice(0, 5).map((i, n) => (
|
|
1253
1299
|
<Text key={`b${n}`} dimColor>
|
|
1254
|
-
{' ◀ '}
|
|
1300
|
+
{i.pending ? ' … ' : ' ◀ '}
|
|
1255
1301
|
{i.label}
|
|
1302
|
+
{i.pending ? ' (Claude is finishing this)' : ''}
|
|
1256
1303
|
</Text>
|
|
1257
1304
|
))}
|
|
1258
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,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.
|