@g3nomaly/g3n-mods 0.1.5 → 0.1.7
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/.claude-plugin/marketplace.json +1 -1
- package/README.md +17 -4
- package/mods/agents-panel/hooks/register.tsx +49 -21
- package/mods/agents-panel/types/index.d.ts +16 -1
- package/mods/agents-panel/view/view.css +51 -0
- package/mods/agents-panel/view/view.ts +126 -0
- package/mods/custom-crab-agents/hooks/register.tsx +108 -95
- package/mods/custom-crab-agents/hooks/sprite.ts +131 -0
- package/mods/custom-crab-agents/types/index.d.ts +35 -0
- package/mods/custom-crab-agents/view/view.css +74 -0
- package/mods/custom-crab-agents/view/view.ts +154 -0
- package/mods/delete-guard/hooks/register.tsx +115 -33
- package/mods/delete-guard/types/index.d.ts +2 -0
- package/mods/mini-game/.claude-plugin/plugin.json +1 -1
- package/mods/mini-game/hooks/bubbles.ts +27 -0
- package/mods/mini-game/hooks/minesweeper.ts +108 -0
- package/mods/mini-game/hooks/mole.ts +91 -0
- package/mods/mini-game/hooks/register.tsx +196 -152
- package/mods/mini-game/types/index.d.ts +18 -1
- package/mods/tool-timeline/hooks/register.tsx +141 -38
- package/mods/tool-timeline/types/index.d.ts +33 -2
- package/mods/tool-timeline/view/view.css +124 -0
- package/mods/tool-timeline/view/view.ts +164 -0
- package/mods/tsconfig.view.json +14 -0
- package/package.json +2 -2
|
@@ -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
|
-
|
|
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)
|
|
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
|
|
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,
|
|
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(
|
|
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
|
-
|
|
427
|
-
|
|
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
|
-
|
|
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
|
|
479
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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. */
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// packages/g3n-mods/mods/mini-game/hooks/bubbles.ts
|
|
2
|
+
import type { Sheet } from '../types'
|
|
3
|
+
|
|
4
|
+
const SIZE = 8
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Creates a sheet with every bubble whole.
|
|
8
|
+
*
|
|
9
|
+
* @param cleared - Sheets popped clean before this one.
|
|
10
|
+
* @returns The new sheet, see `Sheet`.
|
|
11
|
+
*/
|
|
12
|
+
export function newSheet(cleared: number): Sheet {
|
|
13
|
+
return { size: SIZE, popped: [], cleared }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Pops one bubble; popping the last one swaps in a fresh sheet.
|
|
18
|
+
*
|
|
19
|
+
* @param sheet - Sheet to pop on, see `Sheet`.
|
|
20
|
+
* @param bubble - Bubble number, counted row by row from `0`.
|
|
21
|
+
* @returns The sheet after the pop, or the same sheet when the bubble was already popped.
|
|
22
|
+
*/
|
|
23
|
+
export function pop(sheet: Sheet, bubble: number): Sheet {
|
|
24
|
+
if (sheet.popped.includes(bubble)) return sheet
|
|
25
|
+
const popped = [...sheet.popped, bubble]
|
|
26
|
+
return popped.length === sheet.size * sheet.size ? newSheet(sheet.cleared + 1) : { ...sheet, popped }
|
|
27
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// packages/g3n-mods/mods/mini-game/hooks/minesweeper.ts
|
|
2
|
+
import type { Game } from '../types'
|
|
3
|
+
|
|
4
|
+
const SIZE = 9
|
|
5
|
+
const MINES = 10
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Lists the cells around `cell` on a `size` × `size` board.
|
|
9
|
+
*
|
|
10
|
+
* @param cell - Cell number, counted row by row from `0`.
|
|
11
|
+
* @param size - Board width and height in cells.
|
|
12
|
+
* @returns The neighboring cell numbers, from one to eight of them.
|
|
13
|
+
*/
|
|
14
|
+
export function neighbors(cell: number, size: number): number[] {
|
|
15
|
+
const row = Math.floor(cell / size)
|
|
16
|
+
const col = cell % size
|
|
17
|
+
const found: number[] = []
|
|
18
|
+
for (let dr = -1; dr <= 1; dr++) {
|
|
19
|
+
for (let dc = -1; dc <= 1; dc++) {
|
|
20
|
+
const r = row + dr
|
|
21
|
+
const c = col + dc
|
|
22
|
+
if ((dr !== 0 || dc !== 0) && r >= 0 && r < size && c >= 0 && c < size) found.push(r * size + c)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return found
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Counts the mines in the cells around `cell`.
|
|
30
|
+
*
|
|
31
|
+
* @param board - Board to read, see `Game`.
|
|
32
|
+
* @param cell - Cell number to count around.
|
|
33
|
+
* @returns Number of neighboring mines, from `0` to `8`.
|
|
34
|
+
*/
|
|
35
|
+
export function adjacentMines(board: Game, cell: number): number {
|
|
36
|
+
return neighbors(cell, board.size).filter(n => board.mines.includes(n)).length
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Picks random cells for the mines, leaving `safe` and the cells around it free.
|
|
41
|
+
*
|
|
42
|
+
* @param board - Board to place mines on, see `Game`.
|
|
43
|
+
* @param safe - Cell the first reveal hit, which must not be a mine.
|
|
44
|
+
* @returns Mine cell numbers, `board.mineCount` of them.
|
|
45
|
+
*/
|
|
46
|
+
function placeMines(board: Game, safe: number): number[] {
|
|
47
|
+
const banned = new Set([safe, ...neighbors(safe, board.size)])
|
|
48
|
+
const free = Array.from({ length: board.size * board.size }, (_, i) => i).filter(i => !banned.has(i))
|
|
49
|
+
for (let i = free.length - 1; i > 0; i--) {
|
|
50
|
+
const j = Math.floor(Math.random() * (i + 1))
|
|
51
|
+
;[free[i], free[j]] = [free[j] ?? 0, free[i] ?? 0]
|
|
52
|
+
}
|
|
53
|
+
return free.slice(0, board.mineCount)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @brief Reveals one cell: the first reveal places the mines, a zero opens its
|
|
58
|
+
* neighbours, a mine ends the game, and the last safe cell wins it.
|
|
59
|
+
*
|
|
60
|
+
* @param board - Board to reveal on, see `Game`.
|
|
61
|
+
* @param cell - Cell number to reveal.
|
|
62
|
+
* @param now - Milliseconds since the epoch, stamped as the start or end time.
|
|
63
|
+
* @returns The board after the reveal, or the same board when the cell cannot be revealed.
|
|
64
|
+
*/
|
|
65
|
+
export function reveal(board: Game, cell: number, now: number): Game {
|
|
66
|
+
if (board.status === 'won' || board.status === 'lost') return board
|
|
67
|
+
if (board.revealed.includes(cell) || board.flagged.includes(cell)) return board
|
|
68
|
+
|
|
69
|
+
const mines = board.status === 'ready' ? placeMines(board, cell) : board.mines
|
|
70
|
+
const started: Game = board.status === 'ready' ? { ...board, mines, status: 'playing', startedAt: now } : board
|
|
71
|
+
if (mines.includes(cell)) return { ...started, revealed: [...started.revealed, cell], status: 'lost', endedAt: now }
|
|
72
|
+
|
|
73
|
+
const open = new Set(started.revealed)
|
|
74
|
+
const queue = [cell]
|
|
75
|
+
while (queue.length > 0) {
|
|
76
|
+
const next = queue.pop() ?? cell
|
|
77
|
+
if (open.has(next) || started.flagged.includes(next)) continue
|
|
78
|
+
open.add(next)
|
|
79
|
+
if (adjacentMines(started, next) === 0) queue.push(...neighbors(next, started.size).filter(n => !open.has(n)))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const revealed = [...open]
|
|
83
|
+
const isWon = revealed.length === started.size * started.size - started.mineCount
|
|
84
|
+
return isWon ? { ...started, revealed, status: 'won', endedAt: now } : { ...started, revealed }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Flags or unflags a hidden cell while the game is on.
|
|
89
|
+
*
|
|
90
|
+
* @param board - Board to flag on, see `Game`.
|
|
91
|
+
* @param cell - Cell number to flag.
|
|
92
|
+
* @returns The board after the flag, or the same board when the cell cannot be flagged.
|
|
93
|
+
*/
|
|
94
|
+
export function toggleFlag(board: Game, cell: number): Game {
|
|
95
|
+
if (board.revealed.includes(cell) || board.status === 'won' || board.status === 'lost') return board
|
|
96
|
+
const flagged = board.flagged.includes(cell) ? board.flagged.filter(c => c !== cell) : [...board.flagged, cell]
|
|
97
|
+
return { ...board, flagged }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Creates a board in the `ready` state, with no mines placed yet.
|
|
102
|
+
*
|
|
103
|
+
* @param now - Milliseconds since the epoch, stamped as the start time.
|
|
104
|
+
* @returns The new board, see `Game`.
|
|
105
|
+
*/
|
|
106
|
+
export function newGame(now: number): Game {
|
|
107
|
+
return { size: SIZE, mineCount: MINES, mines: [], revealed: [], flagged: [], status: 'ready', isFlagMode: false, startedAt: now }
|
|
108
|
+
}
|