dsh-multi-folder 0.3.1 → 0.3.3
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 +24 -5
- package/README.zh.md +24 -5
- package/SECURITY.md +21 -19
- package/docs/design.md +184 -29
- package/lib/client.js +290 -32
- package/lib/index.js +654 -30
- package/lib/native-picker.ps1 +290 -0
- package/package.json +5 -2
package/lib/index.js
CHANGED
|
@@ -32,13 +32,16 @@
|
|
|
32
32
|
* Background shell runs (`run_in_background: true`) register with the
|
|
33
33
|
* generic jobs runtime (`ctx.jobs`) under the same re-rooted policy,
|
|
34
34
|
* mirroring the shipped pwsh/bash tools so `job_output` / `job_kill` and
|
|
35
|
-
* finish notices keep working.
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
35
|
+
* finish notices keep working. The launch is ASYNC (the handle is published
|
|
36
|
+
* only once launch preparation, Windows ACL grants included, succeeded), so
|
|
37
|
+
* the launcher is adapted to the jobs runtime's synchronous hooks contract
|
|
38
|
+
* exactly like the shipped tools' `processJob`: the job-owned AbortSignal
|
|
39
|
+
* drives preparation cancellation and a rejected preparation settles the job
|
|
40
|
+
* as `failed`. Reads (read/glob/grep) are unfenced and already work.
|
|
41
|
+
* Both shell paths are addressed through the version-adaptive seam
|
|
42
|
+
* {@link shellUsesExecute}: DSH 0.1.7-alpha.1 retired `shell.run` /
|
|
43
|
+
* `shell.start` in favour of a single `execute()`, and turned
|
|
44
|
+
* `JobSpec.owner` from the calling Agent into its SessionId.
|
|
42
45
|
* 3. Prompt injection: one ordered system-prompt section rendered per
|
|
43
46
|
* assembly from the configured directories of the assembling session.
|
|
44
47
|
* 4. Non-interrupting change notification: configuration changes made via
|
|
@@ -65,23 +68,24 @@
|
|
|
65
68
|
* <secondary>` launched from the primary workspace cannot write the
|
|
66
69
|
* repo) gets a workdir-fix hint attached as an additional context at
|
|
67
70
|
* the `tools/post-execute` boundary.
|
|
68
|
-
* 8.
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
* creation primitive).
|
|
71
|
+
* 8. Directory picking: `pick` uses the host's `native` capability, then an
|
|
72
|
+
* OS dialog on failure. A `browse` capability goes straight to the client
|
|
73
|
+
* browser served by `browse`/`makeDir`; panel closure cancels the request.
|
|
74
|
+
* `reveal` opens an existing directory in the host file manager.
|
|
75
|
+
* 9. `@` file discovery for the configured directories (see the
|
|
76
|
+
* "secondary-directory file discovery" section). The shipped `@`
|
|
77
|
+
* file-reference menu is single-root — its provider walks the session cwd
|
|
78
|
+
* only and refuses candidates outside it — so secondary directories are
|
|
79
|
+
* invisible to it. `multiFolder/listFiles` indexes them over the `fs` seam
|
|
80
|
+
* instead, and the client half registers a companion `@` group fed by that
|
|
81
|
+
* endpoint. The shipped provider is untouched: with no secondary
|
|
82
|
+
* directories configured, behavior is exactly upstream's.
|
|
81
83
|
*/
|
|
82
84
|
|
|
83
85
|
import { mkdir } from 'node:fs/promises'
|
|
84
|
-
import {
|
|
86
|
+
import { spawn } from 'node:child_process'
|
|
87
|
+
import { join, dirname, posix, win32 } from 'node:path'
|
|
88
|
+
import { fileURLToPath } from 'node:url'
|
|
85
89
|
import os from 'node:os'
|
|
86
90
|
|
|
87
91
|
export const name = 'dsh-multi-folder'
|
|
@@ -102,6 +106,35 @@ const CONFIG_GUARD_TEXT =
|
|
|
102
106
|
'This file is managed by the dsh-multi-folder plugin. Secondary working directories may only be ' +
|
|
103
107
|
'configured by the user through the UI (session header or session-creation page) or the /multi-folder command; direct edits are rejected.'
|
|
104
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Which `ctx.shell` seam shape this DSH release exposes.
|
|
111
|
+
*
|
|
112
|
+
* DSH 0.1.7-alpha.1 (commit `d6bebc5783`, "converge on execute()") deleted BOTH
|
|
113
|
+
* `ShellExecutor.run` and `ShellExecutor.start` and replaced them with one
|
|
114
|
+
* `execute(spec): Promise<ShellExecution>`. `ShellExecution` extends
|
|
115
|
+
* `ShellProcess` (status/exitCode/signal/done/kill/readOutput/sandbox) and adds
|
|
116
|
+
* the foreground projection `result(): Promise<ShellRunResult>`, so the two old
|
|
117
|
+
* entry points map onto it exactly:
|
|
118
|
+
*
|
|
119
|
+
* old `await shell.run(spec)` -> `await (await shell.execute(spec)).result()`
|
|
120
|
+
* old `proc = await shell.start(spec)` -> `proc = await shell.execute({ ...spec, onExpiry: 'none' })`
|
|
121
|
+
* (the retired `start` armed no deadline,
|
|
122
|
+
* while `resolve()` defaults `onExpiry` to `'kill'`)
|
|
123
|
+
*
|
|
124
|
+
* The same release changed `JobSpec.owner` from the calling `Agent` to its
|
|
125
|
+
* `SessionId`, and `jobs-local` now resolves that id through `agents.get(id)`
|
|
126
|
+
* (`session "[object Object]" has no live agent`). One probe therefore decides
|
|
127
|
+
* both shapes: an executor old enough to still require `run`/`start` also
|
|
128
|
+
* expects the Agent owner. The probe is the PRESENCE of `execute` — that method
|
|
129
|
+
* was introduced by the same commit that retired the other two, so it can never
|
|
130
|
+
* mean anything else — never the ABSENCE of `run`, so a release that keeps the
|
|
131
|
+
* retired methods as deprecated shims would still take the modern path.
|
|
132
|
+
*
|
|
133
|
+
* @param shell - the resolved `ctx.shell` service, or undefined without one.
|
|
134
|
+
* @returns true when the seam is the post-0.1.7 `execute()` contract.
|
|
135
|
+
*/
|
|
136
|
+
const shellUsesExecute = (shell) => shell !== undefined && typeof shell.execute === 'function'
|
|
137
|
+
|
|
105
138
|
export function apply(ctx) {
|
|
106
139
|
const { fs, sandboxPolicy, systemPrompt } = ctx
|
|
107
140
|
// NOTE: shell, shellEnv, and commands are deliberately NOT captured here.
|
|
@@ -355,6 +388,521 @@ export function apply(ctx) {
|
|
|
355
388
|
return { path: target, parent }
|
|
356
389
|
}
|
|
357
390
|
|
|
391
|
+
// ---------------------------------------------------- native folder picker
|
|
392
|
+
|
|
393
|
+
/** How long one native selection may stay open before it is dismissed. */
|
|
394
|
+
const NATIVE_PICK_TIMEOUT_MS = 5 * 60 * 1000
|
|
395
|
+
const pickerScript = () => join(dirname(fileURLToPath(import.meta.url)), 'native-picker.ps1')
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Ask the host's composed `directoryPicker` service — and only when it
|
|
399
|
+
* composed the NATIVE backend.
|
|
400
|
+
* @returns { path, via: 'host-native' } with a null path when the user
|
|
401
|
+
* cancelled, `{ path: null, via: 'unavailable' }` whenever the host must not
|
|
402
|
+
* be shown an OS chooser (the browse backend, an unknown kind, a capability
|
|
403
|
+
* that cannot even be read), or null when no picker is composed at all — the
|
|
404
|
+
* caller may then try the OS dialog of the host machine.
|
|
405
|
+
*/
|
|
406
|
+
const pickViaHostService = async (signal) => {
|
|
407
|
+
let picker
|
|
408
|
+
try {
|
|
409
|
+
picker = ctx.get('directoryPicker')
|
|
410
|
+
} catch {
|
|
411
|
+
picker = undefined
|
|
412
|
+
}
|
|
413
|
+
if (!picker || typeof picker.capability !== 'function') return null
|
|
414
|
+
let capability
|
|
415
|
+
try {
|
|
416
|
+
capability = picker.capability()
|
|
417
|
+
} catch {
|
|
418
|
+
return { path: null, via: 'unavailable' }
|
|
419
|
+
}
|
|
420
|
+
if (!capability || capability.kind !== 'native') return { path: null, via: 'unavailable' }
|
|
421
|
+
if (typeof capability.pick !== 'function') return null
|
|
422
|
+
const selected = await capability.pick(signal)
|
|
423
|
+
return { path: typeof selected === 'string' && fullyQualified(selected) ? selected : null, via: 'host-native' }
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Run one helper process to completion.
|
|
428
|
+
* @returns { code, stdout, stderr }, { spawnError } when it never started, or
|
|
429
|
+
* { aborted } when the caller's signal stopped it.
|
|
430
|
+
*/
|
|
431
|
+
const runPickerProcess = (command, args, timeoutMs, stopWhen, signal) =>
|
|
432
|
+
new Promise((resolve) => {
|
|
433
|
+
if (signal?.aborted) {
|
|
434
|
+
resolve({ aborted: true })
|
|
435
|
+
return
|
|
436
|
+
}
|
|
437
|
+
let child
|
|
438
|
+
try {
|
|
439
|
+
child = spawn(command, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
440
|
+
} catch (e) {
|
|
441
|
+
resolve({ spawnError: e })
|
|
442
|
+
return
|
|
443
|
+
}
|
|
444
|
+
let stdout = ''
|
|
445
|
+
let stderr = ''
|
|
446
|
+
let settled = false
|
|
447
|
+
const done = (outcome) => {
|
|
448
|
+
if (settled) return
|
|
449
|
+
settled = true
|
|
450
|
+
clearTimeout(killer)
|
|
451
|
+
signal?.removeEventListener('abort', abort)
|
|
452
|
+
resolve(outcome)
|
|
453
|
+
}
|
|
454
|
+
const abort = () => {
|
|
455
|
+
try { child.kill() } catch { /* already gone */ }
|
|
456
|
+
done({ aborted: true })
|
|
457
|
+
}
|
|
458
|
+
const killer = setTimeout(() => {
|
|
459
|
+
try { child.kill() } catch { /* already gone */ }
|
|
460
|
+
done({ killed: true, stdout, stderr })
|
|
461
|
+
}, timeoutMs + 5000)
|
|
462
|
+
if (typeof killer.unref === 'function') killer.unref()
|
|
463
|
+
signal?.addEventListener('abort', abort, { once: true })
|
|
464
|
+
if (signal?.aborted) abort()
|
|
465
|
+
child.stdout?.setEncoding('utf8')
|
|
466
|
+
child.stderr?.setEncoding('utf8')
|
|
467
|
+
const consume = (chunk) => {
|
|
468
|
+
stdout += chunk
|
|
469
|
+
if (stopWhen && !settled && stopWhen(stdout)) {
|
|
470
|
+
try { child.kill() } catch { /* already gone */ }
|
|
471
|
+
done({ code: null, stdout, stderr, early: true })
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
if (child.stdout) child.stdout.on('data', consume)
|
|
475
|
+
if (child.stderr) child.stderr.on('data', (chunk) => { stderr += chunk })
|
|
476
|
+
child.on('error', (error) => done({ spawnError: error }))
|
|
477
|
+
child.on('close', (code) => done({ code, stdout, stderr }))
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
/** The last non-empty line of one helper's output, trimmed, or ''. */
|
|
481
|
+
const lastLine = (text) => {
|
|
482
|
+
const lines = String(text || '').split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0)
|
|
483
|
+
return lines.length > 0 ? lines[lines.length - 1] : ''
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const printedPath = (output) => {
|
|
487
|
+
if (!/\r?\n$/.test(String(output))) return null
|
|
488
|
+
const last = lastLine(output)
|
|
489
|
+
return last !== '' && fullyQualified(last) ? last : null
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* The path a helper had printed by the time it exited — the same read as
|
|
494
|
+
* `printedPath`, without the demand that the line had already ended: a helper
|
|
495
|
+
* stopped right after writing its answer still selected something, and reading
|
|
496
|
+
* that as a dismissal would add nothing, silently.
|
|
497
|
+
*/
|
|
498
|
+
const pickerResultPath = (outcome) => {
|
|
499
|
+
if (!outcome || outcome.spawnError) return null
|
|
500
|
+
const last = lastLine(outcome.stdout)
|
|
501
|
+
return last !== '' && fullyQualified(last) ? last : null
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Re-raise the caller's abort reason when the helper was stopped by it — an
|
|
506
|
+
* aborted outcome carries no exit code, and reading that absence as a helper
|
|
507
|
+
* failure would report a cancelled dialog as a broken one.
|
|
508
|
+
*/
|
|
509
|
+
const raiseIfAborted = (outcome, signal) => {
|
|
510
|
+
if (!outcome.aborted) return
|
|
511
|
+
signal?.throwIfAborted()
|
|
512
|
+
throw new Error('the folder dialog was cancelled')
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Open the HOST machine's own folder chooser. Windows drives the shipped
|
|
517
|
+
* `native-picker.ps1` — the Vista+ common item dialog with FOS_PICKFOLDERS,
|
|
518
|
+
* i.e. the same dialog other applications show, which shell integrations hook
|
|
519
|
+
* (the legacy `FolderBrowserDialog` tree is a different control that they do
|
|
520
|
+
* not) — and macOS asks Finder through `osascript`.
|
|
521
|
+
* @returns { path, via: 'os-dialog' }, or null on a platform with no OS
|
|
522
|
+
* dialog to open — the caller then keeps the plugin's own browser.
|
|
523
|
+
*/
|
|
524
|
+
const pickViaOsDialog = async (initial, signal) => {
|
|
525
|
+
const start = typeof initial === 'string' && fullyQualified(initial) ? initial : ''
|
|
526
|
+
if (process.platform === 'win32') {
|
|
527
|
+
const args = [
|
|
528
|
+
'-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass',
|
|
529
|
+
'-File', pickerScript(),
|
|
530
|
+
'-TimeoutMs', String(NATIVE_PICK_TIMEOUT_MS),
|
|
531
|
+
]
|
|
532
|
+
if (start !== '') args.push('-InitialDirectory', start)
|
|
533
|
+
let outcome = await runPickerProcess('pwsh', args, NATIVE_PICK_TIMEOUT_MS, printedPath, signal)
|
|
534
|
+
if (outcome.spawnError && outcome.spawnError.code === 'ENOENT') {
|
|
535
|
+
// Windows PowerShell is always present; pwsh is only usually present.
|
|
536
|
+
outcome = await runPickerProcess('powershell.exe', args, NATIVE_PICK_TIMEOUT_MS, printedPath, signal)
|
|
537
|
+
}
|
|
538
|
+
raiseIfAborted(outcome, signal)
|
|
539
|
+
signal?.throwIfAborted()
|
|
540
|
+
if (outcome.spawnError) throw outcome.spawnError
|
|
541
|
+
const selected = printedPath(outcome.stdout) || pickerResultPath(outcome)
|
|
542
|
+
const settled = { path: typeof selected === 'string' && fullyQualified(selected) ? selected : null, via: 'os-dialog' }
|
|
543
|
+
// `early` = the answer arrived and the helper is merely shutting down;
|
|
544
|
+
// `killed` = its own deadline dismissed the dialog and the process
|
|
545
|
+
// outlived the grace period. Neither is a failure, and NEITHER carries an
|
|
546
|
+
// exit code to judge (both report `code: null`).
|
|
547
|
+
if (outcome.early || outcome.killed) return settled
|
|
548
|
+
// The helper exits 0 for BOTH "selected" and "dismissed" (it prints a path
|
|
549
|
+
// or nothing), so any other exit means the dialog never ran — an
|
|
550
|
+
// ExecutionPolicy block, a failed Add-Type. Reporting that as a plain
|
|
551
|
+
// cancellation would leave the user watching nothing happen at all, which
|
|
552
|
+
// is why it is raised for the caller to fall back on.
|
|
553
|
+
if (outcome.code !== 0) {
|
|
554
|
+
const why = lastLine(outcome.stderr)
|
|
555
|
+
throw new Error('the folder dialog helper failed (exit ' + outcome.code + ')' + (why ? ': ' + why : ''))
|
|
556
|
+
}
|
|
557
|
+
return settled
|
|
558
|
+
}
|
|
559
|
+
if (process.platform === 'darwin') {
|
|
560
|
+
// No prompt: Finder supplies its own localized one, exactly like the
|
|
561
|
+
// Windows dialog supplies its own localized title.
|
|
562
|
+
const outcome = await runPickerProcess('osascript', ['-e', 'POSIX path of (choose folder)'], NATIVE_PICK_TIMEOUT_MS, printedPath, signal)
|
|
563
|
+
raiseIfAborted(outcome, signal)
|
|
564
|
+
signal?.throwIfAborted()
|
|
565
|
+
if (outcome.spawnError) throw outcome.spawnError
|
|
566
|
+
const selected = printedPath(outcome.stdout) || pickerResultPath(outcome)
|
|
567
|
+
const settled = { path: typeof selected === 'string' && fullyQualified(selected) ? selected : null, via: 'os-dialog' }
|
|
568
|
+
// An early or killed outcome carries no exit code of its own, and a
|
|
569
|
+
// dismissal is the ordinary result in both cases.
|
|
570
|
+
if (outcome.early || outcome.killed) return settled
|
|
571
|
+
// AppleScript reports a user cancellation as execution error -128; every
|
|
572
|
+
// other non-zero exit is a real failure and must not be mistaken for one.
|
|
573
|
+
if (outcome.code !== 0 && !/-128|User canceled/i.test(String(outcome.stderr || ''))) {
|
|
574
|
+
const why = lastLine(outcome.stderr)
|
|
575
|
+
throw new Error('the macOS folder chooser failed (exit ' + outcome.code + ')' + (why ? ': ' + why : ''))
|
|
576
|
+
}
|
|
577
|
+
return settled
|
|
578
|
+
}
|
|
579
|
+
return null
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Open the host machine's file manager at one existing directory — one click from the
|
|
584
|
+
* panel to the shell integrations the user already has (Listary, QuickLook, a
|
|
585
|
+
* terminal "open here"). Detached by design: the file manager outlives this
|
|
586
|
+
* call, so there is no exit status to report and no error to invent.
|
|
587
|
+
* @param path - fully qualified directory on the host.
|
|
588
|
+
* @returns the canonical path and the helper that was asked to show it.
|
|
589
|
+
*/
|
|
590
|
+
const coreReveal = async (path) => {
|
|
591
|
+
if (typeof path !== 'string' || !fullyQualified(path)) {
|
|
592
|
+
throw new Error('reveal requires a fully qualified path, got "' + String(path) + '"')
|
|
593
|
+
}
|
|
594
|
+
const target = await fs.resolve(path)
|
|
595
|
+
const info = await fs.stat(target)
|
|
596
|
+
if (!info || info.type !== 'directory') throw new Error('reveal requires an existing directory: "' + path + '"')
|
|
597
|
+
const absolute = fs.processPath(target)
|
|
598
|
+
const command = process.platform === 'win32' ? 'explorer.exe' : process.platform === 'darwin' ? 'open' : 'xdg-open'
|
|
599
|
+
let child
|
|
600
|
+
try {
|
|
601
|
+
child = spawn(command, [absolute], { detached: true, stdio: 'ignore' })
|
|
602
|
+
} catch (e) {
|
|
603
|
+
throw new Error('cannot open "' + absolute + '": ' + String(e && e.message ? e.message : e))
|
|
604
|
+
}
|
|
605
|
+
// A detached spawn reports a missing executable asynchronously, so wait for
|
|
606
|
+
// 'spawn' before claiming success: otherwise a host without `xdg-open` would
|
|
607
|
+
// report an opened file manager that never appeared. After that the process
|
|
608
|
+
// is on its own — detached, unreferenced, and with a listener so a later
|
|
609
|
+
// failure cannot surface as an unhandled 'error' event on the plugin.
|
|
610
|
+
try {
|
|
611
|
+
await new Promise((resolve, reject) => {
|
|
612
|
+
child.once('spawn', resolve)
|
|
613
|
+
child.once('error', reject)
|
|
614
|
+
})
|
|
615
|
+
} catch (e) {
|
|
616
|
+
throw new Error('cannot open "' + absolute + '": ' + String(e && e.message ? e.message : e))
|
|
617
|
+
}
|
|
618
|
+
child.on('error', () => {})
|
|
619
|
+
child.unref()
|
|
620
|
+
return { path: absolute, via: command }
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// ------------------------------------- secondary-directory file discovery
|
|
624
|
+
// The shipped `@` file-reference menu is SINGLE-ROOT by construction: its
|
|
625
|
+
// provider (`dsh-file-reference-local`) builds one `WorkspaceFileSearch` per
|
|
626
|
+
// agent from `agent.session.header.cwd` and refuses every candidate outside
|
|
627
|
+
// that root (`resolveDisplayDirectory` answers undefined for a path that
|
|
628
|
+
// escapes it). A configured secondary directory is by definition outside the
|
|
629
|
+
// primary workspace, so the shipped menu cannot reach it — no upstream
|
|
630
|
+
// incompatibility, simply a scope the provider does not cover.
|
|
631
|
+
//
|
|
632
|
+
// This plugin therefore publishes its own discovery endpoint over the files
|
|
633
|
+
// it already has read access to (`fs.listDir`, the same seam the owned
|
|
634
|
+
// directory browser uses) and the client half registers a companion `@`
|
|
635
|
+
// group fed by it. Nothing here alters the shipped provider: a workspace with
|
|
636
|
+
// no secondary directories keeps exactly the upstream behavior, and the
|
|
637
|
+
// shipped group keeps answering on its own.
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Directory basenames never traversed. Mirrors the shipped provider's
|
|
641
|
+
* defaults (`dsh-file-reference-local`): version-control and dependency
|
|
642
|
+
* stores plus build-output names whose generated files would otherwise
|
|
643
|
+
* spend the entry budget twice and rank beside their own sources. `lib` is
|
|
644
|
+
* deliberately absent there and therefore here.
|
|
645
|
+
*/
|
|
646
|
+
const INDEX_EXCLUDED = new Set([
|
|
647
|
+
'.git', 'node_modules', 'dist', 'build', 'out', 'coverage', 'target',
|
|
648
|
+
'.next', '.nuxt', '.turbo', '.venv', '__pycache__', '.pytest_cache',
|
|
649
|
+
'.mypy_cache', '.gradle',
|
|
650
|
+
])
|
|
651
|
+
/** Entries retained per workspace across every configured directory. */
|
|
652
|
+
const MAX_INDEX_ENTRIES = 20000
|
|
653
|
+
/** Entries retained for one level listing. */
|
|
654
|
+
const MAX_LEVEL_ENTRIES = 500
|
|
655
|
+
/** Candidates one query returns. */
|
|
656
|
+
const MAX_FILE_RESULTS = 30
|
|
657
|
+
/**
|
|
658
|
+
* How long one workspace's index answers before the next query rebuilds it.
|
|
659
|
+
* Autocomplete is advisory, so a few seconds of staleness is invisible while
|
|
660
|
+
* it bounds rebuild cost to one traversal per window — a config change is
|
|
661
|
+
* caught immediately instead, by the directory signature.
|
|
662
|
+
*/
|
|
663
|
+
const FILE_INDEX_TTL_MS = 4000
|
|
664
|
+
/** wsKey(workspace) -> { sig, builtAt, entries } */
|
|
665
|
+
const filesCache = new Map()
|
|
666
|
+
|
|
667
|
+
const lastSegment = (p) => {
|
|
668
|
+
const parts = String(p).replace(/[\\/]+$/, '').split(/[\\/]/)
|
|
669
|
+
return parts.length === 0 ? '' : parts[parts.length - 1]
|
|
670
|
+
}
|
|
671
|
+
/** Absolute path in the forward-slash spelling the mention grammar carries. */
|
|
672
|
+
const slashed = (p) => String(p).replace(/\\/g, '/')
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Index one workspace's secondary directories: breadth-first, canonical-path
|
|
676
|
+
* deduplicated (a junction/symlink pointing back up the tree cannot re-enter
|
|
677
|
+
* the walk), excluding `INDEX_EXCLUDED` basenames, bounded by
|
|
678
|
+
* `MAX_INDEX_ENTRIES` across all directories.
|
|
679
|
+
* @param dirs - configured secondary directories.
|
|
680
|
+
* @returns entries as `{ dir, rel, kind }`, `rel` forward-slashed inside `dir`.
|
|
681
|
+
*/
|
|
682
|
+
const scanFiles = async (dirs) => {
|
|
683
|
+
const entries = []
|
|
684
|
+
const visited = new Set()
|
|
685
|
+
for (const dir of dirs) {
|
|
686
|
+
const queue = [{ abs: dir, rel: '' }]
|
|
687
|
+
for (let cursor = 0; cursor < queue.length && entries.length < MAX_INDEX_ENTRIES; cursor += 1) {
|
|
688
|
+
const level = queue[cursor]
|
|
689
|
+
let target
|
|
690
|
+
let absolute
|
|
691
|
+
try {
|
|
692
|
+
target = await fs.resolve(level.abs)
|
|
693
|
+
absolute = fs.processPath(target)
|
|
694
|
+
} catch {
|
|
695
|
+
continue // an unresolvable level contributes nothing
|
|
696
|
+
}
|
|
697
|
+
const canonical = wsKey(absolute)
|
|
698
|
+
if (visited.has(canonical)) continue
|
|
699
|
+
visited.add(canonical)
|
|
700
|
+
let children
|
|
701
|
+
try {
|
|
702
|
+
children = await fs.listDir(target)
|
|
703
|
+
} catch {
|
|
704
|
+
continue // an unreadable branch costs its own entries; the rest stay useful
|
|
705
|
+
}
|
|
706
|
+
const api = pathApiFor(absolute)
|
|
707
|
+
for (const child of children ?? []) {
|
|
708
|
+
if (child === null || child === undefined) continue
|
|
709
|
+
const childName = String(child.name)
|
|
710
|
+
const rel = level.rel === '' ? childName : level.rel + '/' + childName
|
|
711
|
+
if (child.type === 'directory') {
|
|
712
|
+
if (INDEX_EXCLUDED.has(childName)) continue
|
|
713
|
+
entries.push({ dir, rel, kind: 'directory' })
|
|
714
|
+
queue.push({ abs: api.join(absolute, childName), rel })
|
|
715
|
+
} else if (child.type === 'file') {
|
|
716
|
+
entries.push({ dir, rel, kind: 'file' })
|
|
717
|
+
}
|
|
718
|
+
if (entries.length >= MAX_INDEX_ENTRIES) break
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
return entries
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/** One workspace's index, rebuilt when its directory set changes or the TTL lapses. */
|
|
726
|
+
const indexFor = async (ws, dirs) => {
|
|
727
|
+
const key = wsKey(ws)
|
|
728
|
+
const sig = dirs.map(wsKey).join('|')
|
|
729
|
+
const cached = filesCache.get(key)
|
|
730
|
+
if (cached !== undefined && cached.sig === sig && Date.now() - cached.builtAt < FILE_INDEX_TTL_MS) {
|
|
731
|
+
return cached.entries
|
|
732
|
+
}
|
|
733
|
+
const entries = await scanFiles(dirs)
|
|
734
|
+
filesCache.set(key, { sig, builtAt: Date.now(), entries })
|
|
735
|
+
return entries
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/** Longest common subsequence gap score, mirroring the shipped provider. */
|
|
739
|
+
const subsequenceScore = (target, needle) => {
|
|
740
|
+
let at = 0
|
|
741
|
+
let gap = 0
|
|
742
|
+
for (const character of needle) {
|
|
743
|
+
const found = target.indexOf(character, at)
|
|
744
|
+
if (found < 0) return undefined
|
|
745
|
+
gap += found - at
|
|
746
|
+
at = found + 1
|
|
747
|
+
}
|
|
748
|
+
return Math.max(0, 100 - gap)
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* Score one entry against a query. Ranking mirrors the shipped provider
|
|
753
|
+
* (name beat path, directories win ties) with ONE deliberate deviation: the
|
|
754
|
+
* path/path-subsequence rules read the IN-DIRECTORY relative path, never the
|
|
755
|
+
* absolute one. Every absolute path shares the host prefix (`D:/…`), so
|
|
756
|
+
* scoring it would make a one-character query match every candidate. The
|
|
757
|
+
* directory's own basename stays searchable at the lowest precedence, which
|
|
758
|
+
* is what lets `@secondary-spike` narrow to that directory.
|
|
759
|
+
*/
|
|
760
|
+
const scoreEntry = (entry, needle) => {
|
|
761
|
+
if (needle === '') return 0
|
|
762
|
+
const rel = entry.rel.toLowerCase()
|
|
763
|
+
const name = lastSegment(rel).toLowerCase()
|
|
764
|
+
const bonus = entry.kind === 'directory' ? 25 : 0
|
|
765
|
+
if (name === needle) return 1000 + bonus
|
|
766
|
+
if (name.startsWith(needle)) return 900 + bonus
|
|
767
|
+
if (name.includes(needle)) return 700 + bonus
|
|
768
|
+
if (rel.includes(needle)) return 500 + bonus
|
|
769
|
+
const sub = subsequenceScore(rel, needle)
|
|
770
|
+
if (sub !== undefined) return 300 + sub + bonus
|
|
771
|
+
if (lastSegment(entry.dir).toLowerCase().includes(needle)) return 200 + bonus
|
|
772
|
+
return undefined
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/** Rank entries deterministically and project the wire shape. */
|
|
776
|
+
const rankEntries = (entries, query) => {
|
|
777
|
+
const needle = query.toLowerCase()
|
|
778
|
+
const ranked = []
|
|
779
|
+
for (const entry of entries) {
|
|
780
|
+
const score = scoreEntry(entry, needle)
|
|
781
|
+
if (score !== undefined) ranked.push({ entry, score })
|
|
782
|
+
}
|
|
783
|
+
ranked.sort((left, right) =>
|
|
784
|
+
right.score - left.score
|
|
785
|
+
|| (left.entry.kind === right.entry.kind ? 0 : left.entry.kind === 'directory' ? -1 : 1)
|
|
786
|
+
|| (needle === '' ? 0 : left.entry.rel.length - right.entry.rel.length)
|
|
787
|
+
|| (left.entry.rel < right.entry.rel ? -1 : left.entry.rel > right.entry.rel ? 1 : 0))
|
|
788
|
+
return ranked.slice(0, MAX_FILE_RESULTS).map(({ entry }) => ({
|
|
789
|
+
path: entry.rel === '' ? slashed(entry.dir) : slashed(entry.dir) + '/' + entry.rel,
|
|
790
|
+
kind: entry.kind,
|
|
791
|
+
dir: entry.dir,
|
|
792
|
+
rel: entry.rel,
|
|
793
|
+
}))
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* Decode the in-directory part of a level query into a normalized relative
|
|
798
|
+
* path, or reject it. `.` and empty segments are normalized away, and ANY
|
|
799
|
+
* `..` segment is refused: without that, `@secondary/../../etc/` would list a
|
|
800
|
+
* directory outside every configured root while still claiming the configured
|
|
801
|
+
* directory as its owner — a candidate whose `dir` field lies, and a level
|
|
802
|
+
* listing that escaped the set the user actually granted. The shipped
|
|
803
|
+
* provider refuses the same escape for the same reason.
|
|
804
|
+
* @returns the normalized relative path, or null when the query escapes.
|
|
805
|
+
*/
|
|
806
|
+
const decodeInside = (inside) => {
|
|
807
|
+
const segments = String(inside).split('/').filter((segment) => segment !== '' && segment !== '.')
|
|
808
|
+
return segments.includes('..') ? null : segments.join('/')
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Resolve the directory part of a query to `(configured dir, in-dir path)`.
|
|
813
|
+
* Two spellings are accepted: a fully qualified path inside a configured
|
|
814
|
+
* directory (what a drill inserts), and a path whose first segment names a
|
|
815
|
+
* configured directory by basename (`@secondary-spike/src/`). An ambiguous
|
|
816
|
+
* basename (two configured directories sharing one) is refused, never
|
|
817
|
+
* guessed.
|
|
818
|
+
* @returns the resolved level, or null when the query names no configured directory.
|
|
819
|
+
*/
|
|
820
|
+
const resolveLevel = (dirs, directory) => {
|
|
821
|
+
const trimmed = String(directory).replace(/\/+$/, '')
|
|
822
|
+
if (trimmed === '') return null
|
|
823
|
+
const query = slashed(trimmed).toLowerCase()
|
|
824
|
+
if (fullyQualified(trimmed)) {
|
|
825
|
+
for (const dir of longestRootFirst(dirs)) {
|
|
826
|
+
const root = slashed(dir).toLowerCase()
|
|
827
|
+
if (query === root) return { dir, inside: '' }
|
|
828
|
+
if (query.startsWith(root + '/')) {
|
|
829
|
+
const inside = decodeInside(slashed(trimmed).slice(slashed(dir).length + 1))
|
|
830
|
+
return inside === null ? null : { dir, inside }
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
return null
|
|
834
|
+
}
|
|
835
|
+
const cut = trimmed.indexOf('/')
|
|
836
|
+
const head = cut < 0 ? trimmed : trimmed.slice(0, cut)
|
|
837
|
+
const inside = decodeInside(cut < 0 ? '' : trimmed.slice(cut + 1))
|
|
838
|
+
if (inside === null) return null
|
|
839
|
+
const matches = dirs.filter((dir) => lastSegment(dir).toLowerCase() === head.toLowerCase())
|
|
840
|
+
if (matches.length !== 1) return null
|
|
841
|
+
return { dir: matches[0], inside }
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/** List one level of a configured secondary directory (files and directories). */
|
|
845
|
+
const listLevel = async (dirs, directory, fragment) => {
|
|
846
|
+
const empty = { candidates: [], truncated: false }
|
|
847
|
+
const level = resolveLevel(dirs, directory)
|
|
848
|
+
if (level === null) return empty
|
|
849
|
+
const absolute = level.inside === '' ? level.dir : pathApiFor(level.dir).join(level.dir, ...level.inside.split('/'))
|
|
850
|
+
let children
|
|
851
|
+
try {
|
|
852
|
+
children = await fs.listDir(await fs.resolve(absolute))
|
|
853
|
+
} catch {
|
|
854
|
+
return empty
|
|
855
|
+
}
|
|
856
|
+
const shown = []
|
|
857
|
+
for (const child of children ?? []) {
|
|
858
|
+
if (child === null || child === undefined) continue
|
|
859
|
+
const childName = String(child.name)
|
|
860
|
+
const kind = child.type === 'directory' ? 'directory' : child.type === 'file' ? 'file' : null
|
|
861
|
+
if (kind === null) continue
|
|
862
|
+
if (kind === 'directory' && INDEX_EXCLUDED.has(childName)) continue
|
|
863
|
+
// Hidden entries stay reachable by asking for them explicitly, exactly
|
|
864
|
+
// like the shipped provider (and this plugin's own directory browser).
|
|
865
|
+
if (childName.startsWith('.') && !fragment.startsWith('.')) continue
|
|
866
|
+
shown.push({ dir: level.dir, rel: level.inside === '' ? childName : level.inside + '/' + childName, kind })
|
|
867
|
+
}
|
|
868
|
+
const truncated = shown.length > MAX_LEVEL_ENTRIES
|
|
869
|
+
if (truncated) shown.length = MAX_LEVEL_ENTRIES
|
|
870
|
+
return { candidates: rankEntries(shown, fragment), truncated }
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* Discover candidates for one `@` query across a workspace's configured
|
|
875
|
+
* secondary directories. An empty query yields the directories themselves
|
|
876
|
+
* (the menu's entry points); a query carrying a separator lists that level;
|
|
877
|
+
* a bare fragment fuzzy-ranks the index.
|
|
878
|
+
* @param ws - primary workspace path (configuration key).
|
|
879
|
+
* @param query - path text following `@` or `@"`.
|
|
880
|
+
* @returns the workspace, its configured directories, and the ranked candidates.
|
|
881
|
+
*/
|
|
882
|
+
const coreListFiles = async (ws, query) => {
|
|
883
|
+
ws = requireWorkspace(ws)
|
|
884
|
+
const entry = await loadDirs(ws)
|
|
885
|
+
const dirs = [...entry.dirs]
|
|
886
|
+
const out = { workspace: ws, dirs, candidates: [], truncated: false }
|
|
887
|
+
if (dirs.length === 0) return out
|
|
888
|
+
const q = typeof query === 'string' ? query.replace(/\\/g, '/') : ''
|
|
889
|
+
if (q === '') {
|
|
890
|
+
out.candidates = dirs.map((dir) => ({ path: slashed(dir), kind: 'directory', dir, rel: '' }))
|
|
891
|
+
return out
|
|
892
|
+
}
|
|
893
|
+
const slash = q.lastIndexOf('/')
|
|
894
|
+
if (slash >= 0) {
|
|
895
|
+
const level = await listLevel(dirs, q.slice(0, slash + 1), q.slice(slash + 1))
|
|
896
|
+
out.candidates = level.candidates
|
|
897
|
+
out.truncated = level.truncated
|
|
898
|
+
return out
|
|
899
|
+
}
|
|
900
|
+
const index = await indexFor(ws, dirs)
|
|
901
|
+
const visible = q.startsWith('.') ? index : index.filter((item) => !item.rel.split('/').some((segment) => segment.startsWith('.')))
|
|
902
|
+
out.candidates = rankEntries(visible, q)
|
|
903
|
+
return out
|
|
904
|
+
}
|
|
905
|
+
|
|
358
906
|
// ----------------------------------------------- sessionless remote API
|
|
359
907
|
// `multiFolder/*` endpoints over the Typert gateway. Hand-written
|
|
360
908
|
// `src-json` descriptors registered through ctx.typert.register (the
|
|
@@ -365,6 +913,9 @@ export function apply(ctx) {
|
|
|
365
913
|
const remoteErrorMessage = (e) =>
|
|
366
914
|
'multi-folder: ' + String(e && e.message ? e.message : e).replace(/^multi-folder:\s*/, '')
|
|
367
915
|
|
|
916
|
+
/** One failure's message, for composing a diagnostic without nesting prefixes. */
|
|
917
|
+
const reasonOf = (e) => String(e && e.message ? e.message : e)
|
|
918
|
+
|
|
368
919
|
const multiFolderApi = {
|
|
369
920
|
async list(workspace) {
|
|
370
921
|
try {
|
|
@@ -408,7 +959,55 @@ export function apply(ctx) {
|
|
|
408
959
|
throw new Error(remoteErrorMessage(e))
|
|
409
960
|
}
|
|
410
961
|
},
|
|
962
|
+
async listFiles(workspace, query) {
|
|
963
|
+
try {
|
|
964
|
+
return await coreListFiles(workspace, query)
|
|
965
|
+
} catch (e) {
|
|
966
|
+
throw new Error(remoteErrorMessage(e))
|
|
967
|
+
}
|
|
968
|
+
},
|
|
969
|
+
/**
|
|
970
|
+
* Open the system folder chooser on the host machine and report what came
|
|
971
|
+
* back. `via` tells the client half apart the three outcomes it must
|
|
972
|
+
* distinguish: a completed system dialog (`host-native` / `os-dialog` —
|
|
973
|
+
* a null path means the user cancelled, so nothing must be opened), and
|
|
974
|
+
* `unavailable`, where NO system picker could answer and the client should
|
|
975
|
+
* keep using the plugin's own browser.
|
|
976
|
+
*/
|
|
977
|
+
async pick(cwd, signal) {
|
|
978
|
+
try {
|
|
979
|
+
signal?.throwIfAborted()
|
|
980
|
+
let native = null
|
|
981
|
+
let nativeFailure = null
|
|
982
|
+
try {
|
|
983
|
+
native = await pickViaHostService(signal)
|
|
984
|
+
} catch (e) {
|
|
985
|
+
// A composed chooser that refuses or crashes must not take the session
|
|
986
|
+
// down with it — the OS dialog below is exactly the second attempt.
|
|
987
|
+
// The reason is still kept and logged, because a downgrade nobody can
|
|
988
|
+
// explain afterwards is the failure mode this fallback exists to avoid.
|
|
989
|
+
nativeFailure = e
|
|
990
|
+
ctx.logger?.warn?.('multi-folder: the host directory picker failed: ' + reasonOf(nativeFailure))
|
|
991
|
+
}
|
|
992
|
+
signal?.throwIfAborted()
|
|
993
|
+
if (native) return native
|
|
994
|
+
const os = await pickViaOsDialog(cwd, signal)
|
|
995
|
+
if (os) return os
|
|
996
|
+
if (nativeFailure) throw new Error('the host directory picker failed: ' + reasonOf(nativeFailure))
|
|
997
|
+
return { path: null, via: 'unavailable' }
|
|
998
|
+
} catch (e) {
|
|
999
|
+
throw new Error(remoteErrorMessage(e))
|
|
1000
|
+
}
|
|
1001
|
+
},
|
|
1002
|
+
async reveal(path) {
|
|
1003
|
+
try {
|
|
1004
|
+
return await coreReveal(path)
|
|
1005
|
+
} catch (e) {
|
|
1006
|
+
throw new Error(remoteErrorMessage(e))
|
|
1007
|
+
}
|
|
1008
|
+
},
|
|
411
1009
|
}
|
|
1010
|
+
|
|
412
1011
|
Object.defineProperty(multiFolderApi, 'typertRemote', {
|
|
413
1012
|
value: Object.freeze({
|
|
414
1013
|
service: multiFolderApi,
|
|
@@ -439,6 +1038,9 @@ export function apply(ctx) {
|
|
|
439
1038
|
remoteInvocation('set', ['workspace', 'dirs']),
|
|
440
1039
|
remoteInvocation('browse', ['path']),
|
|
441
1040
|
remoteInvocation('makeDir', ['parent', 'name']),
|
|
1041
|
+
remoteInvocation('listFiles', ['workspace', 'query']),
|
|
1042
|
+
{ ...remoteInvocation('pick', ['cwd']), cancellation: { parameter: 'signal' } },
|
|
1043
|
+
remoteInvocation('reveal', ['path']),
|
|
442
1044
|
],
|
|
443
1045
|
}
|
|
444
1046
|
|
|
@@ -665,24 +1267,34 @@ export function apply(ctx) {
|
|
|
665
1267
|
* Adapt one asynchronous background launch to the jobs runtime's SYNCHRONOUS
|
|
666
1268
|
* hooks contract, mirroring the shipped pwsh/bash tools' `processJob`.
|
|
667
1269
|
*
|
|
668
|
-
*
|
|
669
|
-
* preparation (Windows ACL grants included) and
|
|
670
|
-
* cancelled or fails — so the handle can never be
|
|
671
|
-
*
|
|
672
|
-
* secondary directory fail immediately with
|
|
1270
|
+
* The launch is ASYNC in every supported release — it resolves the process
|
|
1271
|
+
* handle only after launch preparation (Windows ACL grants included) and
|
|
1272
|
+
* rejects when preparation is cancelled or fails — so the handle can never be
|
|
1273
|
+
* dereferenced from `run()`. Treating it as synchronous made every background
|
|
1274
|
+
* run in a secondary directory fail immediately with
|
|
673
1275
|
* `Cannot read properties of undefined (reading 'then')` (`proc.done` read
|
|
674
1276
|
* off the un-awaited promise). The job-owned AbortSignal travels into
|
|
675
1277
|
* `shell.resolve`, so `cancel` stops a launch that has not published a handle
|
|
676
1278
|
* yet, and a rejected preparation settles the job as `failed` instead of
|
|
677
1279
|
* leaving it running forever. A background process outlives the tool call, so
|
|
678
|
-
* no CALLER signal is forwarded
|
|
1280
|
+
* no CALLER signal is forwarded, and it must not inherit a deadline:
|
|
1281
|
+
* `onExpiry: 'none'` is what makes the post-0.1.7 `execute()` path ignore
|
|
1282
|
+
* `timeoutMs`, which the retired `start()` did by construction. Without it a
|
|
1283
|
+
* background command would be killed at the executor's default timeout — a
|
|
1284
|
+
* silent behavior regression the shipped tools avoid the same way.
|
|
679
1285
|
*/
|
|
680
1286
|
const startBackgroundJob = (shell, request) => {
|
|
681
1287
|
const controller = new AbortController()
|
|
1288
|
+
const modern = shellUsesExecute(shell)
|
|
682
1289
|
let proc
|
|
683
1290
|
const done = (async () => {
|
|
684
1291
|
try {
|
|
685
|
-
|
|
1292
|
+
const spec = shell.resolve({
|
|
1293
|
+
...request,
|
|
1294
|
+
signal: controller.signal,
|
|
1295
|
+
...(modern ? { onExpiry: 'none' } : {}),
|
|
1296
|
+
})
|
|
1297
|
+
proc = modern ? await shell.execute(spec) : await shell.start(spec)
|
|
686
1298
|
try {
|
|
687
1299
|
if (controller.signal.aborted) proc.kill()
|
|
688
1300
|
} finally {
|
|
@@ -889,6 +1501,7 @@ export function apply(ctx) {
|
|
|
889
1501
|
if (exec.name === 'pwsh' || exec.name === 'bash') {
|
|
890
1502
|
const shell = ctx.get('shell')
|
|
891
1503
|
if (shell === undefined) return next()
|
|
1504
|
+
const modernShell = shellUsesExecute(shell)
|
|
892
1505
|
if (dirs === null) return next()
|
|
893
1506
|
const rawWorkdir = args && typeof args.workdir === 'string' ? args.workdir : null
|
|
894
1507
|
const joined = rawWorkdir === null
|
|
@@ -923,10 +1536,16 @@ export function apply(ctx) {
|
|
|
923
1536
|
const jobs = ctx.get('jobs')
|
|
924
1537
|
if (jobs === undefined) return next()
|
|
925
1538
|
owned = { policy }
|
|
1539
|
+
// `JobSpec.owner` is the owner's SessionId since 0.1.7-alpha.1
|
|
1540
|
+
// (`jobs-local` resolves it through `agents.get(id)`); the older seam
|
|
1541
|
+
// took the Agent itself. Passing the wrong shape throws
|
|
1542
|
+
// `session "[object Object]" has no live agent` and the background run
|
|
1543
|
+
// never starts. `Agent.id` IS the session id, which is the spelling
|
|
1544
|
+
// the shipped pwsh/bash tools register with.
|
|
926
1545
|
const jobId = jobs.start({
|
|
927
1546
|
kind: exec.name,
|
|
928
1547
|
label: String(args.command),
|
|
929
|
-
...(exec.agent ? { owner: exec.agent } : {}),
|
|
1548
|
+
...(exec.agent ? { owner: modernShell ? exec.agent.id : exec.agent } : {}),
|
|
930
1549
|
run: () => startBackgroundJob(shell, request),
|
|
931
1550
|
})
|
|
932
1551
|
return {
|
|
@@ -937,7 +1556,12 @@ export function apply(ctx) {
|
|
|
937
1556
|
}
|
|
938
1557
|
|
|
939
1558
|
owned = { policy }
|
|
940
|
-
|
|
1559
|
+
// One call site, two release shapes: 0.1.7-alpha.1 replaced
|
|
1560
|
+
// `shell.run(spec)` with `execute(spec)` + the handle's foreground
|
|
1561
|
+
// projection `result()`. See {@link shellUsesExecute}.
|
|
1562
|
+
const result = modernShell
|
|
1563
|
+
? await (await shell.execute(shell.resolve({ ...request, signal: exec.signal }))).result()
|
|
1564
|
+
: await shell.run(shell.resolve({ ...request, signal: exec.signal }))
|
|
941
1565
|
if (result.aborted) {
|
|
942
1566
|
return {
|
|
943
1567
|
isError: true,
|