@g3nomaly/g3n-mods 0.1.5 → 0.1.6

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.
@@ -0,0 +1,154 @@
1
+ // packages/g3n-mods/mods/custom-crab-agents/view/view.ts
2
+ import { crabSvg } from '../hooks/sprite'
3
+ import type { Pose } from '../hooks/sprite'
4
+ import type { CrabView, CrabsViewData, PlannedView } from '../types'
5
+
6
+ /** What the viewer hands a view: a way to send the mod an action and to open a link. */
7
+ type ViewApi = { act: (name: string, arg?: unknown) => void; openLink: (href: string) => void }
8
+
9
+ /** Makes an element with a class, and text when given. */
10
+ function el<K extends keyof HTMLElementTagNameMap>(tag: K, className: string, text?: string): HTMLElementTagNameMap[K] {
11
+ const node = document.createElement(tag)
12
+ node.className = className
13
+ if (text !== undefined) node.textContent = text
14
+ return node
15
+ }
16
+
17
+ /** Sets an element's text only when it changed. */
18
+ function setText(node: Element, text: string): void {
19
+ if (node.textContent !== text) node.textContent = text
20
+ }
21
+
22
+ /**
23
+ * Puts a crab's sprite in its slot, only when its look changed: the SVG animates itself, and replacing it would
24
+ * start its walk over.
25
+ */
26
+ function setSprite(slot: HTMLElement, role: CrabView['role'], pose: Pose, phase: number): void {
27
+ const look = `${role}:${pose}:${phase}`
28
+ if (slot.dataset.look === look) return
29
+ slot.dataset.look = look
30
+ slot.innerHTML = crabSvg(role, pose, phase)
31
+ }
32
+
33
+ /** Moves `children` into `parent` in this order, touching only those out of place, and drops the rest. */
34
+ function arrange(parent: HTMLElement, children: HTMLElement[]): void {
35
+ children.forEach((child, index) => {
36
+ if (parent.children[index] !== child) parent.insertBefore(child, parent.children[index] ?? null)
37
+ })
38
+ while (parent.children.length > children.length) parent.lastElementChild?.remove()
39
+ }
40
+
41
+ /**
42
+ * @brief Mounts the crabs: each subagent as a card with its animated crab, its role, model, steps and stats, and a
43
+ * bar. A card is one real button that opens the agent's chat. Running crabs come first, then the finished ones in a
44
+ * group that folds, then the planned ones in grey.
45
+ *
46
+ * @param root - The shadow root the viewer gives this view.
47
+ * @param api - Sends `openChat` to the mod.
48
+ * @returns The instance the viewer hands each update to.
49
+ */
50
+ export function mount(root: ShadowRoot, api: ViewApi): { update: (data: unknown) => void } {
51
+ const frame = el('div', 'cr')
52
+ const head = el('header', 'cr-head')
53
+ const count = el('span', 'cr-count')
54
+ head.append(el('span', 'cr-title', 'Subagents'), count)
55
+ const running = el('div', 'cr-list')
56
+ const finished = el('details', 'cr-group')
57
+ finished.open = true
58
+ const finishedLabel = el('summary', 'cr-group-label')
59
+ const finishedList = el('div', 'cr-list')
60
+ finished.append(finishedLabel, finishedList)
61
+ const planned = el('section', 'cr-group cr-planned')
62
+ const plannedLabel = el('div', 'cr-group-label')
63
+ const plannedList = el('div', 'cr-list')
64
+ planned.append(plannedLabel, plannedList)
65
+ const empty = el('p', 'cr-empty', 'No subagents yet. Each one shows up here as a crab.')
66
+ frame.append(head, running, finished, planned, empty)
67
+ root.append(frame)
68
+
69
+ const cards = new Map<string, HTMLButtonElement>()
70
+ const plans = new Map<number, HTMLElement>()
71
+
72
+ /** Builds a crab's card once: a button holding the sprite and the text. */
73
+ const makeCard = (id: string): HTMLButtonElement => {
74
+ const card = el('button', 'cr-card')
75
+ card.type = 'button'
76
+ card.addEventListener('click', () => api.act('openChat', { id }))
77
+ const sprite = el('span', 'cr-sprite')
78
+ const body = el('span', 'cr-body')
79
+ const top = el('span', 'cr-top')
80
+ top.append(el('span', 'cr-name'), el('span', 'cr-mark'))
81
+ const meta = el('span', 'cr-meta')
82
+ meta.append(el('span', 'cr-role'), el('span', 'cr-model'))
83
+ const line = el('span', 'cr-line')
84
+ line.append(el('span', 'cr-step'), el('span', 'cr-stats'))
85
+ const bar = el('span', 'cr-bar')
86
+ bar.append(el('span', 'cr-fill'))
87
+ body.append(top, meta, line, bar)
88
+ card.append(sprite, body)
89
+ return card
90
+ }
91
+
92
+ /** Brings a crab's card up to date. */
93
+ const fill = (card: HTMLButtonElement, crab: CrabView): void => {
94
+ card.dataset.status = crab.status
95
+ card.title = `Open the conversation of ${crab.title}`
96
+ card.style.setProperty('--tier', crab.color)
97
+ setSprite(card.querySelector('.cr-sprite') as HTMLElement, crab.role, crab.status === 'running' ? 'running' : 'idle', crab.phase)
98
+ setText(card.querySelector('.cr-name') as HTMLElement, crab.title)
99
+ setText(card.querySelector('.cr-mark') as HTMLElement, crab.status === 'running' ? '' : crab.status === 'failed' ? '✗' : '✓')
100
+ setText(card.querySelector('.cr-role') as HTMLElement, crab.roleName)
101
+ setText(card.querySelector('.cr-model') as HTMLElement, `${crab.model}${crab.effort === undefined ? '' : ` · ${crab.effort}`}`)
102
+ setText(card.querySelector('.cr-step') as HTMLElement, crab.stepLine)
103
+ setText(card.querySelector('.cr-stats') as HTMLElement, crab.stats)
104
+ ;(card.querySelector('.cr-fill') as HTMLElement).style.width = `${(Math.max(0, Math.min(1, crab.share)) * 100).toFixed(1)}%`
105
+ }
106
+
107
+ /** Builds or updates a planned item's grey crab. */
108
+ const plannedCard = (item: PlannedView): HTMLElement => {
109
+ let card = plans.get(item.index)
110
+ if (card === undefined) {
111
+ card = el('div', 'cr-card is-planned')
112
+ const body = el('span', 'cr-body')
113
+ const top = el('span', 'cr-top')
114
+ top.append(el('span', 'cr-name'), el('span', 'cr-mark', '◷'))
115
+ body.append(top, el('span', 'cr-meta'))
116
+ card.append(el('span', 'cr-sprite'), body)
117
+ plans.set(item.index, card)
118
+ }
119
+ setSprite(card.querySelector('.cr-sprite') as HTMLElement, item.role, 'planned', 0)
120
+ setText(card.querySelector('.cr-name') as HTMLElement, `${item.index}. ${item.content}`)
121
+ setText(card.querySelector('.cr-meta') as HTMLElement, `planned${item.after.length > 0 ? ` · after ${item.after.join(', ')}` : ''}`)
122
+ return card
123
+ }
124
+
125
+ return {
126
+ update: (data: unknown) => {
127
+ const view = data as CrabsViewData | null
128
+ const crabs = view?.crabs ?? []
129
+ const plan = view?.planned ?? []
130
+ for (const [id, card] of cards) if (!crabs.some(crab => crab.id === id)) { card.remove(); cards.delete(id) }
131
+ const cardOf = (crab: CrabView): HTMLButtonElement => {
132
+ let card = cards.get(crab.id)
133
+ if (card === undefined) {
134
+ card = makeCard(crab.id)
135
+ cards.set(crab.id, card)
136
+ }
137
+ fill(card, crab)
138
+ return card
139
+ }
140
+ const live = crabs.filter(crab => crab.status === 'running').map(cardOf)
141
+ const done = crabs.filter(crab => crab.status !== 'running').reverse().map(cardOf)
142
+ arrange(running, live)
143
+ arrange(finishedList, done)
144
+ setText(finishedLabel, `Finished · ${done.length}`)
145
+ finished.hidden = done.length === 0
146
+ for (const index of [...plans.keys()]) if (!plan.some(item => item.index === index)) plans.delete(index)
147
+ arrange(plannedList, plan.map(plannedCard))
148
+ setText(plannedLabel, `Planned · ${plan.length}`)
149
+ planned.hidden = plan.length === 0
150
+ empty.hidden = crabs.length > 0 || plan.length > 0
151
+ setText(count, [live.length > 0 ? `${live.length} running` : '', done.length > 0 ? `${done.length} done` : ''].filter(Boolean).join(' · '))
152
+ },
153
+ }
154
+ }
@@ -33,9 +33,6 @@ const WARN_BG = '#3a2222'
33
33
  const WARN_EDGE = '#7a3b3b'
34
34
  const UNTRACKED_BG = '#6b3a22'
35
35
 
36
- /** Shell syntax that starts a new command: `&&`, `||`, `;`, `|`, a newline, `$(` and a backtick. */
37
- const SEGMENT_BREAK = /&&|\|\||;|\||\n|\$\(|`/
38
-
39
36
  /** Words that run the command after them: `sudo rm`, `xargs -0 rm`, `env X=1 rm`. */
40
37
  const PREFIX = /^(?:\w+=\S*\s+|(?:sudo|command|exec|nice|nohup|time|env)\s+(?:-\S+\s+)*|xargs\s+(?:-\S+\s+)*)+/
41
38
 
@@ -49,13 +46,51 @@ const TRASH_COMMANDS = [
49
46
  ] as const
50
47
 
51
48
  /**
52
- * Splits a shell command line into its commands, each trimmed.
49
+ * Splits a shell command line into its commands, each trimmed, as a shell reads it: at `&&`, `||`, `;`, `|`, a
50
+ * newline, a subshell and a command substitution. Quoted text is one word, not commands, and a heredoc body is data,
51
+ * so `python3 - <<EOF` with code that mentions a delete is not taken for one.
53
52
  *
54
53
  * @param command - Shell command line, e.g. `cd build && rm -rf dist`.
55
54
  * @returns The non-empty commands in order.
56
55
  */
57
56
  function segments(command: string): string[] {
58
- return command.split(SEGMENT_BREAK).map(part => part.trim()).filter(part => part.length > 0)
57
+ const parts: string[] = []
58
+ let part = ''
59
+ let quote: "'" | '"' | undefined
60
+ // Command substitutions opened inside double quotes, innermost last: their `)` returns to the quoted text.
61
+ const resume: Array<'"'> = []
62
+ const heredocs: string[] = []
63
+ const lines = command.split('\n')
64
+ for (let l = 0; l < lines.length; l++) {
65
+ const line = lines[l] ?? ''
66
+ for (let i = 0; i < line.length; i++) {
67
+ const char = line.charAt(i)
68
+ const pair = line.slice(i, i + 2)
69
+ if (quote !== undefined) {
70
+ // Inside quotes nothing splits, except a command substitution, which runs, in double quotes.
71
+ if (char === quote) quote = undefined
72
+ else if (quote === '"' && pair === '$(') { parts.push(part); part = ''; resume.push('"'); quote = undefined; i += 1; continue }
73
+ else if (quote === '"' && char === '`') { parts.push(part); part = ''; continue }
74
+ part += char
75
+ continue
76
+ }
77
+ if (char === ')' && resume.length > 0) { parts.push(part); part = ''; quote = resume.pop(); continue }
78
+ if (char === "'" || char === '"') { quote = char; part += char; continue }
79
+ const heredoc = /^<<-?\s*(['"]?)([A-Za-z_][\w-]*)\1/.exec(line.slice(i))
80
+ if (heredoc !== null) { heredocs.push(heredoc[2] ?? ''); part += heredoc[0]; i += heredoc[0].length - 1; continue }
81
+ const breakLength = pair === '&&' || pair === '||' || pair === '$(' ? 2 : char === ';' || char === '|' || char === '`' || char === '(' || char === ')' ? 1 : 0
82
+ if (breakLength > 0) { parts.push(part); part = ''; i += breakLength - 1; continue }
83
+ part += char
84
+ }
85
+ // A heredoc's body is data handed to the command, not commands: skip its lines up to the closing word.
86
+ for (const word of heredocs.splice(0)) {
87
+ while (l + 1 < lines.length && (lines[l + 1] ?? '').trim() !== word) l++
88
+ l++
89
+ }
90
+ if (quote === undefined) { parts.push(part); part = '' } else part += '\n'
91
+ }
92
+ parts.push(part)
93
+ return parts.map(one => one.trim()).filter(one => one.length > 0)
59
94
  }
60
95
 
61
96
  /**
@@ -92,21 +127,49 @@ function operands(args: readonly string[]): string[] {
92
127
  * @returns The delete and what it removes, or `undefined` when nothing is deleted for good.
93
128
  */
94
129
  export function findDeletion(command: string): Deletion | undefined {
130
+ // A `cd` before the delete moves where it runs: `cd guard-demo && rm -rf build` deletes guard-demo/build.
131
+ let dir: string | undefined
132
+ const at = (found: Omit<Deletion, 'dir'>): Deletion => (dir === undefined ? found : { ...found, dir })
95
133
  for (const segment of segments(command)) {
96
134
  const [program = '', ...args] = words(segment)
135
+ if (program === 'cd') {
136
+ const target = unquote(args[0] ?? '~')
137
+ dir = dir === undefined || target.startsWith('/') || target.startsWith('~') ? target : `${dir}/${target}`
138
+ continue
139
+ }
97
140
  if (DELETERS.has(program)) {
98
- return { segment, kind: 'files', paths: operands(args) }
141
+ return at({ segment, kind: 'files', paths: operands(args) })
99
142
  }
100
143
  if (program === 'find' && (args.includes('-delete') || /-exec(?:dir)?\s+(?:\S*\/)?rm\b/.test(segment))) {
101
- return { segment, kind: 'find', paths: [] }
144
+ return at({ segment, kind: 'find', paths: [] })
102
145
  }
103
146
  if (program === 'git' && args[0] === 'clean' && args.some(arg => /^-\w*f/.test(arg) || arg === '--force')) {
104
- return { segment, kind: 'git-clean', paths: [] }
147
+ return at({ segment, kind: 'git-clean', paths: [] })
105
148
  }
106
149
  }
107
150
  return undefined
108
151
  }
109
152
 
153
+ /**
154
+ * Resolves a path as the shell would from `base`: `~` to the home directory, `.` and `..` folded.
155
+ *
156
+ * @param base - Absolute folder a relative path starts from.
157
+ * @param path - Path as the command spelled it, quotes removed.
158
+ * @param home - Home directory, for `~`.
159
+ * @returns The absolute path.
160
+ */
161
+ export function resolvePath(base: string, path: string, home: string): string {
162
+ const expanded = path.replace(/^~(?=\/|$)/, home)
163
+ const joined = expanded.startsWith('/') ? expanded : `${base.replace(/\/$/, '')}/${expanded}`
164
+ const parts: string[] = []
165
+ for (const part of joined.split('/')) {
166
+ if (part === '' || part === '.') continue
167
+ if (part === '..') parts.pop()
168
+ else parts.push(part)
169
+ }
170
+ return `/${parts.join('/')}`
171
+ }
172
+
110
173
  /**
111
174
  * Builds the command Claude should run instead of a permanent delete.
112
175
  *
@@ -178,11 +241,12 @@ function globRegExp(glob: string): RegExp {
178
241
  *
179
242
  * @param $ - Engine handle the hook received.
180
243
  * @param operand - Operand as the command spelled it.
244
+ * @param base - Absolute folder the delete runs in.
181
245
  * @param home - Home directory, for `~`.
182
246
  * @returns The paths, empty when a glob matches nothing.
183
247
  */
184
- async function expandOperand($: EngineInterface, operand: string, home: string): Promise<string[]> {
185
- const path = unquote(operand).replace(/^~(?=\/|$)/, home)
248
+ async function expandOperand($: EngineInterface, operand: string, base: string, home: string): Promise<string[]> {
249
+ const path = resolvePath(base, unquote(operand), home)
186
250
  if (!/[*?[]/.test(path)) return [path]
187
251
  const cut = path.lastIndexOf('/')
188
252
  const dir = cut === -1 ? '.' : path.slice(0, cut) || '/'
@@ -227,16 +291,16 @@ async function walk($: EngineInterface, path: string, found: { path: string; siz
227
291
  * @param deletion - A delete of kind `find` or `git-clean`.
228
292
  * @returns The paths, or `undefined` when the listing failed.
229
293
  */
230
- async function listedPaths($: EngineInterface, deletion: Deletion): Promise<string[] | undefined> {
294
+ async function listedPaths($: EngineInterface, deletion: Deletion, base: string): Promise<string[] | undefined> {
231
295
  const argv = words(deletion.segment).map(unquote)
232
296
  if (deletion.kind === 'git-clean') {
233
297
  const flags = argv.slice(2)
234
298
  .filter(arg => arg !== '--force')
235
299
  .map(arg => (/^-[^-]/.test(arg) ? arg.replace(/f/g, '') : arg))
236
300
  .filter(arg => arg !== '-')
237
- const ran = await $.process.run(['git', 'clean', '-n', ...flags], { timeoutMs: 10_000 }).catch(() => undefined)
301
+ const ran = await $.process.run(['git', 'clean', '-n', ...flags], { cwd: base, timeoutMs: 10_000 }).catch(() => undefined)
238
302
  if (ran?.exitCode !== 0) return undefined
239
- return ran.stdout.split('\n').map(line => line.replace(/^Would remove /, '').trim()).filter(line => line !== '')
303
+ return ran.stdout.split('\n').map(line => line.replace(/^Would remove /, '').trim()).filter(line => line !== '').map(line => `${base}/${line}`)
240
304
  }
241
305
  const kept: string[] = []
242
306
  for (let i = 0; i < argv.length; i++) {
@@ -248,9 +312,9 @@ async function listedPaths($: EngineInterface, deletion: Deletion): Promise<stri
248
312
  }
249
313
  kept.push(arg)
250
314
  }
251
- const ran = await $.process.run([...kept, '-print'], { timeoutMs: 10_000 }).catch(() => undefined)
315
+ const ran = await $.process.run([...kept, '-print'], { cwd: base, timeoutMs: 10_000 }).catch(() => undefined)
252
316
  if (ran?.exitCode !== 0) return undefined
253
- return ran.stdout.split('\n').filter(line => line !== '')
317
+ return ran.stdout.split('\n').filter(line => line !== '').map(line => (line.startsWith('/') ? line : `${base}/${line}`))
254
318
  }
255
319
 
256
320
  /**
@@ -260,8 +324,8 @@ async function listedPaths($: EngineInterface, deletion: Deletion): Promise<stri
260
324
  * @param paths - Roots of the delete, relative to the session's directory or absolute.
261
325
  * @returns Tracked paths relative to the session's directory; empty outside a repository.
262
326
  */
263
- async function trackedFiles($: EngineInterface, paths: readonly string[]): Promise<Set<string>> {
264
- const ran = await $.process.run(['git', 'ls-files', '-z', '--', ...paths], { timeoutMs: 10_000 }).catch(() => undefined)
327
+ async function trackedFiles($: EngineInterface, paths: readonly string[], base: string): Promise<Set<string>> {
328
+ const ran = await $.process.run(['git', 'ls-files', '-z', '--', ...paths], { cwd: base, timeoutMs: 10_000 }).catch(() => undefined)
265
329
  if (ran?.exitCode !== 0) return new Set()
266
330
  return new Set(ran.stdout.split('\0').filter(path => path !== ''))
267
331
  }
@@ -271,14 +335,14 @@ async function trackedFiles($: EngineInterface, paths: readonly string[]): Promi
271
335
  *
272
336
  * @param $ - Engine handle the hook received.
273
337
  * @param deletion - Delete to measure, see `findDeletion`.
274
- * @param cwd - Session's directory, absolute.
338
+ * @param base - Absolute folder the delete runs in, after any `cd` before it.
275
339
  * @param home - Home directory, for `~`.
276
340
  * @returns The files largest first, with their total; `isKnown` false when they could not be listed.
277
341
  */
278
- async function measure($: EngineInterface, deletion: Deletion, cwd: string, home: string): Promise<BlastRadius> {
342
+ async function measure($: EngineInterface, deletion: Deletion, base: string, home: string): Promise<BlastRadius> {
279
343
  const roots = deletion.kind === 'files'
280
- ? (await Promise.all(deletion.paths.map(operand => expandOperand($, operand, home)))).flat()
281
- : await listedPaths($, deletion)
344
+ ? (await Promise.all(deletion.paths.map(operand => expandOperand($, operand, base, home)))).flat()
345
+ : await listedPaths($, deletion, base)
282
346
  if (roots === undefined) return { files: [], total: 0, isCut: false, isKnown: false }
283
347
 
284
348
  const found: { path: string; size: number }[] = []
@@ -289,10 +353,10 @@ async function measure($: EngineInterface, deletion: Deletion, cwd: string, home
289
353
  break
290
354
  }
291
355
  }
292
- const tracked = deletion.kind === 'git-clean' || roots.length === 0 ? new Set<string>() : await trackedFiles($, roots)
356
+ const tracked = deletion.kind === 'git-clean' || roots.length === 0 ? new Set<string>() : await trackedFiles($, roots, base)
293
357
  const files: DoomedFile[] = found
294
358
  .map(file => {
295
- const path = relativeTo(cwd, file.path)
359
+ const path = relativeTo(base, file.path)
296
360
  return { path, size: file.size, isTracked: tracked.has(path) }
297
361
  })
298
362
  .sort((a, b) => b.size - a.size)
@@ -320,6 +384,7 @@ function fileCount(count: number, isCut = false): string {
320
384
  */
321
385
  function restoreNote(radius: BlastRadius): string {
322
386
  if (!radius.isKnown) return "Couldn't list what this deletes, so its size is unknown."
387
+ if (radius.files.length === 0) return "Couldn't find what this names here, so it may delete nothing, or something the guard can't see."
323
388
  const tracked = radius.files.filter(file => file.isTracked).length
324
389
  if (tracked === 0) return "None of these are tracked by git, so they can't be restored."
325
390
  if (tracked === radius.files.length) return 'All of these are tracked by git: their committed versions can be restored, uncommitted changes cannot.'
@@ -415,7 +480,6 @@ async function hold($: EngineInterface, card: Omit<Held, 'settle'>, signal: Abor
415
480
  settle(answer)
416
481
  void showSwitch($)
417
482
  }
418
- const timer = $.clock.after(HOLD_MS, () => drop('timeout'))
419
483
  const onAbort = (): void => drop('cancel')
420
484
  signal?.addEventListener('abort', onAbort, { once: true })
421
485
 
@@ -423,8 +487,19 @@ async function hold($: EngineInterface, card: Omit<Held, 'settle'>, signal: Abor
423
487
  if (held.length === 1) await $.ui.open({ id: PANE, title: HOLD_TITLE, focus: true })
424
488
  $.ui.invalidate('ui.render')
425
489
 
426
- const answer = await answered
427
- timer.cancel()
490
+ // The wait runs on a child process that sleeps out the hold. A hook has ten seconds of its own time; waiting on the
491
+ // engine does not count against it, but waiting on a promise of the module's own (or on $.clock.sleep) does, so a
492
+ // plain await of the answer would fail the hook after ten seconds. Closing the stream kills the child.
493
+ const sleeper = $.process.spawn({ argv: ['sleep', String(Math.ceil(HOLD_MS / 1000))] })
494
+ const slept = (async (): Promise<'timeout'> => {
495
+ try {
496
+ while (!(await sleeper.next()).done) { /* sleep prints nothing */ }
497
+ } catch { /* closed early, or could not start */ }
498
+ return 'timeout'
499
+ })()
500
+ const answer = await Promise.race([answered, slept])
501
+ if (answer === 'timeout') drop('timeout')
502
+ void sleeper.return(undefined as never).catch(() => undefined)
428
503
  signal?.removeEventListener('abort', onAbort)
429
504
  return answer
430
505
  }
@@ -461,7 +536,8 @@ export const register: Register = on => {
461
536
  })
462
537
 
463
538
  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
464
- if (!(await read($, isOn))) return next(e)
539
+ // The saved switch, not this session's copy, so a switch flipped in any session holds here at once.
540
+ if ((await $.store.get(STORE_KEY).catch(() => undefined)) !== true) return next(e)
465
541
  const deletion = findDeletion(e.command)
466
542
  if (deletion === undefined) return next(e)
467
543
 
@@ -474,18 +550,22 @@ export const register: Register = on => {
474
550
  return { deny: `delete-guard: \`${deletion.segment}\` deletes permanently. Move it to the trash instead: ${instead}.` }
475
551
  }
476
552
 
553
+ // Where the delete runs: the session's folder, moved by any `cd` the command makes first. When nothing it names is
554
+ // found there the card still asks, since the guard may be looking in the wrong place.
477
555
  const cwd = await $.session.cwd()
478
- const radius = await measure($, deletion, cwd, home)
479
- if (radius.isKnown && radius.files.length === 0) return next(e)
556
+ const base = deletion.dir === undefined ? cwd : resolvePath(cwd, deletion.dir, home)
557
+ const radius = await measure($, deletion, base, home)
480
558
 
481
- const folder = cwd.split('/').filter(Boolean).pop() ?? cwd
559
+ const folder = base.split('/').filter(Boolean).pop() ?? base
482
560
  const answer = await hold($, { segment: deletion.segment, folder, radius }, next.signal)
483
561
  if (answer === 'delete') return next(e)
484
562
  if (answer === 'timeout') {
485
563
  return { deny: `delete-guard: no one answered the delete card within ${HOLD_MS / 60_000} minutes, so \`${deletion.segment}\` did not run. Ask the user before trying again.` }
486
564
  }
487
565
  return { deny: `delete-guard: the user cancelled \`${deletion.segment}\`. Do not delete these files without asking; to remove them, move them to the trash: ${instead}.` }
488
- }).catch(($, e, next) => (next.called ? next(e) : { deny: 'delete-guard: the guard failed, so the command was not run.' }))
566
+ }).catch(($, e, next) => (next.called
567
+ ? next(e)
568
+ : { deny: `delete-guard: the guard failed (${next.error.kind}${next.error.message === undefined ? '' : `: ${next.error.message}`}), so the command was not run.` }))
489
569
 
490
570
  on('ui.close', async ($, e, next) => {
491
571
  // Closing the card by hand answers every delete it holds with Cancel.
@@ -500,7 +580,9 @@ export const register: Register = on => {
500
580
  const { Box, Text, Button } = $.ui.resolve(e)
501
581
  const card = held[0]
502
582
  if (card === undefined) {
503
- const isGuarding = await read($, isOn)
583
+ // The saved switch, which any session may have flipped; this session's copy until one is saved.
584
+ const stored = await $.store.get(STORE_KEY).catch(() => undefined)
585
+ const isGuarding = stored === undefined ? await read($, isOn) : stored === true
504
586
  return (
505
587
  <Box flexDirection="row" columnGap={2} alignItems="center">
506
588
  <Button key="switch" label={isGuarding ? '● on' : '○ off'} variant={isGuarding ? 'primary' : 'secondary'} onPress={() => setGuard($, !isGuarding)} />
@@ -575,7 +657,7 @@ export const register: Register = on => {
575
657
  </Box>
576
658
  )}
577
659
  <Box flexDirection="row" columnGap={2} alignItems="center" marginTop={1}>
578
- <Button key="delete" variant="primary" label={`Delete ${fileCount(radius.files.length, radius.isCut)}`} onPress={() => answerFirst($, 'delete')} />
660
+ <Button key="delete" variant="primary" label={radius.files.length === 0 ? 'Run it' : `Delete ${fileCount(radius.files.length, radius.isCut)}`} onPress={() => answerFirst($, 'delete')} />
579
661
  <Button key="cancel" label="Cancel" onPress={() => answerFirst($, 'cancel')} />
580
662
  <Text dimColor>Held by delete-guard</Text>
581
663
  </Box>
@@ -5,6 +5,8 @@ export type Deletion = {
5
5
  segment: string
6
6
  kind: 'files' | 'find' | 'git-clean'
7
7
  paths: string[]
8
+ /** Where the delete runs when the command changes directory before it, as its `cd`s spell it, e.g. `guard-demo` */
9
+ dir?: string
8
10
  }
9
11
 
10
12
  /** One file a delete would remove: its path relative to the session's directory, its bytes, and whether git tracks it. */