backstep 0.1.10 → 0.1.14

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.
@@ -7,7 +7,7 @@
7
7
  import { atom, read, update } from 'claude-code'
8
8
  import type { EngineInterface, Register } from 'claude-code'
9
9
 
10
- import type { Rewind, RewindItem, Turn } from '../types'
10
+ import type { GithubCode, Rewind, RewindItem, Turn } from '../types'
11
11
 
12
12
  const turns = atom({ plugin: 'backstep', key: 'turns' } as const, [] as Turn[])
13
13
  const rewind = atom({ plugin: 'backstep', key: 'rewind' } as const, null as Rewind | null)
@@ -16,6 +16,8 @@ const armed = atom({ plugin: 'backstep', key: 'armed' } as const, null as number
16
16
  // The keys bound to undo, read as a session starts and when the person changes
17
17
  // them. Kept in state, not a variable, so the band redraws with the new key.
18
18
  const keys = atom({ plugin: 'backstep', key: 'keys' } as const, ['ctrl+u'] as string[])
19
+ // The one-time code GitHub's approval page wants, on the band while GitHub waits.
20
+ const githubCode = atom({ plugin: 'backstep', key: 'githubCode' } as const, null as GithubCode | null)
19
21
  // Backstep's mark, as on the logo: the front rewind triangle in the terminal's
20
22
  // own black or white, the one behind it in the logo's pink.
21
23
  const PINK = '#ee7bb6'
@@ -57,16 +59,99 @@ type Outcome = {
57
59
  detail: string
58
60
  run?: { server: string; tool: string; args: Record<string, unknown> }
59
61
  pushed?: { root: string; pushes: Pushed[] }
62
+ github?: GhMade[]
60
63
  }
61
64
  const PUSH = /\bgit\b[^;&|]*\bpush\b/
65
+ // A gh command that makes something on GitHub: noted once it runs, so undo can offer to take it away.
66
+ const GH_CREATE = /\bgh\s+(repo|issue|pr|release)\s+create\b/
67
+ type GhMade = { kind: 'repo' | 'issue' | 'pr' | 'release'; repo: string; number?: number; tag?: string; url: string }
68
+ const describeGh = (g: GhMade) =>
69
+ g.kind === 'repo' ? `the GitHub repository ${g.repo}` : g.kind === 'release' ? `the release ${g.tag} in ${g.repo}` : `${g.kind === 'pr' ? 'pull request' : 'issue'} #${g.number} in ${g.repo}`
70
+
71
+ // Runs a Backstep command that answers with { ok, detail }, however it fails.
72
+ async function jsonRun($: EngineInterface, args: string[], timeoutMs: number, stdin?: string): Promise<{ ok: boolean; detail: string; needs?: string }> {
73
+ const ran = await $.process
74
+ .run(backstep(args), { timeoutMs, ...(stdin !== undefined ? { stdin } : {}) })
75
+ .catch((err: unknown) => ({ exitCode: 1, stdout: '', stderr: err instanceof Error ? err.message : String(err) }))
76
+ try {
77
+ return JSON.parse(ran.stdout) as { ok: boolean; detail: string; needs?: string }
78
+ } catch {
79
+ return { ok: false, detail: (ran.stderr || 'it failed').trim().split('\n').at(-1) ?? 'it failed' }
80
+ }
81
+ }
82
+
83
+ const ghUndo = ($: EngineInterface, g: GhMade) => jsonRun($, ['gh-undo'], 120_000, JSON.stringify(g))
84
+
85
+ // Gets GitHub's OK for one more gh permission. The CLI prints the one-time code
86
+ // on a line of its own as soon as GitHub gives it, so the band shows it while
87
+ // GitHub waits; the answer is its last line.
88
+ async function ghAuth($: EngineInterface, scope: string, what: string): Promise<{ ok: boolean; detail: string }> {
89
+ let out = ''
90
+ try {
91
+ const child = $.process.spawn({ argv: backstep(['gh-auth', scope]) })
92
+ for await (const { stream, text } of child) {
93
+ if (stream !== 'stdout') continue
94
+ out += text
95
+ const code = out.match(/"code":"([A-Z0-9]{4}-[A-Z0-9]{4})"/)?.[1]
96
+ if (code && !(await read($, githubCode))) {
97
+ await update($, githubCode, () => ({ code, what }))
98
+ $.ui.log(`${MARK_TEXT} One more step to ${what}: paste the code ${code} on the GitHub page that just opened, then press Authorize. (It's already copied, so ⌘V / Ctrl+V works.)`)
99
+ }
100
+ }
101
+ } catch (err) {
102
+ out += `\n${JSON.stringify({ ok: false, detail: err instanceof Error ? err.message : String(err) })}`
103
+ } finally {
104
+ await update($, githubCode, () => null)
105
+ }
106
+ try {
107
+ return JSON.parse(out.trim().split('\n').at(-1) ?? '') as { ok: boolean; detail: string }
108
+ } catch {
109
+ return { ok: false, detail: "GitHub didn't answer" }
110
+ }
111
+ }
112
+
113
+ // Adds a line to the band's list once something offered after the rewind is settled.
114
+ async function addItem($: EngineInterface, item: RewindItem) {
115
+ await update($, rewind, (r) => (r ? { ...r, items: [...r.items, item], shown: r.shown + 1 } : r))
116
+ }
117
+
118
+ // Asks, for each thing Claude made on GitHub in the undone turns, whether to take it away there too.
119
+ // Returns the repositories deleted, whose pushes then need no undo of their own.
120
+ async function offerGithubUndo($: EngineInterface, outcomes: Outcome[]): Promise<Set<string>> {
121
+ const gone = new Set<string>()
122
+ for (const g of outcomes.flatMap((o) => (o.ok && o.github ? o.github : []))) {
123
+ const yes = g.kind === 'repo' ? 'Delete it' : g.kind === 'issue' || g.kind === 'pr' ? 'Close it' : 'Delete it'
124
+ const answer = await $.ui
125
+ .ask(`Claude created ${describeGh(g)}. ${yes === 'Close it' ? 'Close' : 'Delete'} it on GitHub too?`, { header: 'Backstep', options: [yes, 'Leave it'] })
126
+ .catch(() => null)
127
+ if (answer !== yes) {
128
+ $.ui.log(`${MARK_TEXT} Not undone on GitHub: ${describeGh(g)} stays (you chose to leave it).`)
129
+ continue
130
+ }
131
+ let r = await ghUndo($, g)
132
+ // gh's sign-in needs one more permission: get it on GitHub's own page, then try again.
133
+ if (!r.ok && r.needs) {
134
+ $.ui.toast('One more step: paste the code shown above the prompt on the GitHub page that just opened, then press Authorize.', { timeoutMs: 15_000 })
135
+ const granted = await ghAuth($, r.needs, `${yes === 'Close it' ? 'close' : 'delete'} ${describeGh(g)}`)
136
+ r = granted.ok ? await ghUndo($, g) : { ok: false, detail: `GitHub didn't give the OK (${granted.detail}), so ${describeGh(g)} is still there` }
137
+ }
138
+ if (r.ok && g.kind === 'repo') gone.add(g.repo)
139
+ await addItem($, r.ok ? { label: `on GitHub: ${r.detail}`, ok: true } : { label: `on GitHub: ${r.detail}`, ok: false })
140
+ $.ui.log(r.ok ? `${MARK_TEXT} On GitHub: ${r.detail}.` : `${MARK_TEXT} Not undone on GitHub: ${r.detail}`)
141
+ $.ui.toast(r.ok ? `Backstep ${r.detail}.` : `Backstep ${r.detail}`)
142
+ }
143
+ return gone
144
+ }
62
145
 
63
146
  const PUSH_ASK = { yes: 'Push the undo', no: 'Leave it as it is' }
64
147
 
65
148
  // Undo rewound the code here; this asks before taking it back on GitHub too,
66
149
  // as a new commit that undoes Claude's (never a force push).
67
- async function offerPushUndo($: EngineInterface, outcomes: Outcome[]) {
150
+ async function offerPushUndo($: EngineInterface, outcomes: Outcome[], repoGone = false) {
68
151
  for (const o of outcomes) {
69
152
  if (!o.ok || !o.pushed) continue
153
+ // The repository it went to is deleted: nothing left there to undo.
154
+ if (repoGone) continue
70
155
  for (const p of o.pushed.pushes) {
71
156
  const what =
72
157
  p.before === null
@@ -184,11 +269,14 @@ async function undoLastTurn($: EngineInterface, count = 1) {
184
269
  .map((line) => ({ label: line.replace(/^(restored|removed) (.*)$/, (_, verb: string, p: string) => `${short(p)} ${verb}`), ok: true })),
185
270
  )
186
271
  const stopped = outcomes.filter((o) => !o.ok).map((o) => ({ label: `${o.tool}: ${o.detail.split('\n')[0] ?? 'failed'}`, ok: false }))
187
- // A push is offered back on GitHub below, so it isn't listed as lost.
272
+ // A push, or something made on GitHub, is offered back below, so it isn't listed as lost.
188
273
  const offered = outcomes.some((o) => o.ok && o.pushed)
274
+ const offeredGh = outcomes.some((o) => o.ok && o.github?.length)
275
+ // Deleting the repository takes whatever was pushed to it along.
276
+ const offeredRepo = outcomes.some((o) => o.ok && o.github?.some((g) => g.kind === 'repo'))
189
277
  const kept = taken
190
278
  .flatMap((t) => t.lostItems ?? [])
191
- .filter((l) => !(offered && /pushes to a remote/.test(l)))
279
+ .filter((l) => !((offered || offeredRepo) && /pushes to a remote/.test(l)) && !(offeredGh && /changes GitHub/.test(l)))
192
280
  .map((label) => ({ label, ok: false }))
193
281
  const items: RewindItem[] = [...undone, ...stopped, ...kept]
194
282
  const done = undone.length
@@ -228,17 +316,15 @@ async function undoLastTurn($: EngineInterface, count = 1) {
228
316
  )
229
317
  }
230
318
 
231
- // Asked once the band shows what came back.
232
- void offerPushUndo($, outcomes)
319
+ // Asked once the band shows what came back: GitHub things first, then pushes.
320
+ void offerGithubUndo($, outcomes).then((gone) => offerPushUndo($, outcomes, gone.size > 0))
233
321
 
234
- // Reveal the restored items one by one, then fold the band away.
322
+ // Reveal the items one by one. The list stays until the person's next
323
+ // message (it's in the conversation too), so nothing is missed.
235
324
  const reveal = $.clock.every(90, () => {
236
325
  void update($, rewind, (r) => (r && r.shown < r.items.length ? { ...r, shown: r.shown + 1 } : r))
237
326
  })
238
- $.clock.after(failed || lost ? 12_000 : 4200, () => {
239
- reveal.cancel()
240
- void update($, rewind, () => null)
241
- })
327
+ $.clock.after(90 * (items.length + 2), () => reveal.cancel())
242
328
  }
243
329
 
244
330
  type Next = (e: Record<string, unknown>) => Promise<{ deny?: string; isError?: true; result?: unknown; text?: string }>
@@ -332,11 +418,12 @@ const brief = (command: string) => {
332
418
  // Something in a turn that can't be undone, named, so undo can list it.
333
419
  const lostOne = (x: Turn, what: string): Turn => ({ ...x, lost: (x.lost ?? 0) + 1, lostItems: [...(x.lostItems ?? []), what].slice(-20) })
334
420
 
335
- // A turn named by what the person asked: ("commit and push it").
336
- const named = (t: Turn) => {
421
+ // A turn named by what the person asked: "commit and push it", cut to `max`.
422
+ const quoted = (t: Turn, max = 40) => {
337
423
  const p = (t.prompt ?? '').replace(/\s+/g, ' ').trim()
338
- return p ? ` ("${p.length > 40 ? `${p.slice(0, 39)}…` : p}")` : ''
424
+ return p ? ` "${p.length > max ? `${p.slice(0, max - 1)}…` : p}"` : ''
339
425
  }
426
+ const named = (t: Turn) => (t.prompt ? ` (${quoted(t).trim()})` : '')
340
427
 
341
428
  // Each part only saves to git (add, commit, push and friends), or only moves around.
342
429
  const GIT_SAVE = /^(git\s+(add|commit|push|status|log|diff|show|branch|remote|fetch|rev-parse|config)\b|cd\s|echo\s|true$)/
@@ -394,7 +481,7 @@ async function checkCoverage($: EngineInterface, cwd: string) {
394
481
  const found = await $.process.run(backstep(['connect', '--dir', cwd, '--json', '--auto']), { timeoutMs: 120_000 }).catch(() => null)
395
482
  try {
396
483
  for (const c of JSON.parse(found?.stdout || '[]') as Connected[]) {
397
- if (c.service === 'supabase' && c.state === 'ask') await askSupabase($, c.dir ?? cwd)
484
+ if (c.state === 'ask') await askDatabase($, c.dir ?? cwd, c.service)
398
485
  if (c.state === 'added') $.ui.toast('Backstep added Firebase for this project, so Claude can reach it and its changes can be undone. Restart Claude Code to use it.')
399
486
  }
400
487
  } catch {
@@ -422,36 +509,45 @@ const ASK = {
422
509
  // Projects asked about this session: "Not now" means not again until the next one.
423
510
  const asked = new Set<string>()
424
511
 
425
- // Asked when Claude Code opens a project that uses Supabase, or Claude is about
426
- // to change one, and Backstep isn't connected to it; the answer is the only
427
- // thing that connects it.
428
- async function askSupabase($: EngineInterface, cwd: string) {
429
- if (asked.has(cwd)) return
430
- asked.add(cwd)
512
+ // How each database is named in the question, and what the answer connects.
513
+ const DB_NAME: Record<string, string> = { supabase: 'Supabase', postgres: 'a Postgres database', firebase: 'Firebase' }
514
+
515
+ // Asked when Claude Code opens a project that uses a database (Supabase, other
516
+ // Postgres, Firebase), or Claude is about to change one, and Backstep isn't
517
+ // connected to it; the answer is the only thing that connects it.
518
+ async function askDatabase($: EngineInterface, cwd: string, service = 'supabase') {
519
+ const key = `${service}:${cwd}`
520
+ if (asked.has(key)) return
521
+ asked.add(key)
522
+ const name = DB_NAME[service] ?? service
431
523
  const answer = await $.ui
432
- .ask("This project uses Supabase, but Backstep isn't connected to it, so Claude's changes to its database can't be undone. Connect it?", {
524
+ .ask(`This project uses ${name}, but Backstep isn't connected to it, so Claude's changes to it can't be undone. Connect it?`, {
433
525
  header: 'Backstep',
434
526
  options: [ASK.connect, ASK.later, ASK.never],
435
527
  })
436
528
  .catch(() => null)
437
529
  if (answer === ASK.never) {
438
- await $.process.run(backstep(['connect', 'supabase', '--dir', cwd, '--dismiss'])).catch(() => null)
530
+ await $.process.run(backstep(['connect', service, '--dir', cwd, '--dismiss'])).catch(() => null)
439
531
  return
440
532
  }
441
533
  if (answer !== ASK.connect) return
442
- const ran = await $.process.run(backstep(['connect', 'supabase', '--dir', cwd, '--json']), { timeoutMs: 120_000 }).catch(() => null)
443
- let s: Connected | undefined
534
+ const ran = await $.process.run(backstep(['connect', service, '--dir', cwd, '--json']), { timeoutMs: 120_000 }).catch(() => null)
535
+ let all: Connected[] = []
444
536
  try {
445
- s = (JSON.parse(ran?.stdout || '[]') as Connected[])[0]
537
+ all = JSON.parse(ran?.stdout || '[]') as Connected[]
446
538
  } catch {
447
- s = undefined
539
+ all = []
448
540
  }
449
- if (s?.state === 'connected' || s?.state === 'covered') {
450
- $.ui.toast(`Backstep ${s.detail}. Claude's changes to its database can now be undone.`)
541
+ const s = all.find((c) => c.state === 'connected' || c.state === 'covered') ?? all.find((c) => c.state === 'needs')
542
+ if (s && (s.state === 'connected' || s.state === 'covered')) {
543
+ $.ui.toast(`Backstep ${s.detail}.`)
544
+ $.ui.log(`${MARK_TEXT} Backstep ${s.detail}.`)
451
545
  return
452
546
  }
453
- // Nothing in the project to connect with: a sign-in is needed, and Claude walks the person through it.
454
- $.ui.toast('Backstep needs a Supabase sign-in for this. Ask Claude to "connect Backstep to Supabase" and it will walk you through it.')
547
+ // What's still needed, said where it stays.
548
+ const need = s?.detail ?? `ask Claude to "connect Backstep to ${service}"`
549
+ $.ui.log(`${MARK_TEXT} Not connected to ${name} yet: ${need}`)
550
+ $.ui.toast(service === 'supabase' ? 'Backstep needs a Supabase sign-in for this. Ask Claude to "connect Backstep to Supabase" and it will walk you through it.' : `Backstep isn't connected to ${name} yet: ${need}`)
455
551
  }
456
552
 
457
553
  const UPDATE = { yes: 'Update', later: 'Not now', skip: 'Skip this version' }
@@ -596,6 +692,9 @@ export const register: Register = (on, options) => {
596
692
  on('prompt.submit', async ($, e, next) => {
597
693
  if (e.command || e.isMeta || (e.origin.kind !== 'composer' && e.origin.kind !== 'bridge')) return next(e)
598
694
  const startedAt = await $.clock.now()
695
+ // The last undo's list has been seen: the band goes back to the undo line.
696
+ const shown = await read($, rewind)
697
+ if (shown && shown.phase !== 'running') await update($, rewind, () => null)
599
698
  await update($, turns, (l) => [...(l ?? []), newTurn(startedAt, e.text)].slice(-KEEP_TURNS))
600
699
  const pending = await read($, note)
601
700
  if (!pending) return next(e)
@@ -636,8 +735,8 @@ export const register: Register = (on, options) => {
636
735
  // so it can be saved before the command runs.
637
736
  const checked = await $.process.run(backstep(['db-check', '--cwd', cwd, '--', command])).catch(() => null)
638
737
  try {
639
- const ask = (JSON.parse(checked?.stdout || '{}') as { ask?: string | null }).ask
640
- if (ask) await askSupabase($, ask)
738
+ const { ask, service } = JSON.parse(checked?.stdout || '{}') as { ask?: string | null; service?: string | null }
739
+ if (ask) await askDatabase($, ask, service ?? 'supabase')
641
740
  } catch {
642
741
  // can't tell: run it as before
643
742
  }
@@ -665,9 +764,9 @@ export const register: Register = (on, options) => {
665
764
  )
666
765
  }
667
766
  // A push: once it's run, note what it sent, so undo can offer to take it back there.
668
- if (plan?.id && PUSH.test(command)) {
767
+ if (plan?.id && (PUSH.test(command) || GH_CREATE.test(command))) {
669
768
  const r = await next(e)
670
- await $.process.run(backstep(['shell-end', plan.id])).catch(() => null)
769
+ await $.process.run(backstep(['shell-end', plan.id]), { stdin: r.text ?? '' }).catch(() => null)
671
770
  return r
672
771
  }
673
772
  } else if (tool.startsWith('mcp__')) {
@@ -693,6 +792,24 @@ export const register: Register = (on, options) => {
693
792
  )
694
793
  }
695
794
 
795
+ const waiting = await read($, githubCode)
796
+ // GitHub is waiting on the person: the code, big and plain, says this is a step to take, not a failure.
797
+ const step = waiting ? (
798
+ <Box flexDirection="column" marginTop={r ? 1 : 0}>
799
+ <Box>
800
+ <Text bold>◀</Text>
801
+ <Text color={PINK} bold>◀</Text>
802
+ <Text color="warning" bold>{` One more step to ${waiting.what}:`}</Text>
803
+ </Box>
804
+ <Text>{' Paste this code on the GitHub page that just opened, then press Authorize:'}</Text>
805
+ <Box>
806
+ <Text>{' '}</Text>
807
+ <Text color={PINK} bold>{waiting.code}</Text>
808
+ <Text dimColor>{' (already copied: ⌘V / Ctrl+V)'}</Text>
809
+ </Box>
810
+ </Box>
811
+ ) : null
812
+
696
813
  if (r) {
697
814
  const tone = r.phase === 'failed' ? 'error' : r.phase === 'empty' ? 'warning' : undefined
698
815
  const glyph = r.phase === 'failed' ? '✗' : r.phase === 'empty' ? '!' : '✓'
@@ -720,47 +837,63 @@ export const register: Register = (on, options) => {
720
837
  {i.label}
721
838
  </Text>
722
839
  ))}
840
+ {step}
723
841
  </Box>
724
842
  )
725
843
  }
726
844
 
845
+ if (step) return step
846
+
727
847
  const { target, before } = await undoTargets($)
728
848
  const label = (await read($, keys)).map(keyLabel).join(' / ')
729
849
  if (target && (await read($, armed)) !== null) {
850
+ // Laid out a line at a time, so nothing gets squeezed into columns on a narrow terminal.
851
+ const cancel = (
852
+ <Button key="cancel" plain dimColor onPress={() => void update($, armed, () => null)}>
853
+ cancel
854
+ </Button>
855
+ )
730
856
  // The last turn only committed and pushed: the work it saved is what the person most likely wants back.
731
857
  if (before) {
732
858
  return (
859
+ <Box flexDirection="column">
860
+ <Box>
861
+ <Text bold>◀</Text>
862
+ <Text color={PINK} bold>◀</Text>
863
+ <Text color="warning" bold>{' That turn only saved and pushed to git.'}</Text>
864
+ </Box>
865
+ <Text dimColor>{` Undo it and the turn before it${quoted(before, 50)}?`}</Text>
866
+ <Box>
867
+ <Text>{' '}</Text>
868
+ <Button key="undo" action={UNDO_ACTION} plain onPress={() => void pressUndo($)}>
869
+ {`${label} again to undo both`}
870
+ </Button>
871
+ <Text dimColor>{' · '}</Text>
872
+ <Button key="just" plain dimColor onPress={() => void update($, armed, () => null).then(() => undoLastTurn($, 1))}>
873
+ just the push
874
+ </Button>
875
+ <Text dimColor>{' · '}</Text>
876
+ {cancel}
877
+ </Box>
878
+ </Box>
879
+ )
880
+ }
881
+ return (
882
+ <Box flexDirection="column">
733
883
  <Box>
734
884
  <Text bold>◀</Text>
735
885
  <Text color={PINK} bold>◀</Text>
736
- <Text color="warning" bold> That turn only saved and pushed to git. </Text>
886
+ <Text color="warning" bold>{" Undo Claude's last turn?"}</Text>
887
+ </Box>
888
+ <Text dimColor>{` ${quoted(target, 50).trim() || 'That turn'} · ${summary(target)}`}</Text>
889
+ <Box>
890
+ <Text>{' '}</Text>
737
891
  <Button key="undo" action={UNDO_ACTION} plain onPress={() => void pressUndo($)}>
738
- {`${label} again to undo it and the turn before${named(before)}`}
739
- </Button>
740
- <Text dimColor> · </Text>
741
- <Button key="just" plain dimColor onPress={() => void update($, armed, () => null).then(() => undoLastTurn($, 1))}>
742
- just the push
743
- </Button>
744
- <Text dimColor> · </Text>
745
- <Button key="cancel" plain dimColor onPress={() => void update($, armed, () => null)}>
746
- cancel
892
+ {`${label} again to undo`}
747
893
  </Button>
894
+ <Text dimColor>{' · '}</Text>
895
+ {cancel}
748
896
  </Box>
749
- )
750
- }
751
- return (
752
- <Box>
753
- <Text bold>◀</Text>
754
- <Text color={PINK} bold>◀</Text>
755
- <Text color="warning" bold>{` Undo Claude's last turn${named(target)}? `}</Text>
756
- <Text dimColor>{summary(target)} </Text>
757
- <Button key="undo" action={UNDO_ACTION} plain onPress={() => void pressUndo($)}>
758
- {`${label} again to undo`}
759
- </Button>
760
- <Text dimColor> · </Text>
761
- <Button key="cancel" plain dimColor onPress={() => void update($, armed, () => null)}>
762
- cancel
763
- </Button>
764
897
  </Box>
765
898
  )
766
899
  }
@@ -773,7 +906,7 @@ export const register: Register = (on, options) => {
773
906
  <Text bold>◀</Text>
774
907
  <Text color={PINK} bold>◀</Text>
775
908
  <Text> </Text>
776
- <Text dimColor>{target ? `Last turn${named(target)}: ${summary(target)} ` : 'Backstep is on · nothing to undo yet '}</Text>
909
+ <Text dimColor>{target ? `Last turn:${quoted(target, 28)} · ${summary(target)} ` : 'Backstep is on · nothing to undo yet '}</Text>
777
910
  <Button key="undo" action={UNDO_ACTION} plain dimColor onPress={() => void pressUndo($)}>
778
911
  {`${label} to undo`}
779
912
  </Button>
@@ -29,10 +29,14 @@ export type Rewind = {
29
29
  message: string
30
30
  }
31
31
 
32
+ // GitHub waiting on the person's OK for one more permission: the one-time code to paste, and what it's for.
33
+ export type GithubCode = { code: string; what: string }
34
+
32
35
  declare module 'claude-code' {
33
36
  interface PluginState {
34
37
  // armed: when the undo key was first pressed; a second press within a few seconds undoes.
35
38
  // keys: the keys bound to undo, kept here so the band redraws when they change.
36
- backstep: { turns: Turn[]; rewind: Rewind | null; note: string | null; armed: number | null; keys: string[] }
39
+ // githubCode: the code GitHub's approval page wants, shown on the band until GitHub answers.
40
+ backstep: { turns: Turn[]; rewind: Rewind | null; note: string | null; armed: number | null; keys: string[]; githubCode: GithubCode | null }
37
41
  }
38
42
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backstep",
3
- "version": "0.1.10",
3
+ "version": "0.1.14",
4
4
  "description": "Undo for everything your agents touch: an MCP proxy that makes agent actions reversible.",
5
5
  "scripts": {
6
6
  "dev": "node src/cli.ts",