backstep 0.1.14 → 0.1.16
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/fs.js +7 -0
- package/dist/cli.js +23 -6
- package/dist/undo.js +13 -1
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.test.tsx +37 -3
- package/mod/hooks/register.tsx +44 -22
- package/package.json +1 -1
package/dist/adapters/fs.js
CHANGED
|
@@ -24,6 +24,13 @@ function sizeOf(p, budget) {
|
|
|
24
24
|
return total;
|
|
25
25
|
}
|
|
26
26
|
// Returns null when a path is too large to copy; the caller then can't promise undo.
|
|
27
|
+
// Whether a path sits in the folder Claude Code has open. Edits elsewhere belong to
|
|
28
|
+
// other projects: Backstep never records them, so undo here can never touch them.
|
|
29
|
+
export function insideDir(path, dir) {
|
|
30
|
+
const p = resolvePath(path, dir);
|
|
31
|
+
const d = resolve(dir).replace(/\/$/, "");
|
|
32
|
+
return p === d || p.startsWith(d + "/");
|
|
33
|
+
}
|
|
27
34
|
export function snapshotPaths(actionId, paths, cwd) {
|
|
28
35
|
const dir = snapshotDir(actionId);
|
|
29
36
|
const seen = new Set();
|
package/dist/cli.js
CHANGED
|
@@ -5,7 +5,7 @@ import { runProxy } from "./proxy.js";
|
|
|
5
5
|
import { startUi } from "./ui/server.js";
|
|
6
6
|
import { Store, snapshotDir } from "./store.js";
|
|
7
7
|
import { pruneDatabaseCopies, undoAction, undoRun, undoSince } from "./undo.js";
|
|
8
|
-
import { snapshotPaths } from "./adapters/fs.js";
|
|
8
|
+
import { insideDir, snapshotPaths } from "./adapters/fs.js";
|
|
9
9
|
import { notePushed, snapshotSqlite, snapshotTree } from "./adapters/tree.js";
|
|
10
10
|
import { revertPush } from "./push.js";
|
|
11
11
|
import { ghMade, grantScope, undoGh } from "./github.js";
|
|
@@ -24,8 +24,16 @@ import { autoWrap, printChanges, unwrapServers, wrapServers } from "./servers.js
|
|
|
24
24
|
import { disconnectSupabase, dismissSupabase, firebaseConn, postgresUrl, restKey } from "./supabase.js";
|
|
25
25
|
// The Supabase projects a shell command could change: those in the folder it
|
|
26
26
|
// works in (or a level or two below), when the command is one that reaches a database.
|
|
27
|
+
// A command that `cd`s into another project is out of scope: only the open folder is tracked.
|
|
28
|
+
function scopedDir(command, cwd) {
|
|
29
|
+
const d = commandDir(command, cwd);
|
|
30
|
+
return insideDir(d, cwd) ? d : null;
|
|
31
|
+
}
|
|
27
32
|
function reachedProjects(command, cwd) {
|
|
28
|
-
const
|
|
33
|
+
const at = scopedDir(command, cwd);
|
|
34
|
+
if (!at)
|
|
35
|
+
return [];
|
|
36
|
+
const dirs = supabaseDirs(at);
|
|
29
37
|
return touchesDatabase(command) || touchesSupabase(command, dirs.length > 0) ? dirs : [];
|
|
30
38
|
}
|
|
31
39
|
// Every database a shell command could change, by folder: Supabase, other Postgres, Firebase.
|
|
@@ -33,7 +41,8 @@ function reachedDatabases(command, cwd) {
|
|
|
33
41
|
const out = [];
|
|
34
42
|
for (const d of reachedProjects(command, cwd))
|
|
35
43
|
out.push({ dir: d, service: "supabase" });
|
|
36
|
-
|
|
44
|
+
const at = scopedDir(command, cwd);
|
|
45
|
+
for (const d of at ? dbDirs(at) : []) {
|
|
37
46
|
const u = projectUses(d);
|
|
38
47
|
if (u.postgres && (touchesDatabase(command) || touchesSupabase(command, true)))
|
|
39
48
|
out.push({ dir: d, service: "postgres" });
|
|
@@ -307,10 +316,17 @@ async function main() {
|
|
|
307
316
|
args: sep === -1 ? rest : rest.slice(0, sep),
|
|
308
317
|
options: { session: { type: "string" }, tool: { type: "string" }, cwd: { type: "string" } },
|
|
309
318
|
});
|
|
310
|
-
const
|
|
311
|
-
if (!values.session || !values.tool ||
|
|
319
|
+
const asked = sep === -1 ? [] : rest.slice(sep + 1);
|
|
320
|
+
if (!values.session || !values.tool || asked.length === 0) {
|
|
312
321
|
throw new Error("usage: backstep snap --session <id> --tool <name> [--cwd <dir>] -- <paths...>");
|
|
313
322
|
}
|
|
323
|
+
// Claude Code names the folder it has open: a file in another project is left out entirely.
|
|
324
|
+
const scope = values.cwd;
|
|
325
|
+
const paths = scope ? asked.filter((p) => insideDir(p, scope)) : asked;
|
|
326
|
+
if (paths.length === 0) {
|
|
327
|
+
console.log(JSON.stringify({ outside: true, undoable: false }));
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
314
330
|
const store = new Store();
|
|
315
331
|
const run = store.sessionRun("claude-code", `session:${values.session}`);
|
|
316
332
|
const a = store.addAction({
|
|
@@ -541,11 +557,12 @@ async function main() {
|
|
|
541
557
|
until: { type: "string" },
|
|
542
558
|
json: { type: "boolean", default: false },
|
|
543
559
|
"via-claude-code": { type: "boolean", default: false },
|
|
560
|
+
session: { type: "string" },
|
|
544
561
|
},
|
|
545
562
|
});
|
|
546
563
|
const store = new Store();
|
|
547
564
|
const outcomes = values.since
|
|
548
|
-
? await undoSince(store, Number(values.since), values.until ? Number(values.until) : undefined, { viaClaudeCode: values["via-claude-code"] })
|
|
565
|
+
? await undoSince(store, Number(values.since), values.until ? Number(values.until) : undefined, { viaClaudeCode: values["via-claude-code"], session: values.session })
|
|
549
566
|
: values.run
|
|
550
567
|
? await undoRun(store, values.run)
|
|
551
568
|
: await undoAction(store, positionals[0] ?? "");
|
package/dist/undo.js
CHANGED
|
@@ -201,9 +201,21 @@ export async function undoRun(store, runId) {
|
|
|
201
201
|
// Everything any agent did in a window of time: what one turn of a chat did,
|
|
202
202
|
// across Claude's own edits and every server wrapped by Backstep.
|
|
203
203
|
export async function undoSince(store, since, until = Date.now(), opts = {}) {
|
|
204
|
-
|
|
204
|
+
let inWindow = store.db
|
|
205
205
|
.prepare("SELECT * FROM actions WHERE created_at >= ? AND created_at <= ? ORDER BY created_at, seq")
|
|
206
206
|
.all(since, until);
|
|
207
|
+
if (opts.session) {
|
|
208
|
+
const mine = `session:${opts.session}`;
|
|
209
|
+
const commands = new Map();
|
|
210
|
+
inWindow = inWindow.filter((a) => {
|
|
211
|
+
if (!commands.has(a.run_id))
|
|
212
|
+
commands.set(a.run_id, store.getRun(a.run_id)?.command);
|
|
213
|
+
const command = commands.get(a.run_id);
|
|
214
|
+
// Another session's run: never ours to undo. Servers wrapped by `backstep wrap`
|
|
215
|
+
// record under their own runs and stay included, as before.
|
|
216
|
+
return !(command?.startsWith("session:") && command !== mine);
|
|
217
|
+
});
|
|
218
|
+
}
|
|
207
219
|
return undoAll(store, inWindow.filter(undoable), opts);
|
|
208
220
|
}
|
|
209
221
|
async function undoAll(store, actions, opts = {}) {
|
|
@@ -60,6 +60,39 @@ test('an edit is snapshotted and the band offers to undo the turn', { options: {
|
|
|
60
60
|
}
|
|
61
61
|
})
|
|
62
62
|
|
|
63
|
+
test('edits outside the open folder are never tracked', { options: { cli: CLI } }, async ($, on) => {
|
|
64
|
+
mock.clock(on, { now: 5000 })
|
|
65
|
+
const { runs } = engine(on, { exitCode: 0, stdout: '[]' })
|
|
66
|
+
|
|
67
|
+
await $.prompt.submit({ text: 'fix the other project', wait: false, origin: { kind: 'composer' } })
|
|
68
|
+
await $.tool.call({ tool: 'Write', file_path: '/other-project/src/new.ts', content: 'x' })
|
|
69
|
+
await $.tool.call({ tool: 'Edit', file_path: '/repo-sibling/a.ts', old_string: 'a', new_string: 'b' })
|
|
70
|
+
await $.tool.call({ tool: 'Edit', file_path: '/repo/../other-project/a.ts', old_string: 'a', new_string: 'b' })
|
|
71
|
+
|
|
72
|
+
expect(runs.filter((r) => r.includes('snap'))).toEqual([])
|
|
73
|
+
const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
|
|
74
|
+
expect((await ui.find({ type: 'Text', text: /nothing to undo yet/ }))?.text).toBeTruthy()
|
|
75
|
+
await ui.unmount()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('a long last turn stays on one line with a fixed gap after the logo', { options: { cli: CLI } }, async ($, on) => {
|
|
79
|
+
mock.clock(on, { now: 5000 })
|
|
80
|
+
engine(on, { exitCode: 0, stdout: '[]' }, { kind: 'write', undoable: true })
|
|
81
|
+
|
|
82
|
+
await $.prompt.submit({ text: 'scrap that, lets try making the whole thing much longer than fits', wait: false, origin: { kind: 'composer' } })
|
|
83
|
+
for (const f of ['a', 'b', 'c']) await $.tool.call({ tool: 'Edit', file_path: `/repo/src/${f}.ts`, old_string: 'a', new_string: 'b' })
|
|
84
|
+
for (let i = 0; i < 8; i++) await $.tool.call({ tool: 'Bash', command: `touch x${i}` })
|
|
85
|
+
|
|
86
|
+
const ui = await $.ui.mount({ ...BAND, props: { ...BAND.props, bodyColumns: 60 }, surface: 'terminal' })
|
|
87
|
+
const line = await ui.find({ type: 'Text', text: /Last turn/ })
|
|
88
|
+
// No leading space to be trimmed on a wrap: the gap is the logo box's own margin.
|
|
89
|
+
expect(line?.text).toMatch(/^Last turn:/)
|
|
90
|
+
expect(line?.props.wrap).toBe('truncate-end')
|
|
91
|
+
expect(await ui.find({ type: 'Box', props: { flexShrink: 0, marginRight: 1 } })).toBeTruthy()
|
|
92
|
+
expect(await ui.find({ type: 'Button', key: 'undo' })).toBeTruthy()
|
|
93
|
+
await ui.unmount()
|
|
94
|
+
})
|
|
95
|
+
|
|
63
96
|
test('pressing undo rewinds the turn and tells Claude on the next prompt', { options: { cli: CLI } }, async ($, on) => {
|
|
64
97
|
const clock = mock.clock(on, { now: 5000 })
|
|
65
98
|
const { runs, prompts } = engine(on, {
|
|
@@ -75,7 +108,7 @@ test('pressing undo rewinds the turn and tells Claude on the next prompt', { opt
|
|
|
75
108
|
const pressed = ui.press({ key: 'undo' })
|
|
76
109
|
await clock.settle()
|
|
77
110
|
expect((await ui.find({ type: 'Text', text: /Rewinding/ }))?.text).toBeTruthy()
|
|
78
|
-
expect(runs.at(-1)).toEqual(['node', '--disable-warning=ExperimentalWarning', CLI, 'undo', '--since', '5000', '--json', '--via-claude-code'])
|
|
111
|
+
expect(runs.at(-1)).toEqual(['node', '--disable-warning=ExperimentalWarning', CLI, 'undo', '--since', '5000', '--session', 's1', '--json', '--via-claude-code'])
|
|
79
112
|
|
|
80
113
|
await clock.advance(1000)
|
|
81
114
|
await pressed
|
|
@@ -141,7 +174,8 @@ test('with nothing to undo the band still holds the key, and a press says there
|
|
|
141
174
|
|
|
142
175
|
const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
|
|
143
176
|
expect((await ui.find({ type: 'Text', text: /nothing to undo yet/ }))).toBeTruthy()
|
|
144
|
-
// The
|
|
177
|
+
// The gap after the mark is its own fixed margin, so a long line can't squeeze it away.
|
|
178
|
+
expect((await ui.find({ type: 'Text', text: /nothing to undo yet/ }))?.text).toMatch(/^Backstep is on/) // The mark: the front triangle in the terminal's own color, the one behind in the logo's pink.
|
|
145
179
|
expect((await ui.find({ type: 'Text', text: '◀', props: { color: '#ee7bb6' } }))).toBeTruthy()
|
|
146
180
|
await ui.press({ key: 'undo' })
|
|
147
181
|
expect(toasts[0]).toContain('Nothing to undo yet')
|
|
@@ -666,7 +700,7 @@ test('when the last turn only committed and pushed, undo offers to take back the
|
|
|
666
700
|
await clock.advance(1000)
|
|
667
701
|
await pressed
|
|
668
702
|
// Both turns: everything since the first one started.
|
|
669
|
-
expect(runs.find((r) => r.includes('undo'))?.slice(3)).toEqual(['undo', '--since', '5000', '--json', '--via-claude-code'])
|
|
703
|
+
expect(runs.find((r) => r.includes('undo'))?.slice(3)).toEqual(['undo', '--since', '5000', '--session', 's1', '--json', '--via-claude-code'])
|
|
670
704
|
await ui.unmount()
|
|
671
705
|
})
|
|
672
706
|
|
package/mod/hooks/register.tsx
CHANGED
|
@@ -183,6 +183,19 @@ async function offerPushUndo($: EngineInterface, outcomes: Outcome[], repoGone =
|
|
|
183
183
|
// The first press asks, right there above the prompt; a second press within a
|
|
184
184
|
// few seconds undoes. A dialog would take the band off screen, and with it the
|
|
185
185
|
// key, which would then suspend Claude Code in a terminal.
|
|
186
|
+
// Whether a file path is inside the folder this session has open.
|
|
187
|
+
function within(path: string, cwd: string): boolean {
|
|
188
|
+
const abs = path.startsWith('/') ? path : `${cwd}/${path}`
|
|
189
|
+
const parts: string[] = []
|
|
190
|
+
for (const seg of abs.split('/')) {
|
|
191
|
+
if (seg === '..') parts.pop()
|
|
192
|
+
else if (seg && seg !== '.') parts.push(seg)
|
|
193
|
+
}
|
|
194
|
+
const p = '/' + parts.join('/')
|
|
195
|
+
const d = cwd.replace(/\/+$/, '')
|
|
196
|
+
return p === d || p.startsWith(d + '/')
|
|
197
|
+
}
|
|
198
|
+
|
|
186
199
|
async function pressUndo($: EngineInterface) {
|
|
187
200
|
if (await read($, rewind)) return
|
|
188
201
|
const target = (await read($, turns)).findLast(hasWork)
|
|
@@ -218,7 +231,7 @@ async function undoLastTurn($: EngineInterface, count = 1) {
|
|
|
218
231
|
const cwd = await $.session.cwd()
|
|
219
232
|
const short = (p: string) => (p.startsWith(cwd + '/') ? p.slice(cwd.length + 1) : p)
|
|
220
233
|
const [ran] = await Promise.all([
|
|
221
|
-
$.process.run(backstep(['undo', '--since', String(target.startedAt), '--json', '--via-claude-code'])).catch((err: unknown) => ({
|
|
234
|
+
$.process.run(backstep(['undo', '--since', String(target.startedAt), '--session', await $.session.id(), '--json', '--via-claude-code'])).catch((err: unknown) => ({
|
|
222
235
|
exitCode: 1,
|
|
223
236
|
stdout: '',
|
|
224
237
|
stderr: err instanceof Error ? err.message : String(err),
|
|
@@ -712,9 +725,11 @@ export const register: Register = (on, options) => {
|
|
|
712
725
|
if (EDIT_TOOLS.has(tool)) {
|
|
713
726
|
const input = e as { file_path?: string; notebook_path?: string }
|
|
714
727
|
const path = input.file_path ?? input.notebook_path
|
|
715
|
-
|
|
728
|
+
const cwd = await $.session.cwd()
|
|
729
|
+
// Only the open folder is tracked: an edit in another project is never
|
|
730
|
+
// recorded, so undoing here can never delete or roll back files there.
|
|
731
|
+
if (path && within(path, cwd)) {
|
|
716
732
|
const t = await current($)
|
|
717
|
-
const cwd = await $.session.cwd()
|
|
718
733
|
const sid = await $.session.id()
|
|
719
734
|
// A failed snapshot never blocks the edit; it just won't be undoable.
|
|
720
735
|
const snap = await $.process
|
|
@@ -778,14 +793,21 @@ export const register: Register = (on, options) => {
|
|
|
778
793
|
on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
|
|
779
794
|
if (e.props.hasSurvey) return next(e)
|
|
780
795
|
const { Box, Button, Text } = $.ui.resolve(e)
|
|
796
|
+
// The logo keeps its own box with a fixed gap after it: a space inside the
|
|
797
|
+
// text next to it gets trimmed when a long line wraps, crushing the two together.
|
|
798
|
+
const mark = () => (
|
|
799
|
+
<Box flexShrink={0} marginRight={1}>
|
|
800
|
+
<Text bold>◀</Text>
|
|
801
|
+
<Text color={PINK} bold>◀</Text>
|
|
802
|
+
</Box>
|
|
803
|
+
)
|
|
781
804
|
const r = await read($, rewind)
|
|
782
805
|
|
|
783
806
|
if (r?.phase === 'running') {
|
|
784
807
|
return (
|
|
785
808
|
<Box>
|
|
786
|
-
|
|
787
|
-
<Text
|
|
788
|
-
<Text bold> Rewinding </Text>
|
|
809
|
+
{mark()}
|
|
810
|
+
<Text bold>Rewinding </Text>
|
|
789
811
|
<Text>{bar(r.frame)}</Text>
|
|
790
812
|
<Text dimColor> undoing the last turn…</Text>
|
|
791
813
|
</Box>
|
|
@@ -797,9 +819,8 @@ export const register: Register = (on, options) => {
|
|
|
797
819
|
const step = waiting ? (
|
|
798
820
|
<Box flexDirection="column" marginTop={r ? 1 : 0}>
|
|
799
821
|
<Box>
|
|
800
|
-
|
|
801
|
-
<Text color={
|
|
802
|
-
<Text color="warning" bold>{` One more step to ${waiting.what}:`}</Text>
|
|
822
|
+
{mark()}
|
|
823
|
+
<Text color="warning" bold>{`One more step to ${waiting.what}:`}</Text>
|
|
803
824
|
</Box>
|
|
804
825
|
<Text>{' Paste this code on the GitHub page that just opened, then press Authorize:'}</Text>
|
|
805
826
|
<Box>
|
|
@@ -858,9 +879,8 @@ export const register: Register = (on, options) => {
|
|
|
858
879
|
return (
|
|
859
880
|
<Box flexDirection="column">
|
|
860
881
|
<Box>
|
|
861
|
-
|
|
862
|
-
<Text color={
|
|
863
|
-
<Text color="warning" bold>{' That turn only saved and pushed to git.'}</Text>
|
|
882
|
+
{mark()}
|
|
883
|
+
<Text color="warning" bold>{'That turn only saved and pushed to git.'}</Text>
|
|
864
884
|
</Box>
|
|
865
885
|
<Text dimColor>{` Undo it and the turn before it${quoted(before, 50)}?`}</Text>
|
|
866
886
|
<Box>
|
|
@@ -881,9 +901,8 @@ export const register: Register = (on, options) => {
|
|
|
881
901
|
return (
|
|
882
902
|
<Box flexDirection="column">
|
|
883
903
|
<Box>
|
|
884
|
-
|
|
885
|
-
<Text color={
|
|
886
|
-
<Text color="warning" bold>{" Undo Claude's last turn?"}</Text>
|
|
904
|
+
{mark()}
|
|
905
|
+
<Text color="warning" bold>{"Undo Claude's last turn?"}</Text>
|
|
887
906
|
</Box>
|
|
888
907
|
<Text dimColor>{` ${quoted(target, 50).trim() || 'That turn'} · ${summary(target)}`}</Text>
|
|
889
908
|
<Box>
|
|
@@ -903,13 +922,16 @@ export const register: Register = (on, options) => {
|
|
|
903
922
|
// with nothing to undo, a press says so rather than doing nothing.
|
|
904
923
|
return (
|
|
905
924
|
<Box>
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
<
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
925
|
+
{mark()}
|
|
926
|
+
{/* One line: a long summary is cut short so the key to press always shows. */}
|
|
927
|
+
<Box flexShrink={1} minWidth={0}>
|
|
928
|
+
<Text dimColor wrap="truncate-end">{target ? `Last turn:${quoted(target, 28)} · ${summary(target)}` : 'Backstep is on · nothing to undo yet'}</Text>
|
|
929
|
+
</Box>
|
|
930
|
+
<Box flexShrink={0} marginLeft={2}>
|
|
931
|
+
<Button key="undo" action={UNDO_ACTION} plain dimColor onPress={() => void pressUndo($)}>
|
|
932
|
+
{`${label} to undo`}
|
|
933
|
+
</Button>
|
|
934
|
+
</Box>
|
|
913
935
|
</Box>
|
|
914
936
|
)
|
|
915
937
|
})
|