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/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. `shell.start` is ASYNC (it publishes the
36
- * handle only once launch preparation, Windows ACL grants included,
37
- * succeeded), so the launcher is adapted to the jobs runtime's synchronous
38
- * hooks contract exactly like the shipped tools' `processJob`: the job-owned
39
- * AbortSignal drives preparation cancellation and a rejected preparation
40
- * settles the job as `failed`. Reads (read/glob/grep) are unfenced and
41
- * already work.
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. Owned directory browser: "Add directory" opens a browser drawn by the
69
- * client half and served by `browse`/`makeDir` above. Listing rides the
70
- * `fs` seam (`fs.listDir`), which — unlike the host's directory-picker
71
- * seam — is composed in EVERY deployment, so one interaction covers the
72
- * native-chooser composition, the browse composition (LAN / remote
73
- * clients / desktop shells) and shells that compose no picker at all.
74
- * The framework offers no plugin-facing alternative: `uiWorkspace`'s
75
- * `pickDirectory()` is native-only (it answers `directory-picker/unavailable`
76
- * under the browse composition), its `listDirectory`/`createDirectory`
77
- * twins are browse-only, and the shipped in-app browser is reachable only
78
- * by the shell's own workspace surfaces. Directory creation mirrors the
79
- * shipped browse backend, which uses Node's `mkdir` (the fs seam has no
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 { join, posix, win32 } from 'node:path'
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
- * `shell.start` is ASYNC — it resolves the process handle only after launch
669
- * preparation (Windows ACL grants included) and rejects when preparation is
670
- * cancelled or fails — so the handle can never be dereferenced from `run()`.
671
- * Calling it as if it returned a process made every background run in a
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; `shell.start` ignores `timeoutMs` by design.
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
- proc = await shell.start(shell.resolve({ ...request, signal: controller.signal }))
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
- const result = await shell.run(shell.resolve({ ...request, signal: exec.signal }))
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,