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.
@@ -1,61 +1,654 @@
1
- /** Built-in Client Plugin: configure the next standalone ACP harness. */
2
-
1
+ /** Built-in Client Plugin: discover, prepare, and switch standalone ACP Harnesses. */
3
2
  import {
4
- activateHarness,
5
- discoverHarnesses,
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
- function entryOptions(settingsPath, options) {
14
- return discoverHarnesses(settingsPath, options).map((entry) => ({
15
- value: entry.id,
16
- label: entry.label,
17
- description: `${entry.source} · ${[entry.command, ...entry.args]
18
- .map((part) => /\s/.test(part) ? JSON.stringify(part) : part)
19
- .join(' ')}`,
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
- const choices = () => entryOptions(settingsPath, options)
26
- const save = (id) => {
27
- const entry = discoverHarnesses(settingsPath, options).find(({ id: candidate }) => candidate === id)
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
- activateHarness(settingsPath, id)
31
- ctx.tuiOverlay.openView({
32
- id: 'harness-saved',
33
- title: 'Harness saved',
34
- nodes: [{
35
- id: 'notice',
36
- kind: 'notice',
37
- level: 'info',
38
- text: options.hostOwned
39
- ? `${entry.label} is saved for a new standalone session. The current dsh profile and session remain Host-owned.`
40
- : `${entry.label} will start in a new session after restarting standalone Martty.`,
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
- const command = ctx.tuiCommands.register({
45
- name: 'harness',
46
- description: 'Switch harness in a new session on the next standalone launch',
47
- input: { hint: '[id]', options: choices() },
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
- const requested = args.trim()
50
- if (requested.length > 0) return save(requested)
51
- const entries = choices()
52
- const selected = selectedHarness(settingsPath)?.id
53
- ctx.tuiOverlay.openSelect({
54
- id: 'harness',
55
- title: 'Harness · next standalone session',
56
- value: entries.some(({ value }) => value === selected) ? selected : entries[0].value,
57
- options: entries,
58
- }, { onSubmit: save })
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
- return () => command?.()
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
  }