martty 0.2.34 → 0.2.35
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/README.md +108 -11
- package/bin/martty.js +52 -5
- package/lib/acp-client.js +296 -20
- package/lib/acp-registry.snapshot.json +1450 -0
- package/lib/acp-session-config.js +23 -0
- package/lib/acp-session-plan.js +10 -1
- package/lib/acp-session-stats.js +11 -0
- package/lib/acp-session-status.js +70 -25
- package/lib/agent.js +24 -4
- package/lib/boot.js +29 -5
- package/lib/command-args.js +28 -0
- package/lib/download.js +165 -0
- package/lib/harness-discovery.js +41 -0
- package/lib/harness-package.js +200 -0
- package/lib/harness-registry.js +468 -0
- package/lib/harness-removal.js +88 -0
- package/lib/harness-view.js +639 -46
- package/lib/harnesses.js +1053 -41
- package/lib/index.js +4 -0
- package/lib/mux.js +23 -0
- package/lib/plan-view.js +18 -3
- package/lib/status-view.js +2 -0
- package/lib/tui-commands.js +7 -1
- package/lib/tui-overlay.js +27 -5
- package/package.json +7 -4
- package/vendor/darwin-arm64/martty +0 -0
- package/vendor/darwin-x64/martty +0 -0
- package/vendor/linux-arm64/martty +0 -0
- package/vendor/linux-x64/martty +0 -0
- package/vendor/win32-x64/martty.exe +0 -0
package/lib/harness-view.js
CHANGED
|
@@ -1,61 +1,654 @@
|
|
|
1
|
-
/** Built-in Client Plugin:
|
|
2
|
-
|
|
1
|
+
/** Built-in Client Plugin: discover, prepare, and switch standalone ACP Harnesses. */
|
|
3
2
|
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
selectedHarness,
|
|
7
|
-
upsertHarness,
|
|
3
|
+
addHarnessAsync, discoverHarnessCandidates, discoverHarnesses, fetchAcpRegistry,
|
|
4
|
+
setDefaultHarness, tokenizeHarnessArgs, upsertHarness, savedHarnesses,
|
|
8
5
|
} from './harnesses.js'
|
|
6
|
+
import { planHarnessRemoval, removeHarness } from './harness-removal.js'
|
|
7
|
+
import { scanHarnessCandidates } from './harness-discovery.js'
|
|
8
|
+
import { readAcpRegistrySnapshot } from './harness-registry.js'
|
|
9
|
+
import { stripVTControlCharacters } from 'node:util'
|
|
9
10
|
|
|
10
11
|
export const name = 'harness-view'
|
|
11
|
-
export const inject = ['tuiCommands', 'tuiOverlay']
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
12
|
+
export const inject = ['tuiCommands', 'tuiOverlay', 'acpClient', 'acpSessionStatus', 'tuiSlots']
|
|
13
|
+
|
|
14
|
+
const ADD = ':add'
|
|
15
|
+
const REFRESH = ':refresh'
|
|
16
|
+
const MANUAL = ':manual'
|
|
17
|
+
const BACK = ':back'
|
|
18
|
+
const DOWNLOAD = ':download:'
|
|
19
|
+
const addOption = { value: ADD, label: '+ Add Harness…', description: 'Browse the ACP Registry and local programs' }
|
|
20
|
+
const manualOption = { value: MANUAL, label: 'Manual configuration…', description: 'Use an ACP command not listed here' }
|
|
21
|
+
const commandText = (entry) => [entry.resolvedCommand ?? entry.command, ...(entry.args ?? [])]
|
|
22
|
+
.map((part) => /\s/.test(part) ? JSON.stringify(part) : part).join(' ')
|
|
23
|
+
const errorText = (error) => error instanceof Error ? error.message : String(error)
|
|
24
|
+
const recipeIdentity = (entry) => JSON.stringify([
|
|
25
|
+
entry.id, entry.resolvedCommand ?? entry.command, entry.args ?? [],
|
|
26
|
+
Object.entries(entry.env ?? {}).sort(([left], [right]) => left.localeCompare(right)),
|
|
27
|
+
...(entry.distribution?.type === 'binary'
|
|
28
|
+
? [entry.version, entry.distribution.target, entry.distribution.archive] : []),
|
|
29
|
+
])
|
|
30
|
+
const locallyAvailable = (entry) => ['Configured', 'Installed', 'Found locally'].includes(entry.status)
|
|
31
|
+
|| ['builtin', 'forced', 'path', 'managed'].includes(entry.source)
|
|
32
|
+
|
|
33
|
+
function findDescription(entry) {
|
|
34
|
+
if (entry.status === 'Checking locally') return 'checking local installation…'
|
|
35
|
+
if (entry.status === 'Available to install') return `install in Martty · ${entry.installPath ?? 'private bin directory'}`
|
|
36
|
+
if (entry.status?.startsWith('Available via ')) return `${entry.status.toLowerCase()} · first launch may download packages`
|
|
37
|
+
if (entry.status === 'Needs npx') return 'needs npx · install Node.js/npm'
|
|
38
|
+
if (entry.status === 'Needs uvx') return 'needs uvx · install uv'
|
|
39
|
+
if (entry.status === 'Not installed') return `not installed · install ${commandText(entry.install ?? {})}`
|
|
40
|
+
if (entry.status === 'Configured') return `configured · ${commandText(entry)}`
|
|
41
|
+
return `found locally · configure · ${commandText(entry)}`
|
|
21
42
|
}
|
|
22
43
|
|
|
23
44
|
export function apply(ctx, options = {}) {
|
|
24
45
|
const settingsPath = options.settingsPath
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
46
|
+
let registryPromise
|
|
47
|
+
let registrySnapshot = options.registry ?? readAcpRegistrySnapshot({ ...options, settingsPath })
|
|
48
|
+
let candidateSnapshot = []
|
|
49
|
+
let flow = 0
|
|
50
|
+
let switchVersion = 0
|
|
51
|
+
let disposed = false
|
|
52
|
+
let operation
|
|
53
|
+
let ownedOverlay
|
|
54
|
+
let pendingFailure
|
|
55
|
+
let commandRegistration
|
|
56
|
+
const downloads = new Map()
|
|
57
|
+
const configurationRevisions = new Map()
|
|
58
|
+
const removing = new Set()
|
|
59
|
+
let downloadSequence = 0
|
|
60
|
+
let downloadRevision = 0
|
|
61
|
+
let visibleDownload
|
|
62
|
+
let downloadNotice
|
|
63
|
+
const stopDownloadSlot = ctx.tuiSlots?.inject('conversation.input.dock', () => {
|
|
64
|
+
downloadNotice = ctx.tuiSlots.register({ name: 'conversation.input.dock', id: 'harness-downloads', order: -20 }, [])
|
|
65
|
+
return () => downloadNotice.dispose()
|
|
66
|
+
})
|
|
67
|
+
let runningRecipe = options.forcedHarness
|
|
68
|
+
let failedRecipe
|
|
69
|
+
const isRunning = (entry) => {
|
|
70
|
+
const child = ctx.acpClient?.child
|
|
71
|
+
if (child?.exitCode != null || child?.signalCode != null) return false
|
|
72
|
+
const live = ctx.acpClient?.command ? ctx.acpClient : runningRecipe
|
|
73
|
+
return live != null && recipeIdentity({ id: '', command: entry.resolvedCommand ?? entry.command, args: entry.args, env: entry.env }) === recipeIdentity({
|
|
74
|
+
id: '', command: live.command, args: live.args, env: live.env,
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
const isCurrent = (entry) => isRunning(entry) && recipeIdentity(entry) !== failedRecipe
|
|
78
|
+
const currentOption = (entry) => isCurrent(entry) ? { label: `${entry.label} (current)`, disabled: true } : {}
|
|
79
|
+
const currentFirst = (left, right) => Number(isCurrent(right)) - Number(isCurrent(left))
|
|
80
|
+
const choices = () => discoverHarnesses(settingsPath, options).sort(currentFirst).map((entry) => ({
|
|
81
|
+
value: entry.id, label: entry.label, description: `${entry.source} · ${commandText(entry)}`,
|
|
82
|
+
...currentOption(entry),
|
|
83
|
+
}))
|
|
84
|
+
const commandChoices = () => [...choices(), { ...addOption, value: 'add' }]
|
|
85
|
+
const refreshCommandChoices = () => commandRegistration?.update({ input: {
|
|
86
|
+
hint: '[id] | add | remove [id] | find [query]', options: commandChoices(),
|
|
87
|
+
} })
|
|
88
|
+
|
|
89
|
+
function openView(spec, handlers) {
|
|
90
|
+
if (disposed) return
|
|
91
|
+
ownedOverlay = ctx.tuiOverlay.openView(spec, handlers)
|
|
92
|
+
return ownedOverlay
|
|
93
|
+
}
|
|
94
|
+
function openSelect(spec, handlers) {
|
|
95
|
+
if (disposed) return
|
|
96
|
+
ownedOverlay = ctx.tuiOverlay.openSelect(spec, handlers)
|
|
97
|
+
return ownedOverlay
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function loadRegistry(refresh = false, signal) {
|
|
101
|
+
if (options.registry !== undefined) return options.registry
|
|
102
|
+
if (refresh) registryPromise = undefined
|
|
103
|
+
if (registryPromise === undefined) {
|
|
104
|
+
registryPromise = Promise.resolve().then(() => (options.fetchRegistry ?? fetchAcpRegistry)({ ...options, signal }))
|
|
105
|
+
}
|
|
106
|
+
const pending = registryPromise
|
|
107
|
+
try {
|
|
108
|
+
const registry = await pending
|
|
109
|
+
if (!disposed) registrySnapshot = registry
|
|
110
|
+
return registry
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (registryPromise === pending) registryPromise = undefined
|
|
113
|
+
throw error
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function manual(id = 'local') {
|
|
118
|
+
openView({ id: 'harness-manual', title: 'Manual ACP command · enter back', nodes: [{
|
|
119
|
+
id: 'instructions', kind: 'markdown',
|
|
120
|
+
text: `The command must start an ACP server on stdin/stdout.\n\n`
|
|
121
|
+
+ `\`/harness add ${id} --command <path> --arg <argument>\`\n\n`
|
|
122
|
+
+ 'Quote paths containing spaces. Repeat --arg for each argument. An ordinary agent CLI is not necessarily an ACP server.',
|
|
123
|
+
}] }, { onSubmit: () => browse() })
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function retryView(title, error, retry, query = '') {
|
|
127
|
+
if (disposed) return
|
|
128
|
+
if (ctx.tuiOverlay.active() !== null) {
|
|
129
|
+
pendingFailure = { title, error, retry, query }
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
const clean = (value) => stripVTControlCharacters(String(value))
|
|
133
|
+
.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, '').trim()
|
|
134
|
+
const raw = errorText(error)
|
|
135
|
+
const marker = '\nAgent stderr:\n'
|
|
136
|
+
const boundary = raw.indexOf(marker)
|
|
137
|
+
const message = clean(error?.acpError?.message ?? (boundary < 0 ? raw : raw.slice(0, boundary)))
|
|
138
|
+
const diagnostics = clean(error?.diagnostics ?? (boundary < 0 ? '' : raw.slice(boundary + marker.length)))
|
|
139
|
+
const code = error?.acpError?.code ?? error?.code
|
|
140
|
+
const context = [error?.method, code === undefined ? undefined : `Code: ${code}`].filter(Boolean).join(' · ')
|
|
141
|
+
const nodes = [
|
|
142
|
+
{ id: 'reason', kind: 'notice', level: 'error', text: message || 'The Harness could not be started.' },
|
|
143
|
+
...(context ? [{ id: 'context', kind: 'notice', level: 'info', text: clean(context) }] : []),
|
|
144
|
+
...(error?.acpError?.data === undefined ? [] : [{
|
|
145
|
+
id: 'error-data', kind: 'generic', title: 'Error data', body: clean(JSON.stringify(error.acpError.data, null, 2)),
|
|
146
|
+
}]),
|
|
147
|
+
...(diagnostics ? [{ id: 'stderr', kind: 'generic', title: 'Agent stderr', body: diagnostics }] : []),
|
|
148
|
+
{ id: 'actions', kind: 'notice', level: 'info', text: 'Enter retries this operation. Esc closes; use /harness to choose another Harness.' },
|
|
149
|
+
]
|
|
150
|
+
openView({ id: 'harness-setup-error', title: `${title} · enter retry`, nodes }, { onSubmit: retry })
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const removalOptions = () => ({ ...options, isCurrent: isRunning })
|
|
154
|
+
function removal(id, back = () => showHarnessPicker(id), selected = 'config') {
|
|
155
|
+
try {
|
|
156
|
+
if (!id) {
|
|
157
|
+
const entries = savedHarnesses(settingsPath).map(entry => {
|
|
158
|
+
try {
|
|
159
|
+
planHarnessRemoval(settingsPath, entry.id, removalOptions())
|
|
160
|
+
return { value: entry.id, label: entry.label, description: commandText(entry) }
|
|
161
|
+
} catch (error) {
|
|
162
|
+
return { value: entry.id, label: entry.label, description: errorText(error), disabled: true }
|
|
163
|
+
}
|
|
164
|
+
})
|
|
165
|
+
if (!entries.length) return openView({ id: 'harness-remove-empty', title: 'Remove Harness', nodes: [{
|
|
166
|
+
id: 'empty', kind: 'notice', level: 'info', text: 'No saved Harness configuration to remove.',
|
|
167
|
+
}] })
|
|
168
|
+
return openSelect({ id: 'harness-remove', title: 'Remove Harness · esc back',
|
|
169
|
+
value: entries.find(entry => entry.value === selected)?.value ?? entries[0].value, options: entries }, {
|
|
170
|
+
onCancel: back,
|
|
171
|
+
onSubmit: target => removal(target, () => removal(undefined, back, target)),
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
const plan = planHarnessRemoval(settingsPath, id, removalOptions())
|
|
175
|
+
openSelect({ id: 'harness-remove-mode', title: `Remove ${plan.entry.label} · esc back`, value: selected, options: [
|
|
176
|
+
{ value: 'config', label: 'Remove configuration only', description: 'Keep installed files, history and credentials' },
|
|
177
|
+
{ value: 'cleanup', label: 'Remove configuration and private installation', disabled: !!plan.cleanupReason,
|
|
178
|
+
description: plan.cleanupReason ?? plan.resources.join('\n') },
|
|
179
|
+
] }, { onCancel: back, onSubmit: mode => {
|
|
180
|
+
const cleanup = mode === 'cleanup'
|
|
181
|
+
if (mode !== 'config' && !cleanup) return
|
|
182
|
+
openView({ id: 'harness-remove-confirm', title: `Remove ${plan.entry.label}? · enter remove · esc back`, nodes: [
|
|
183
|
+
{ id: 'target', kind: 'notice', level: 'warn', text: `Remove ${plan.entry.label} (${id}) and clear its saved default reference.` },
|
|
184
|
+
{ id: 'settings', kind: 'generic', title: 'Configuration file', body: settingsPath },
|
|
185
|
+
{ id: 'resources', kind: 'generic', title: cleanup ? 'Permanently delete private installation' : 'Keep installed files',
|
|
186
|
+
body: cleanup ? plan.resources.join('\n') : 'No installed resources will be deleted.' },
|
|
187
|
+
{ id: 'preserved', kind: 'notice', level: 'info', text: 'History, credentials and shared caches are kept. Any active download for this Harness will be cancelled first. Esc returns to removal options without deleting anything.' },
|
|
188
|
+
] }, { onCancel: () => removal(id, back, mode), onSubmit: () => executeRemoval(plan, cleanup) })
|
|
189
|
+
} })
|
|
190
|
+
} catch (error) { retryView('Could not remove Harness', error, () => removal(id, back, selected)) }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function executeRemoval(plan, cleanup) {
|
|
194
|
+
const id = plan.entry.id
|
|
195
|
+
if (removing.has(id)) return
|
|
196
|
+
removing.add(id)
|
|
197
|
+
try {
|
|
198
|
+
// Recheck before cancellation, and again inside removeHarness after workers settle.
|
|
199
|
+
planHarnessRemoval(settingsPath, id, removalOptions())
|
|
200
|
+
configurationRevisions.set(id, (configurationRevisions.get(id) ?? 0) + 1)
|
|
201
|
+
const jobs = [...downloads.values()].filter(job => job.entry.id === id)
|
|
202
|
+
for (const job of jobs) job.controller.abort()
|
|
203
|
+
openView({ id: 'harness-removing', title: `Removing ${plan.entry.label}`, nodes: [{
|
|
204
|
+
id: 'working', kind: 'notice', level: 'info', text: 'Waiting for pending installation work to stop, then removing the confirmed configuration.',
|
|
205
|
+
}] })
|
|
206
|
+
await Promise.allSettled(jobs.map(job => job.task))
|
|
207
|
+
if (disposed) return
|
|
208
|
+
for (const job of jobs) downloads.delete(job.key)
|
|
209
|
+
notifyDownloads()
|
|
210
|
+
const result = removeHarness(settingsPath, plan, { ...removalOptions(), cleanup })
|
|
211
|
+
candidateSnapshot = candidateSnapshot.filter(entry => entry.id !== id)
|
|
212
|
+
pendingFailure = undefined
|
|
213
|
+
refreshCommandChoices(); notifyDownloads()
|
|
214
|
+
const active = ctx.tuiOverlay.active()
|
|
215
|
+
if (active?.id === 'harness-removing') ownedOverlay?.close()
|
|
216
|
+
const text = `${plan.entry.label} configuration removed. ` + (result.removed.length
|
|
217
|
+
? `Deleted private installation: ${result.removed.join(', ')}.` : 'Installed files were kept.')
|
|
218
|
+
+ ' History and credentials were kept.'
|
|
219
|
+
// Do not replace a different panel opened while cancellation was settling.
|
|
220
|
+
if (ctx.tuiOverlay.active() === null) openView({ id: 'harness-removed', title: 'Harness removed', nodes: [{
|
|
221
|
+
id: 'removed', kind: 'notice', level: 'info', text,
|
|
222
|
+
}] })
|
|
223
|
+
} catch (error) {
|
|
224
|
+
if (ctx.tuiOverlay.active()?.id === 'harness-removing') ownedOverlay?.close()
|
|
225
|
+
retryView('Could not remove Harness', error, () => removal(id))
|
|
226
|
+
} finally { removing.delete(id) }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function switchNow(entry, download) {
|
|
230
|
+
if (disposed) return
|
|
231
|
+
if (removing.has(entry.id)) throw new Error('This Harness is being removed; wait until removal finishes')
|
|
232
|
+
const version = ++switchVersion
|
|
233
|
+
failedRecipe = undefined
|
|
234
|
+
pendingFailure = undefined
|
|
235
|
+
try {
|
|
236
|
+
if (typeof ctx.acpClient?.switchAgent !== 'function') throw new Error('Harness switching is unavailable on this ACP transport')
|
|
237
|
+
// Connect registers the prepared recipe, independently of sign-in/session setup.
|
|
238
|
+
// A failed or cancelled login must not make an explicitly added Harness disappear.
|
|
239
|
+
upsertHarness(settingsPath, entry)
|
|
240
|
+
refreshCommandChoices()
|
|
241
|
+
const handoff = await ctx.acpClient.switchAgent({ command: entry.command, args: entry.args,
|
|
242
|
+
...(entry.env !== undefined ? { env: entry.env } : {}) })
|
|
243
|
+
if (disposed || version !== switchVersion) {
|
|
244
|
+
void handoff?.ready?.catch(() => {})
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
// The process is current already; readiness controls persistence, not
|
|
248
|
+
// which running recipe the picker/composer identifies.
|
|
249
|
+
refreshCommandChoices()
|
|
250
|
+
const commit = () => {
|
|
251
|
+
if (disposed || version !== switchVersion) return
|
|
252
|
+
setDefaultHarness(settingsPath, entry.id)
|
|
253
|
+
runningRecipe = entry
|
|
254
|
+
refreshCommandChoices()
|
|
255
|
+
if (download !== undefined && downloads.get(download.key) === download) {
|
|
256
|
+
downloads.delete(download.key)
|
|
257
|
+
notifyDownloads()
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
// The action lets the painter initialize/session/new; awaiting ready here deadlocks it.
|
|
261
|
+
if (handoff?.ready !== undefined) {
|
|
262
|
+
void handoff.ready.then(commit).catch((error) => {
|
|
263
|
+
if (!disposed && version === switchVersion) {
|
|
264
|
+
failedRecipe = recipeIdentity(entry)
|
|
265
|
+
refreshCommandChoices()
|
|
266
|
+
retryView(`Could not connect ${entry.label}`, error, () => switchNow(entry, download))
|
|
267
|
+
}
|
|
268
|
+
})
|
|
269
|
+
} else commit() // Older stream integrations keep their existing contract.
|
|
270
|
+
return { action: 'harness-switched', harness: {
|
|
271
|
+
id: entry.id, label: entry.label, command: entry.command, args: entry.args,
|
|
272
|
+
...(entry.env !== undefined ? { env: entry.env } : {}),
|
|
273
|
+
} }
|
|
274
|
+
} catch (error) { retryView(`Could not connect ${entry.label}`, error, () => switchNow(entry, download)) }
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function save(id, preparedEntry, download) {
|
|
278
|
+
if (disposed) return
|
|
279
|
+
const entry = preparedEntry ?? discoverHarnesses(settingsPath, options).find((candidate) => candidate.id === id)
|
|
28
280
|
if (entry === undefined) throw new Error(`unknown harness ${JSON.stringify(id)}`)
|
|
281
|
+
if (!options.hostOwned && isCurrent(entry)) return
|
|
282
|
+
if (options.hostOwned) {
|
|
283
|
+
upsertHarness(settingsPath, entry)
|
|
284
|
+
setDefaultHarness(settingsPath, id)
|
|
285
|
+
refreshCommandChoices()
|
|
286
|
+
if (download !== undefined && downloads.get(download.key) === download) {
|
|
287
|
+
downloads.delete(download.key)
|
|
288
|
+
notifyDownloads()
|
|
289
|
+
}
|
|
290
|
+
openView({ id: 'harness-saved', title: 'Harness saved', nodes: [{
|
|
291
|
+
id: 'notice', kind: 'notice', level: 'info',
|
|
292
|
+
text: `${entry.label} is saved for a new standalone session. The current dsh profile and session remain Host-owned.`,
|
|
293
|
+
}] })
|
|
294
|
+
return
|
|
295
|
+
}
|
|
296
|
+
// Selecting a registered recipe is a switch, not Add/Install. The saved
|
|
297
|
+
// launcher owns its cache; a per-process preparation flag is not evidence
|
|
298
|
+
// that the package needs downloading again.
|
|
299
|
+
if (ctx.acpSessionStatus?.current?.().session?.started === true) {
|
|
300
|
+
openSelect({ id: 'harness-confirm', title: 'Switch Harness? · starts a new session', value: 'switch', options: [
|
|
301
|
+
{ value: 'switch', label: `Switch to ${entry.label}`, description: 'Current session stays available in /session' },
|
|
302
|
+
{ value: 'cancel', label: 'Stay in current session', description: 'Keep using the current Harness' },
|
|
303
|
+
] }, { onSubmit: (action) => action === 'switch' ? switchNow(entry, download) : undefined })
|
|
304
|
+
return
|
|
305
|
+
}
|
|
306
|
+
return switchNow(entry, download)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function configured(entry) {
|
|
310
|
+
if (removing.has(entry.id)) return
|
|
311
|
+
configurationRevisions.set(entry.id, (configurationRevisions.get(entry.id) ?? 0) + 1)
|
|
29
312
|
upsertHarness(settingsPath, entry)
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
id: '
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
313
|
+
refreshCommandChoices()
|
|
314
|
+
openView({ id: 'harness-saved', title: 'Harness configured', nodes: [{
|
|
315
|
+
id: 'notice', kind: 'notice', level: 'info',
|
|
316
|
+
text: `${entry.label} is configured. Your current Harness is unchanged. Use /harness when you want to switch.`,
|
|
317
|
+
}] })
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function configure(id, tokens, scoped, query = '', displayedEntry) {
|
|
321
|
+
if (disposed) return
|
|
322
|
+
if (removing.has(id)) return
|
|
323
|
+
const revision = configurationRevisions.get(id)
|
|
324
|
+
try {
|
|
325
|
+
if (!tokens.includes('--command')) {
|
|
326
|
+
const candidate = displayedEntry ?? discoverHarnessCandidates(settingsPath, scoped).find((entry) => entry.id === id)
|
|
327
|
+
if (candidate?.status?.startsWith('Available via ')) return install(candidate, scoped, query)
|
|
328
|
+
}
|
|
329
|
+
const entry = await addHarnessAsync(settingsPath, id, tokens, { ...scoped, persist: false })
|
|
330
|
+
if (disposed || removing.has(id) || configurationRevisions.get(id) !== revision) return
|
|
331
|
+
const runner = /^(npx|uvx)(?:\.(?:cmd|exe|bat))?$/i.exec(entry.command.split(/[\\/]/).at(-1))?.[1]?.toLowerCase()
|
|
332
|
+
if (runner && entry.args?.[0] && !entry.args[0].startsWith('-')) {
|
|
333
|
+
return install({ ...entry, distribution: { type: runner, command: entry.command, args: entry.args } }, scoped, query, entry)
|
|
334
|
+
}
|
|
335
|
+
return configured(entry)
|
|
336
|
+
} catch (error) { retryView('Could not configure Harness', error, () => configure(id, tokens, scoped, query), query) }
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function downloadOptions() {
|
|
340
|
+
return [...downloads.values()].map((job) => ({
|
|
341
|
+
value: `${DOWNLOAD}${job.key}`,
|
|
342
|
+
label: `${job.state === 'ready' ? 'Downloaded' : job.state === 'failed' ? 'Download failed' : 'Downloading'} · ${job.entry.label}`,
|
|
343
|
+
description: `${job.state === 'ready' ? 'View completed setup' : job.state === 'failed' ? 'View error and retry' : 'View download progress'} · ${commandText(job.entry)}`,
|
|
344
|
+
}))
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function matchingDownload(entry) {
|
|
348
|
+
const identity = recipeIdentity(entry)
|
|
349
|
+
return [...downloads.values()].find((job) => job.identity === identity)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function notifyDownloads() {
|
|
353
|
+
if (disposed) return
|
|
354
|
+
const jobs = [...downloads.values()].filter((job) => !job.acknowledged)
|
|
355
|
+
.sort((left, right) => left.changed - right.changed)
|
|
356
|
+
const latest = jobs.at(-1)
|
|
357
|
+
downloadNotice?.update(latest === undefined ? [] : [{
|
|
358
|
+
id: 'summary', kind: 'generic', body: '',
|
|
359
|
+
title: `${latest.state === 'ready' ? 'Download complete' : latest.state === 'failed' ? 'Download failed' : 'Downloading'} · ${latest.entry.label}`
|
|
360
|
+
+ `${jobs.length > 1 ? ` · ${jobs.length} downloads` : ''} · /harness to view`,
|
|
361
|
+
status: latest.state === 'running' ? 'running' : latest.state === 'ready' ? 'done' : 'err',
|
|
362
|
+
tone: latest.state === 'failed' ? 'err' : latest.state === 'ready' ? 'ok' : 'brand',
|
|
363
|
+
}])
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function showDownload(job) {
|
|
367
|
+
if (disposed) return
|
|
368
|
+
visibleDownload = job
|
|
369
|
+
const hide = () => { visibleDownload = undefined; notifyDownloads() }
|
|
370
|
+
if (job.state === 'ready') {
|
|
371
|
+
const acknowledge = () => {
|
|
372
|
+
downloads.delete(job.key)
|
|
373
|
+
hide()
|
|
374
|
+
}
|
|
375
|
+
openView({ id: 'harness-installing', title: `Download complete · ${job.entry.label} · enter switch · esc close`, nodes: [
|
|
376
|
+
{ id: 'complete', kind: 'notice', level: 'info', text: job.registered
|
|
377
|
+
? `${job.entry.label} is installed and configured.`
|
|
378
|
+
: `${job.entry.label} is installed. Your newer configuration is unchanged.` },
|
|
379
|
+
{ id: 'next', kind: 'text', text: 'Setup is complete. Enter switches to this Harness using its saved configuration. Esc closes without switching.' },
|
|
380
|
+
] }, { onSubmit: () => {
|
|
381
|
+
acknowledge()
|
|
382
|
+
return save(job.entry.id)
|
|
383
|
+
}, onCancel: acknowledge })
|
|
384
|
+
return
|
|
385
|
+
}
|
|
386
|
+
if (job.state === 'failed') {
|
|
387
|
+
openView({ id: 'harness-installing', title: `Download failed · ${job.entry.label} · enter retry · esc close`, nodes: [
|
|
388
|
+
{ id: 'error', kind: 'notice', level: 'error', text: errorText(job.error) },
|
|
389
|
+
{ id: 'next', kind: 'text', text: 'Enter retries the download. The current Harness and saved default are unchanged.' },
|
|
390
|
+
] }, { onSubmit: () => {
|
|
391
|
+
hide()
|
|
392
|
+
job.acknowledged = true
|
|
393
|
+
notifyDownloads()
|
|
394
|
+
downloads.delete(job.key)
|
|
395
|
+
return install(job.entry, job.scoped, job.query, job.preparedEntry)
|
|
396
|
+
}, onCancel: () => { job.acknowledged = true; hide() } })
|
|
397
|
+
return
|
|
398
|
+
}
|
|
399
|
+
const { phase, receivedBytes, totalBytes, detail } = job.progress
|
|
400
|
+
const label = ({ download: 'Downloading', extract: 'Extracting', verify: 'Verifying', complete: 'Finishing installation' })[phase] ?? 'Preparing'
|
|
401
|
+
const size = receivedBytes === undefined ? '' : ` · ${(receivedBytes / 1048576).toFixed(1)} MB`
|
|
402
|
+
+ (totalBytes ? ` / ${(totalBytes / 1048576).toFixed(1)} MB` : '')
|
|
403
|
+
openView({ id: 'harness-installing', title: `${label} ${job.entry.label} · esc run in background`, nodes: [
|
|
404
|
+
{ id: 'progress', kind: 'notice', level: 'info', text: `${label}${size}` },
|
|
405
|
+
{ id: 'location', kind: 'text', text: job.entry.installPath ?? `${job.entry.distribution.type} package cache` },
|
|
406
|
+
...(detail ? [{ id: 'detail', kind: 'text', text: detail }] : []),
|
|
407
|
+
{ id: 'hint', kind: 'text', text: 'Keep this panel open until the download finishes. Esc hides it; downloading continues while Martty is open.' },
|
|
408
|
+
] }, { onCancel: hide, onSubmit: () => showDownload(job) })
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// The job belongs to the plugin, not its modal. Return immediately so a long
|
|
412
|
+
// download cannot block the painter's ACP command queue or the old session.
|
|
413
|
+
function install(entry, scoped, query, preparedEntry) {
|
|
414
|
+
if (removing.has(entry.id)) return
|
|
415
|
+
if (disposed) return
|
|
416
|
+
const existing = matchingDownload(entry)
|
|
417
|
+
if (existing !== undefined) return showDownload(existing)
|
|
418
|
+
const key = downloads.has(entry.id) ? `${entry.id}:${++downloadSequence}` : entry.id
|
|
419
|
+
const job = { key, identity: recipeIdentity(entry), changed: ++downloadRevision,
|
|
420
|
+
entry, scoped, query, preparedEntry, controller: new AbortController(), state: 'running', progress: { phase: 'download' } }
|
|
421
|
+
const revision = (configurationRevisions.get(entry.id) ?? 0) + 1
|
|
422
|
+
configurationRevisions.set(entry.id, revision)
|
|
423
|
+
downloads.set(key, job)
|
|
424
|
+
const current = () => !disposed && !job.controller.signal.aborted && downloads.get(key) === job
|
|
425
|
+
const ownsPanel = () => visibleDownload === job && ctx.tuiOverlay.active()?.id === 'harness-installing'
|
|
426
|
+
const progress = (snapshot) => {
|
|
427
|
+
if (!current()) return
|
|
428
|
+
const now = Date.now()
|
|
429
|
+
const redraw = snapshot.phase !== job.progress.phase || snapshot.detail !== job.progress.detail || now - (job.lastUpdate ?? 0) >= 100
|
|
430
|
+
job.progress = snapshot
|
|
431
|
+
if (redraw && ownsPanel()) { job.lastUpdate = now; showDownload(job) }
|
|
432
|
+
}
|
|
433
|
+
const finish = () => {
|
|
434
|
+
if (!current()) return
|
|
435
|
+
const foreground = ownsPanel()
|
|
436
|
+
job.changed = ++downloadRevision
|
|
437
|
+
notifyDownloads()
|
|
438
|
+
if (foreground) showDownload(job)
|
|
439
|
+
}
|
|
440
|
+
showDownload(job)
|
|
441
|
+
notifyDownloads()
|
|
442
|
+
job.task = (async () => {
|
|
443
|
+
const preparation = { ...scoped, settingsPath, persist: false, signal: job.controller.signal, onProgress: progress }
|
|
444
|
+
if (entry.distribution.type === 'npx' || entry.distribution.type === 'uvx') {
|
|
445
|
+
const prepare = options.preparePackage ?? (await import('./harness-package.js')).prepareHarnessPackage
|
|
446
|
+
await prepare(entry, preparation)
|
|
447
|
+
}
|
|
448
|
+
if (!current()) return
|
|
449
|
+
job.configured = preparedEntry ?? await addHarnessAsync(settingsPath, entry.id, [], preparation)
|
|
450
|
+
if (!current()) return
|
|
451
|
+
if (configurationRevisions.get(entry.id) === revision) {
|
|
452
|
+
upsertHarness(settingsPath, job.configured)
|
|
453
|
+
refreshCommandChoices()
|
|
454
|
+
job.registered = true
|
|
455
|
+
}
|
|
456
|
+
job.state = 'ready'
|
|
457
|
+
finish()
|
|
458
|
+
})().catch((error) => {
|
|
459
|
+
if (!current()) return
|
|
460
|
+
job.state = 'failed'
|
|
461
|
+
job.error = error
|
|
462
|
+
finish()
|
|
42
463
|
})
|
|
43
464
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
465
|
+
|
|
466
|
+
function choose(entry, scoped, query) {
|
|
467
|
+
if (removing.has(entry.id)) return
|
|
468
|
+
if (entry.status === 'Configured') return save(entry.id)
|
|
469
|
+
const downloading = matchingDownload(entry)
|
|
470
|
+
if (downloading !== undefined) return showDownload(downloading)
|
|
471
|
+
if (entry.status === 'Available to install') {
|
|
472
|
+
openSelect({ id: 'harness-install-confirm', title: `Install ${entry.label} in Martty?`, value: 'install', options: [
|
|
473
|
+
{ value: 'install', label: `Install ${entry.label}`, description: `download to ${entry.installPath ?? "Martty's private bin directory"}` },
|
|
474
|
+
{ value: 'cancel', label: 'Back', description: 'Choose another Harness' },
|
|
475
|
+
] }, { onSubmit: (action) => action === 'install' ? install(entry, scoped, query) : browse(query) })
|
|
476
|
+
return
|
|
477
|
+
}
|
|
478
|
+
if (entry.status === 'Needs npx' || entry.status === 'Needs uvx') {
|
|
479
|
+
const runner = entry.distribution.type
|
|
480
|
+
const runtime = runner === 'npx' ? 'Node.js/npm' : 'uv'
|
|
481
|
+
const url = runner === 'npx' ? 'https://nodejs.org/en/download' : 'https://docs.astral.sh/uv/getting-started/installation/'
|
|
482
|
+
openSelect({ id: 'harness-runner-missing', title: `${entry.label} needs ${runtime}`, value: ':recheck', options: [
|
|
483
|
+
{ value: ':recheck', label: 'Recheck installation', description: `Install ${runtime}: ${url}` },
|
|
484
|
+
{ value: MANUAL, label: 'Use an existing ACP command…', description: 'The program may be installed outside PATH' },
|
|
485
|
+
{ value: BACK, label: 'Back to Harnesses', description: 'Choose another distribution' },
|
|
486
|
+
] }, { onSubmit: (action) => action === MANUAL ? manual(entry.id) : browse(query, action === ':recheck') })
|
|
487
|
+
return
|
|
488
|
+
}
|
|
489
|
+
if (entry.status?.startsWith('Available via ')) {
|
|
490
|
+
openSelect({ id: 'harness-package-confirm', title: `Download ${entry.label}?`, value: 'configure', options: [
|
|
491
|
+
{ value: 'configure', label: 'Download', description: `Prepare packages with ${entry.distribution.type}, then connect when ready` },
|
|
492
|
+
{ value: BACK, label: 'Back', description: 'Choose another Harness' },
|
|
493
|
+
] }, { onSubmit: (action) => action === 'configure' ? install(entry, scoped, query) : browse(query) })
|
|
494
|
+
return
|
|
495
|
+
}
|
|
496
|
+
if (entry.status === 'Not installed') {
|
|
497
|
+
const instruction = entry.fallback === undefined
|
|
498
|
+
? `Install: \`${commandText(entry.install ?? {})}\`\nThen return here and press Enter to check again.`
|
|
499
|
+
: `Configure with /harness add ${entry.id}; this saves the registry fallback (\`${commandText(entry.install ?? {})}\`) as the launch command.`
|
|
500
|
+
openView({ id: 'harness-find-install', title: `${entry.label} is not installed`, nodes: [{
|
|
501
|
+
id: 'instructions', kind: 'markdown', text: instruction,
|
|
502
|
+
}] }, { onSubmit: () => browse(query, true) })
|
|
503
|
+
return
|
|
504
|
+
}
|
|
505
|
+
// A local path is the recipe the user selected. Do not silently turn a
|
|
506
|
+
// vanished executable into an unconfirmed download on a second scan.
|
|
507
|
+
return configured({ ...entry, command: entry.resolvedCommand ?? entry.command })
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function browse(query = '', refresh = false) {
|
|
511
|
+
if (disposed) return
|
|
512
|
+
operation?.abort()
|
|
513
|
+
const version = ++flow
|
|
514
|
+
const controller = new AbortController()
|
|
515
|
+
operation = controller
|
|
516
|
+
let registryLoaded = false
|
|
517
|
+
const cancel = () => {
|
|
518
|
+
++flow; controller.abort(); operation = undefined
|
|
519
|
+
if (!registryLoaded) registryPromise = undefined
|
|
520
|
+
}
|
|
521
|
+
const current = () => !disposed && version === flow
|
|
522
|
+
const scan = options.scanCandidates ?? scanHarnessCandidates
|
|
523
|
+
const scanOptions = { ...options, settingsPath, signal: controller.signal }
|
|
524
|
+
let complete = false
|
|
525
|
+
let registryFailure
|
|
526
|
+
function show(candidates, registry, pending = false, failure) {
|
|
527
|
+
if (!current()) return
|
|
528
|
+
const entries = [...candidates].sort((left, right) => currentFirst(left, right)
|
|
529
|
+
|| Number(locallyAvailable(right)) - Number(locallyAvailable(left)))
|
|
530
|
+
const scoped = { ...options, registry }
|
|
531
|
+
openSelect({ id: 'harness-find', title: failure ? 'Add Harness · Registry offline' : pending ? 'Add Harness · checking Registry…' : 'Add Harness', searchable: true,
|
|
532
|
+
value: entries[0]?.id ?? REFRESH,
|
|
533
|
+
options: [
|
|
534
|
+
...entries.map((entry) => ({ value: entry.id, label: entry.label, description: findDescription(entry),
|
|
535
|
+
...currentOption(entry),
|
|
536
|
+
group: locallyAvailable(entry) ? 'Installed / configured' : entry.status === 'Checking locally' ? 'Catalog' : 'Not downloaded' })),
|
|
537
|
+
{ value: REFRESH, label: failure ? 'Retry Registry' : 'Refresh Registry', description: failure ? errorText(failure) : 'Recheck the catalog and local programs' },
|
|
538
|
+
manualOption,
|
|
539
|
+
],
|
|
540
|
+
}, { onCancel: cancel, onSubmit: (id) => {
|
|
541
|
+
cancel()
|
|
542
|
+
if (id === REFRESH) return browse(query, true)
|
|
543
|
+
if (id === MANUAL) return manual()
|
|
544
|
+
// Selection acts on the rendered snapshot. Re-discovery here used to scan
|
|
545
|
+
// every local environment again before even showing the next panel.
|
|
546
|
+
const entry = entries.find((candidate) => candidate.id === id)
|
|
547
|
+
if (entry !== undefined) {
|
|
548
|
+
const selectedOptions = { ...scoped, registry: registry.filter((record) => record.id === id) }
|
|
549
|
+
if (entry.status !== 'Checking locally') return choose(entry, selectedOptions, query)
|
|
550
|
+
const selectionVersion = flow
|
|
551
|
+
return scan(settingsPath, selectedOptions, query).then((entries) => {
|
|
552
|
+
if (disposed || flow !== selectionVersion) return
|
|
553
|
+
const resolved = entries.find((candidate) => candidate.id === id)
|
|
554
|
+
if (resolved !== undefined) return choose(resolved, selectedOptions, query)
|
|
555
|
+
}).catch((error) => retryView('Could not check Harness', error, () => browse(query)))
|
|
556
|
+
}
|
|
557
|
+
} })
|
|
558
|
+
}
|
|
559
|
+
// The local worker and network request start independently. A slow catalog
|
|
560
|
+
// never prevents choosing a saved/local command or leaving the panel.
|
|
561
|
+
const saved = discoverHarnesses(settingsPath, { ...options, registry: [], pathValue: '' })
|
|
562
|
+
.map((entry) => ({ ...entry, status: 'Configured' }))
|
|
563
|
+
const known = new Map([...candidateSnapshot, ...saved].map((entry) => [entry.id, entry]))
|
|
564
|
+
const preview = [...known.values(), ...registrySnapshot.filter((record) => !known.has(record.id))
|
|
565
|
+
.map((record) => ({ id: record.id, label: record.label, status: 'Checking locally', source: 'registry' }))]
|
|
566
|
+
.filter((entry) => !query || `${entry.id} ${entry.label}`.toLowerCase().includes(query.toLowerCase()))
|
|
567
|
+
let displayed = preview
|
|
568
|
+
show(displayed, registrySnapshot, true)
|
|
569
|
+
const local = scan(settingsPath, { ...scanOptions, registry: registrySnapshot }, query)
|
|
570
|
+
void local.then((entries) => {
|
|
571
|
+
if ((!complete || registryFailure !== undefined) && current()) {
|
|
572
|
+
candidateSnapshot = entries
|
|
573
|
+
displayed = entries
|
|
574
|
+
show(entries, registrySnapshot, registryFailure === undefined, registryFailure)
|
|
575
|
+
}
|
|
576
|
+
}).catch(() => {})
|
|
577
|
+
void (async () => { try {
|
|
578
|
+
const registry = await loadRegistry(refresh, controller.signal)
|
|
579
|
+
registryLoaded = true
|
|
580
|
+
if (!current()) return
|
|
581
|
+
const entries = await scan(settingsPath, { ...scanOptions, registry }, query)
|
|
582
|
+
if (!current()) return
|
|
583
|
+
complete = true
|
|
584
|
+
candidateSnapshot = entries
|
|
585
|
+
show(entries, registry)
|
|
586
|
+
} catch (error) {
|
|
587
|
+
if (!current()) return
|
|
588
|
+
registryFailure = error
|
|
589
|
+
complete = true
|
|
590
|
+
show(displayed, registrySnapshot, false, error)
|
|
591
|
+
} })()
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
commandRegistration = ctx.tuiCommands.register({ name: 'harness', description: 'Switch or add a Harness; switching starts a new session',
|
|
595
|
+
input: { hint: '[id] | add | remove [id] | find [query]', options: commandChoices() },
|
|
48
596
|
}, async (args) => {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
597
|
+
if (disposed) return
|
|
598
|
+
refreshCommandChoices()
|
|
599
|
+
if (args.trim() === '' && pendingFailure !== undefined) {
|
|
600
|
+
const { title, error, retry, query } = pendingFailure
|
|
601
|
+
pendingFailure = undefined
|
|
602
|
+
return retryView(title, error, retry, query)
|
|
603
|
+
}
|
|
604
|
+
let tokens
|
|
605
|
+
try { tokens = tokenizeHarnessArgs(args.trim()) } catch (error) {
|
|
606
|
+
retryView('Could not read Harness command', error, () => manual())
|
|
607
|
+
return
|
|
608
|
+
}
|
|
609
|
+
if (tokens[0] === 'remove') return removal(tokens[1])
|
|
610
|
+
if (tokens[0] === 'find' || (tokens[0] === 'add' && tokens[1] === undefined)) return browse(tokens.slice(1).join(' '))
|
|
611
|
+
if (tokens[0] === 'add') {
|
|
612
|
+
try {
|
|
613
|
+
if (tokens.slice(2).includes('--command')) return configure(tokens[1], tokens.slice(2), options)
|
|
614
|
+
const scoped = { ...options, registry: (await loadRegistry()).filter((record) => record.id === tokens[1]) }
|
|
615
|
+
const entry = discoverHarnessCandidates(settingsPath, scoped).find((candidate) => candidate.id === tokens[1])
|
|
616
|
+
if (!tokens.slice(2).includes('--command') && (entry?.status === 'Available to install' || entry?.status?.startsWith('Available via '))) return choose(entry, scoped, '')
|
|
617
|
+
return configure(tokens[1], tokens.slice(2), scoped)
|
|
618
|
+
} catch (error) { retryView('Could not configure Harness', error, () => browse()) }
|
|
619
|
+
return
|
|
620
|
+
}
|
|
621
|
+
if (tokens[0] !== undefined) {
|
|
622
|
+
const saved = discoverHarnesses(settingsPath, options).find((entry) => entry.id === tokens[0])
|
|
623
|
+
if (saved !== undefined) return save(saved.id, saved)
|
|
624
|
+
const job = [...downloads.values()].filter((job) => job.entry.id === tokens[0]).at(-1)
|
|
625
|
+
return job === undefined ? save(tokens[0]) : showDownload(job)
|
|
626
|
+
}
|
|
627
|
+
showHarnessPicker()
|
|
59
628
|
})
|
|
60
|
-
|
|
629
|
+
|
|
630
|
+
function showHarnessPicker(selected) {
|
|
631
|
+
const entries = choices()
|
|
632
|
+
const saved = savedHarnesses(settingsPath)
|
|
633
|
+
const forced = [options.forcedHarness, ...(options.defaults ?? []).filter(entry => entry.source === 'forced')].filter(Boolean)
|
|
634
|
+
// Eligibility does not walk installation trees; inspect resource ownership only on Delete.
|
|
635
|
+
for (const option of entries) {
|
|
636
|
+
const entry = saved.find(entry => entry.id === option.value)
|
|
637
|
+
if (entry && !isRunning(entry) && !forced.some(value => value.id === entry.id || value.command === entry.command)) {
|
|
638
|
+
option.deletable = true
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
const downloaded = downloadOptions()
|
|
642
|
+
if (entries.length === 0 && downloaded.length === 0) return browse()
|
|
643
|
+
openSelect({ id: 'harness', title: 'Switch Harness · starts a new session',
|
|
644
|
+
value: (entries.find(entry => entry.value === selected) ?? entries.find((entry) => entry.disabled) ?? entries[0] ?? downloaded[0]).value,
|
|
645
|
+
options: [...entries, ...downloaded, addOption],
|
|
646
|
+
}, { onDelete: id => removal(id),
|
|
647
|
+
onSubmit: (id) => id === ADD ? browse() : id.startsWith(DOWNLOAD) ? showDownload(downloads.get(id.slice(DOWNLOAD.length))) : save(id) })
|
|
648
|
+
}
|
|
649
|
+
return () => {
|
|
650
|
+
disposed = true; ++flow; ++switchVersion; operation?.abort()
|
|
651
|
+
for (const job of downloads.values()) job.controller.abort()
|
|
652
|
+
ownedOverlay?.close(); stopDownloadSlot?.(); commandRegistration?.()
|
|
653
|
+
}
|
|
61
654
|
}
|