dsh-multi-folder 0.3.2 → 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 CHANGED
@@ -50,7 +50,8 @@ A Multi-folder button appears in the session header, and a second entry appears
50
50
 
51
51
  | Action | Behavior |
52
52
  | ------ | -------- |
53
- | Add directory | Opens the plugin's own directory browser (path field, one level of child directories, optional new folder) |
53
+ | Add directory | Uses the host's native picker, then its OS dialog on failure; a `browse` capability opens the client browser directly (path field, one level of child directories, optional new folder) |
54
+ | Open in file manager | Opens the host's file manager (Explorer / Finder) at a configured directory |
54
55
  | Remove / refresh | Applies immediately |
55
56
  | Switch session | The panel auto-switches to that session's directories |
56
57
  | Reopen panel | Uses the per-session cache — no redundant command rows |
@@ -98,8 +99,8 @@ When a shell run ends in such a denial and references a configured secondary dir
98
99
  - **Prompt injection** — one ordered `systemPrompt` section with a text provider evaluated per assembly, rendering only for sessions whose workspace has configured directories.
99
100
  - **Notifications** — a pending notice armed by the command handler (only on actual change) is consumed at the next boundary by either the `agent/pre-step` waterfall (prepend into the entering message batch) or the `tools/post-execute` waterfall (attach as `additionalContexts`), whichever fires first — the framework's native plugin-sourced `notice` context.
100
101
  - **Configuration & security boundary** — per-workspace config lives in a host-owned store outside every agent sandbox root (`<DSH_HOME>/storages/multi-folder/<workspace-key>.json`). Direct `write`/`edit` attempts against the config file are rejected with an explicit message — **the agent can never self-grant directories; configuration is user-managed by design**. See [SECURITY.md](SECURITY.md).
101
- - **Sessionless remote API** — a `multiFolder` namespace registered through `ctx.typert.register` (hand-written `src-json` descriptors) plus a plain-object service provided as `multiFolder`. Its `list`/`add`/`remove`/`set` methods are keyed by workspace **path** and share one validated core with the `/multi-folder` command, so the creation page can configure directories before any session exists. `browse`/`makeDir` ride the same namespace: they serve the plugin's own directory browser and never touch the configuration store.
102
- - **Owned directory browser** — "Add directory" is drawn by this plugin and served by `browse`/`makeDir`, so it behaves identically in every deployment: the host's native chooser composition, the browse composition (LAN or remote clients, desktop shells) and shells that compose no picker at all. Listing rides the host `fs` seam (`fs.resolve` + `fs.listDir`).
102
+ - **Sessionless remote API** — a `multiFolder` namespace registered through `ctx.typert.register` (hand-written `src-json` descriptors) plus a plain-object service provided as `multiFolder`. Its `list`/`add`/`remove`/`set` methods are keyed by workspace **path** and share one validated core with the `/multi-folder` command, so the creation page can configure directories before any session exists. `browse`/`makeDir` serve the plugin's directory browser; `pick`/`reveal` select or open a host directory.
103
+ - **Directory picking** — `multiFolder/pick` uses the host's `native` picker, then the OS dialog if it fails. A `browse` capability opens the client browser directly. Closing the panel cancels the request; cancelling a dialog adds nothing. The Windows helper is `lib/native-picker.ps1`.
103
104
  - **`@` discovery** — a second reader of that same `fs` seam: `multiFolder/listFiles` indexes the configured directories (breadth-first, canonical-path deduplicated so a junction cannot re-enter the walk, generated/vendor basenames excluded, capped and cached per workspace with a short TTL) and the client half registers a companion `@` source over it. The shipped single-root provider is not modified, and the shipped files/sessions group is not disturbed: the trigger registry keys sources by `(trigger, name)` and renders one group each.
104
105
  - **Client** — a hand-maintained factory bundle (`window.__ModuleLoader__.load`), no build toolchain required. The panel drives the host through two channels: the Remote BFF (`ctx.remote.commands.execute`) for sessions, and the shared `/api` RPC channel (`ctx.connection.rpc.call`) for the sessionless endpoints.
105
106
 
@@ -108,8 +109,9 @@ When a shell run ends in such a denial and references a configured secondary dir
108
109
  | Path | Purpose |
109
110
  | ---- | ------- |
110
111
  | `cordis.patch.yml` | Profile patch layer inserting the `dsh-multi-folder` row |
111
- | `lib/index.js` | Host plugin: config store, tool-pipeline interception, prompt injection, dual-channel notifications, `/multi-folder` command, sessionless `multiFolder/*` remote API (configuration, the owned browser's `browse`/`makeDir`, and `listFiles` for the `@` menu) |
112
- | `lib/client.js` | Client plugin (factory bundle): session-header button + overlay panel + session-creation page entry (input-dock chip / upstream hero chip / fixed fallback launcher) + the owned directory browser behind "Add directory" + the companion `@` source |
112
+ | `lib/index.js` | Host plugin: configuration, tool interception, remote API, directory picker and file manager |
113
+ | `lib/client.js` | Client panel, directory browser and companion `@` source |
114
+ | `lib/native-picker.ps1` | Windows folder-dialog helper |
113
115
  | `test/` | Runtime-free behavior tests (see Development) |
114
116
  | `docs/` | Design and analysis documents |
115
117
 
package/README.zh.md CHANGED
@@ -50,7 +50,8 @@ dsh plugin --profile web add dsh-multi-folder
50
50
 
51
51
  | 操作 | 行为 |
52
52
  | ---- | ---- |
53
- | 添加目录 | 打开插件自带的目录浏览器(路径输入框 + 一级子目录列表 + 可新建文件夹) |
53
+ | 添加目录 | `native` 使用系统文件夹对话框,失败后尝试宿主对话框;`browse` 直接打开插件自带浏览器(路径输入框 + 一级子目录列表 + 可新建文件夹) |
54
+ | 在文件管理器中打开 | 在宿主机器的文件管理器(资源管理器 / Finder)中打开某个已配置目录 |
54
55
  | 移除 / 刷新 | 立即生效 |
55
56
  | 切换会话 | 面板自动切换为该会话的副工作目录 |
56
57
  | 重新打开面板 | 使用会话级缓存,不产生冗余命令行 |
@@ -98,8 +99,8 @@ Agent 无需任何额外操作:`read` / `glob` / `grep` 随处可用;`write`
98
99
  - **提示词注入**——一个有序 `systemPrompt` 段落,text provider 每次组装按会话求值,仅为配置了副目录的会话渲染。
99
100
  - **通知**——命令处理器仅在目录集合实际变化时置位 pending notice;`agent/pre-step`(前置注入进入批次)与 `tools/post-execute`(附加为 `additionalContexts`)两个通道中先触发者消费——均使用框架原生的插件来源 `notice` 上下文。
100
101
  - **配置与安全边界**——per-workspace 配置存储于 Agent 沙箱之外的宿主自有目录(`<DSH_HOME>/storages/multi-folder/<workspace-key>.json`)。对配置文件的任何直接 `write`/`edit` 都会收到显式拒绝——**Agent 永远无法自我授予目录,配置权仅属于用户**。详见 [SECURITY.md](SECURITY.md)。
101
- - **无会话远程 API**——经 `ctx.typert.register` 注册 `multiFolder` 命名空间(手写 `src-json` 描述符),并以普通对象服务 `multiFolder` 提供;`list`/`add`/`remove`/`set` 以工作区**路径**为键,与 `/multi-folder` 命令共享同一套校验核心,因此会话尚未建立时创建页也能直接配置。`browse`/`makeDir` 属于同一命名空间:它们只服务插件自带的目录浏览器,不触碰配置存储。
102
- - **自带目录浏览器**——「添加目录」由插件自己绘制、经 `browse`/`makeDir` 提供服务,因此在任何部署下行为一致:宿主使用原生选择器的组合、使用 browse 后端的组合(局域网/远程客户端、桌面壳),以及完全没有选择器的壳。列目录走宿主 `fs` seam(`fs.resolve` + `fs.listDir`)。
102
+ - **无会话远程 API**——经 `ctx.typert.register` 注册 `multiFolder` 命名空间(手写 `src-json` 描述符),并以普通对象服务 `multiFolder` 提供;`list`/`add`/`remove`/`set` 以工作区**路径**为键,与 `/multi-folder` 命令共享同一套校验核心,因此会话尚未建立时创建页也能直接配置。`browse`/`makeDir` 服务于自带浏览器;`pick`/`reveal` 负责选择或打开宿主目录。
103
+ - **目录选择**——`multiFolder/pick` 先调用宿主的 `native` 选择器,失败后尝试系统对话框;`browse` 能力直接打开客户端浏览器。关闭面板会取消请求;取消系统对话框不会添加目录。Windows 助手为 `lib/native-picker.ps1`。
103
104
  - **`@` 发现**——同一 `fs` seam 的第二种用法:`multiFolder/listFiles` 为已配置目录建立索引(广度优先、按规范化路径去重以免 junction 绕回、排除生成物/依赖目录名、按工作区限量并短 TTL 缓存),客户端再据此注册一个并列的 `@` source。自带 provider 不做任何修改,自带分组也不受影响:触发器注册表以 `(trigger, name)` 为键,每个 source 各渲染一个分组。
104
105
  - **客户端**——手写维护的 factory bundle(`window.__ModuleLoader__.load`),无需构建工具链;面板经两条通道驱动宿主:会话内走 Remote BFF(`ctx.remote.commands.execute`),无会话端点走共享 `/api` RPC 通道(`ctx.connection.rpc.call`)。
105
106
 
@@ -108,8 +109,9 @@ Agent 无需任何额外操作:`read` / `glob` / `grep` 随处可用;`write`
108
109
  | 路径 | 作用 |
109
110
  | ---- | ---- |
110
111
  | `cordis.patch.yml` | profile patch 层,插入 `dsh-multi-folder` 行 |
111
- | `lib/index.js` | 宿主插件:配置存储、工具流水线拦截、提示词注入、双通道通知、`/multi-folder` 命令、无会话 `multiFolder/*` 远程 API(配置端点 + 自带浏览器用的 `browse`/`makeDir` + `@` 菜单用的 `listFiles`) |
112
- | `lib/client.js` | 客户端插件(factory bundle):会话头部按钮 + 覆盖层面板 + 会话创建页入口(输入框上方的 dock 胶囊 / 上游 hero chip / 右下角兜底浮动按钮)+「添加目录」背后的自带目录浏览器 + 并列的 `@` source |
112
+ | `lib/index.js` | 宿主插件:配置、工具拦截、远程 API、目录选择器和文件管理器 |
113
+ | `lib/client.js` | 客户端面板、自带浏览器和并列 `@` 来源 |
114
+ | `lib/native-picker.ps1` | Windows 文件夹选择器助手 |
113
115
  | `test/` | 免 DSH 运行时的行为测试(见开发) |
114
116
  | `docs/` | 设计与分析文档 |
115
117
 
package/SECURITY.md CHANGED
@@ -32,6 +32,27 @@ granted the session. The design enforces four boundaries:
32
32
  bypasses confinement (as it already does for the primary workspace, by the user's
33
33
  explicit choice).
34
34
 
35
+ ### Host-side UI actions (`pick` / `reveal`)
36
+
37
+ Two sessionless endpoints ask the **host machine** to show UI instead of reading or
38
+ writing a path:
39
+
40
+ - `multiFolder/pick` asks the host's composed directory picker first, and only a
41
+ `native` composition (a loopback, attended, non-SSH host — the framework's own
42
+ `directory-picker-auto` decision) may fall back to this plugin's own OS dialog:
43
+ `lib/native-picker.ps1` on Windows, `osascript` on macOS. A `browse` composition
44
+ never reaches a dialog — the client is sent to the browser this plugin draws.
45
+ - `multiFolder/reveal` opens one **existing directory** in the host file manager
46
+ (`explorer.exe` / `open` / `xdg-open`). It refuses anything that is not an existing
47
+ directory, so a stale entry cannot launch a file.
48
+
49
+ Both ride the same trusted browser→host RPC channel as every other `multiFolder/*`
50
+ endpoint (the `connection` Host/Origin fence plus browser authentication) and are
51
+ never exposed as agent tools. They act on the host by design — the host owns the
52
+ filesystem the session is configured against — which is exactly why a remote client
53
+ must never be routed to them: the UI would appear on a display nobody clicked from.
54
+ The `native`-only gate above is what enforces that.
55
+
35
56
  ### What is deliberately out of scope
36
57
 
37
58
  - In a `danger-full-access` session the agent can already touch the whole filesystem;
@@ -39,22 +60,3 @@ granted the session. The design enforces four boundaries:
39
60
  - The agent can *read* the configuration file (reads are not policy-fenced in the DSH
40
61
  filesystem backend). Reading reveals nothing the system prompt does not already list
41
62
  for that session.
42
-
43
- ## Reporting a vulnerability
44
-
45
- If you believe you have found a security issue in this plugin, please report it
46
- privately by opening a GitHub Security Advisory on the repository instead of a public
47
- issue. Please include:
48
-
49
- - the affected version,
50
- - a minimal reproduction,
51
- - the expected vs. observed behavior.
52
-
53
- We will acknowledge the report within 7 days and aim to publish a fix (or a
54
- documented mitigation) before public disclosure.
55
-
56
- ## Supported versions
57
-
58
- | Version | Supported |
59
- | ------- | --------- |
60
- | 0.1.x | ✅ |
package/docs/design.md CHANGED
@@ -140,7 +140,7 @@ opens its own **sessionless** endpoints on the shared `/api` RPC channel:
140
140
  `ctx.inject(['typert'], (t) => t.typert.register(REMOTE_CONTRIBUTION))` —
141
141
  the sanctioned manual path documented by `dsh-typert-loader` ("Manual
142
142
  `ctx.typert.register()` remains available for contributions that do not use
143
- a `./typert` artifact"). All six descriptors use `src-json` codecs (no zod
143
+ a `./typert` artifact"). All nine descriptors use `src-json` codecs (no zod
144
144
  schemas needed) with `invocation: { kind: 'direct' }`:
145
145
 
146
146
  | Endpoint | Parameters (wire) | Result |
@@ -152,6 +152,8 @@ opens its own **sessionless** endpoints on the shared `/api` RPC channel:
152
152
  | `multiFolder/browse` | `path` | `{ path, parent, home, entries, truncated }` |
153
153
  | `multiFolder/makeDir` | `parent`, `name` | `{ path, parent }` |
154
154
  | `multiFolder/listFiles` | `workspace`, `query` | `{ workspace, dirs, candidates, truncated }` |
155
+ | `multiFolder/pick` | `cwd` (plus transport cancellation) | `{ path, via }` |
156
+ | `multiFolder/reveal` | `path` | `{ path, via }` |
155
157
 
156
158
  The four configuration endpoints are keyed by workspace; `browse`/`makeDir`
157
159
  serve the plugin's own directory browser and are keyed by path instead;
@@ -330,25 +332,26 @@ window.__ModuleLoader__.load({
330
332
  'multiFolder/<op>', { args })` against the sessionless remote endpoints.
331
333
  The panel runs in either mode according to how it was opened; mutations
332
334
  and refreshes route per mode, and both modes share the same row/error UI.
333
- - Owned directory browser ("Add directory"): the plugin draws the picking
334
- interaction itself and serves it from `multiFolder/browse` +
335
- `multiFolder/makeDir` over the shared RPC channel, instead of asking the host
336
- for a picker. One interaction therefore covers every deployment, which no
337
- host picker does: `uiWorkspace.pickDirectory()` is native-only (the host
338
- answers `directory-picker/unavailable` when it composed the browse backend —
339
- a LAN bind, a remote client, a desktop shell), its
340
- `listDirectory`/`createDirectory` twins are refused under the native
341
- composition, and the shipped in-app browser is reachable only by the shell's
342
- own workspace surfaces (its `directoryFlow` holes are declared and driven by
343
- ui-workspace, not by plugins). Listing rides the **`fs` seam**
344
- (`fs.resolve` + `fs.listDir`), which every composition provides; only
345
- directories are returned, hidden entries are flagged, the level is capped at
346
- 1000 with a `truncated` flag, and paths must be fully qualified. Creation
347
- mirrors the shipped browse backend (`dsh-host-directory-picker-browse`) by
348
- calling Node's `mkdir` on a validated single segment, because the `fs` seam
349
- exposes no creation primitive. Neither endpoint touches the configuration
350
- store: choosing a level still commits through the mode's own channel
351
- (`/multi-folder add` in a session, `multiFolder/add` on the creation page).
335
+ - Directory picking: `multiFolder/pick` calls the host's `native` picker with
336
+ the RPC abort signal, then tries a host OS dialog if that picker fails.
337
+ A `browse` capability returns `unavailable` immediately, so remote clients
338
+ open the plugin's browser instead of a dialog on the host. A completed dialog
339
+ returns a path or `null` on cancellation; closing the panel aborts the request.
340
+ - The owned browser's listing rides the **`fs` seam** (`fs.resolve` +
341
+ `fs.listDir`), which every composition provides; only directories are
342
+ returned, hidden entries are flagged, the level is capped at 1000 with a
343
+ `truncated` flag, and paths must be fully qualified. Creation mirrors the
344
+ shipped browse backend (`dsh-host-directory-picker-browse`) by calling Node's
345
+ `mkdir` on a validated single segment, because the `fs` seam exposes no
346
+ creation primitive. Neither endpoint touches the configuration store: choosing
347
+ a level still commits through the mode's own channel (`/multi-folder add` in a
348
+ session, `multiFolder/add` on the creation page).
349
+ - On Windows, `lib/native-picker.ps1` uses `IFileOpenDialog` on an STA thread,
350
+ sets per-monitor DPI awareness (a build without that thread API still shows the
351
+ dialog), assists it to the foreground for a few seconds, and closes an
352
+ unanswered dialog on a deadline. It exits 0 for a selection or dismissal and
353
+ nonzero when `Show()` fails. `multiFolder/reveal` checks `fs.stat` before
354
+ opening an existing directory with the host file manager.
352
355
  - `@` source: registered through `ctx.inject(['inputTriggers'], …)` (see the
353
356
  `@` discovery section for the full decision table). It resolves the addressed
354
357
  session's workspace from the `sessions` snapshot (`byId[sessionId].cwd`), calls
package/lib/client.js CHANGED
@@ -27,17 +27,14 @@
27
27
  * (`ctx.connection.rpc.call('/api', endpoint, { args })`), keyed by workspace
28
28
  * path instead of sessionId.
29
29
  *
30
- * "Add directory" never asks the host for a picker: the plugin draws its own
31
- * browser (an editable path field, one level of child directories, an optional
32
- * "new folder") on the `multiFolder/browse` + `multiFolder/makeDir` endpoints
33
- * over the shared RPC channel. One interaction serves every deployment, which
34
- * the host's own picker cannot: `uiWorkspace.pickDirectory()` is native-only
35
- * (it answers `directory-picker/unavailable` whenever the host composed the
36
- * browse backend — a LAN bind, a remote client, a desktop shell), while its
37
- * `listDirectory`/`createDirectory` twins are refused under the native
38
- * composition. Listing this way also stays clear of the DSH Windows native
39
- * chooser worker, whose crash surfaces as `directory picker failed: … worker
40
- * exited …`.
30
+ * "Add directory" calls `multiFolder/pick`. A `native` host picker may fall back
31
+ * to an OS dialog; a `browse` capability opens this plugin's browser through
32
+ * `multiFolder/browse` and `multiFolder/makeDir`. Closing the panel cancels a
33
+ * pending pick. Dismissing a system dialog adds nothing.
34
+ *
35
+ * Every configured row also carries "open in the host file manager"
36
+ * (`multiFolder/reveal`), which puts the directory in Explorer/Finder where the
37
+ * user's own shell integrations live.
41
38
  *
42
39
  * `@` discovery for the configured directories. The shipped `@` menu is
43
40
  * SINGLE-ROOT: `ui-reference` feeds it from `remote.fileReferences.list`, whose
@@ -107,6 +104,10 @@ window.__ModuleLoader__.load({
107
104
  browseDraft: '',
108
105
  /** "new folder" name draft. */
109
106
  newDirName: '',
107
+ /** A system pick is in flight. */
108
+ pickBusy: false,
109
+ /** Muted reason shown when the system picker FAILED and the browser took over. */
110
+ notice: null,
110
111
  /** Bumped on every workspaceCache write so chips re-read their count. */
111
112
  cacheRev: 0,
112
113
  };
@@ -232,6 +233,10 @@ window.__ModuleLoader__.load({
232
233
  'browse.newDirPlaceholder': '新建文件夹名称',
233
234
  'browse.newDirCreate': '创建',
234
235
  'browse.hint': '浏览宿主机文件系统;选中的目录将作为副工作目录添加。',
236
+ 'panel.addBusy': '等待系统对话框…',
237
+ 'panel.addBrowse': '改用内置浏览器选择…',
238
+ 'panel.reveal': '在资源管理器中打开(宿主机)',
239
+ 'panel.pickFailed': '系统目录选择器调用失败,已改用内置浏览器:{message}',
235
240
  };
236
241
  /** English dictionary — checked complete against the zh key set. */
237
242
  var enDict = {
@@ -269,6 +274,10 @@ window.__ModuleLoader__.load({
269
274
  'browse.newDirPlaceholder': 'New folder name',
270
275
  'browse.newDirCreate': 'Create',
271
276
  'browse.hint': 'Browses the host filesystem; the chosen directory is added as a secondary working directory.',
277
+ 'panel.addBusy': 'Waiting for the system dialog…',
278
+ 'panel.addBrowse': 'Use the built-in browser instead…',
279
+ 'panel.reveal': 'Open in the host file manager',
280
+ 'panel.pickFailed': 'The system picker failed; using the built-in browser: {message}',
272
281
  };
273
282
 
274
283
  // ------------------------------------------------------------- plugin
@@ -297,16 +306,37 @@ window.__ModuleLoader__.load({
297
306
  * the `t` seat the renderer synthesizes from the declared
298
307
  * `locale:` namespace (which also re-renders them on locale switch). */
299
308
  var t = locale.bind(NS);
309
+ var activePick = null;
310
+
311
+ function cancelPick() {
312
+ if (!activePick) return;
313
+ activePick.abort();
314
+ activePick = null;
315
+ patch({ pickBusy: false });
316
+ }
317
+
318
+ function closePanel() {
319
+ cancelPick();
320
+ patch({ open: false });
321
+ }
322
+
323
+ ctx.effect(function () {
324
+ return function () {
325
+ var controller = activePick;
326
+ activePick = null;
327
+ if (controller) controller.abort();
328
+ };
329
+ }, 'dsh-multi-folder: cancel picker on unload');
300
330
 
301
331
  // -------------------------------------------------- sessionless RPC
302
332
  /** Call one `multiFolder/*` endpoint over the shared /api channel.
303
333
  * The Host gateway answers with the same `{ ok, value }` envelope as
304
334
  * the command remote; business errors surface as thrown Errors. */
305
- function remoteCall(endpoint, args) {
335
+ function remoteCall(endpoint, args, signal) {
306
336
  if (!connection || !connection.rpc || typeof connection.rpc.call !== 'function') {
307
337
  return Promise.reject(new Error('multi-folder: the shared RPC channel (connection service) is unavailable'));
308
338
  }
309
- return connection.rpc.call('/api', endpoint, { args: args }).then(function (envelope) {
339
+ return connection.rpc.call('/api', endpoint, { args: args }, signal).then(function (envelope) {
310
340
  if (!envelope || envelope.ok !== true) {
311
341
  var message = envelope && envelope.error !== undefined
312
342
  ? String(envelope.error.message !== undefined ? envelope.error.message : envelope.error)
@@ -382,6 +412,7 @@ window.__ModuleLoader__.load({
382
412
  * renders it as its own popover, everything else uses the overlay. */
383
413
  function openForWorkspace(workspacePath, anchor) {
384
414
  var seat = anchor || 'overlay';
415
+ cancelPick();
385
416
  patch(browserReset());
386
417
  if (!workspacePath) {
387
418
  patch({ open: true, mode: 'workspace', sessionId: null, workspace: null, dirs: [], error: null, anchor: seat });
@@ -458,11 +489,8 @@ window.__ModuleLoader__.load({
458
489
  }
459
490
 
460
491
  // -------------------------------------------- owned directory browser
461
- // "Add directory" is served entirely by this plugin (host item 8): the
462
- // browser reads levels over `multiFolder/browse` and may create one child
463
- // through `multiFolder/makeDir`, so the interaction never depends on the
464
- // host's composed directory picker — the native chooser, the browse
465
- // backend and a shell with no picker at all all behave the same here.
492
+ // The owned browser reads levels over `multiFolder/browse` and creates
493
+ // directories through `multiFolder/makeDir`.
466
494
 
467
495
  /** Read one level into the store. A blank path asks the host for its
468
496
  * home directory, so the browser always has somewhere to start. */
@@ -486,9 +514,11 @@ window.__ModuleLoader__.load({
486
514
  });
487
515
  }
488
516
 
489
- /** Open the browser at the panel's workspace (or the host home when the
490
- * panel has no workspace path yet) and read its first level. */
491
- function addDirectory() {
517
+ /** Open the OWNED browser at the panel's workspace (or the host home when
518
+ * the panel has no workspace path yet) and read its first level. Reachable
519
+ * from the panel at all times, and the automatic destination whenever the
520
+ * system picker cannot answer. */
521
+ function openOwnedBrowser() {
492
522
  var snapshot = getSnapshot();
493
523
  patch({
494
524
  browseOpen: true,
@@ -501,6 +531,35 @@ window.__ModuleLoader__.load({
501
531
  browseTo(snapshot.workspace || '');
502
532
  }
503
533
 
534
+ /** Pick a directory, or open the owned browser when no system picker answers. */
535
+ function addDirectory() {
536
+ var snapshot = getSnapshot();
537
+ if (snapshot.pickBusy) return Promise.resolve();
538
+ var controller = new AbortController();
539
+ activePick = controller;
540
+ patch({ pickBusy: true, error: null, notice: null });
541
+ return remoteCall('multiFolder/pick', { cwd: snapshot.workspace || '' }, controller.signal).then(
542
+ function (value) {
543
+ if (activePick !== controller) return undefined;
544
+ activePick = null;
545
+ patch({ pickBusy: false });
546
+ var picked = value && typeof value.path === 'string' && value.path !== '' ? value.path : null;
547
+ if (picked) return commitBrowsePath(picked);
548
+ if (value && (value.via === 'host-native' || value.via === 'os-dialog')) return undefined;
549
+ return openOwnedBrowser();
550
+ },
551
+ function (error) {
552
+ if (activePick !== controller) return undefined;
553
+ activePick = null;
554
+ patch({
555
+ pickBusy: false,
556
+ notice: t('panel.pickFailed', { message: String(error && error.message ? error.message : error) }),
557
+ });
558
+ return openOwnedBrowser();
559
+ },
560
+ );
561
+ }
562
+
504
563
  /** Leave the browser without adding anything. */
505
564
  function closeBrowser() {
506
565
  patch(browserReset());
@@ -510,7 +569,7 @@ window.__ModuleLoader__.load({
510
569
  * fresh open of the panel (header button, chip, launcher) starts there,
511
570
  * so a browser left open by a dismissed panel never comes back stale. */
512
571
  function browserReset() {
513
- return { browseOpen: false, browse: null, browseBusy: false, browseError: null, newDirName: '' };
572
+ return { browseOpen: false, browse: null, browseBusy: false, browseError: null, newDirName: '', notice: null };
514
573
  }
515
574
 
516
575
  /** Adopt one path as a secondary working directory through the channel
@@ -553,6 +612,7 @@ window.__ModuleLoader__.load({
553
612
  function openFor(sessionId) {
554
613
  if (!sessionId) return;
555
614
  var cached = sessionCache[sessionId];
615
+ cancelPick();
556
616
  patch(browserReset());
557
617
  patch({
558
618
  open: true,
@@ -594,7 +654,7 @@ window.__ModuleLoader__.load({
594
654
  var current = getSnapshot();
595
655
  var isOpen = current.open && current.sessionId === sessionId;
596
656
  if (isOpen) {
597
- patch({ open: false });
657
+ closePanel();
598
658
  } else {
599
659
  openFor(sessionId);
600
660
  }
@@ -688,7 +748,7 @@ window.__ModuleLoader__.load({
688
748
  {
689
749
  type: 'button',
690
750
  title: t('title.close'),
691
- onClick: function () { closeBrowser(); patch({ open: false }); },
751
+ onClick: function () { closePanel(); },
692
752
  style: { padding: '2px 8px', borderRadius: 6, border: '1px solid transparent', background: 'transparent', color: TOKEN.inkMuted, cursor: 'pointer' },
693
753
  },
694
754
  '✕',
@@ -753,6 +813,9 @@ window.__ModuleLoader__.load({
753
813
  store.browseError
754
814
  ? React.createElement('div', { style: { marginBottom: 8, color: TOKEN.danger, whiteSpace: 'pre-wrap', fontSize: 12 } }, String(store.browseError))
755
815
  : null,
816
+ store.notice
817
+ ? React.createElement('div', { style: { marginBottom: 8, color: TOKEN.inkMuted, whiteSpace: 'pre-wrap', fontSize: 11 } }, String(store.notice))
818
+ : null,
756
819
  React.createElement(
757
820
  'div',
758
821
  { style: { maxHeight: 200, overflowY: 'auto', marginBottom: 6 } },
@@ -835,6 +898,22 @@ window.__ModuleLoader__.load({
835
898
  },
836
899
  dir,
837
900
  ),
901
+ React.createElement(
902
+ 'button',
903
+ {
904
+ type: 'button',
905
+ title: t('panel.reveal'),
906
+ disabled: !usable,
907
+ onClick: function () {
908
+ patch({ error: null });
909
+ remoteCall('multiFolder/reveal', { path: dir }).catch(function (e) {
910
+ patch({ error: String(e && e.message ? e.message : e) });
911
+ });
912
+ },
913
+ style: { padding: '2px 6px', borderRadius: 6, border: '1px solid transparent', background: 'transparent', color: TOKEN.inkMuted, cursor: 'pointer' },
914
+ },
915
+ '📂',
916
+ ),
838
917
  React.createElement(
839
918
  'button',
840
919
  {
@@ -864,7 +943,7 @@ window.__ModuleLoader__.load({
864
943
  {
865
944
  type: 'button',
866
945
  title: t('title.close'),
867
- onClick: function () { patch({ open: false }); },
946
+ onClick: function () { closePanel(); },
868
947
  style: { padding: '2px 8px', borderRadius: 6, border: '1px solid transparent', background: 'transparent', color: TOKEN.inkMuted, cursor: 'pointer' },
869
948
  },
870
949
  '✕',
@@ -896,7 +975,7 @@ window.__ModuleLoader__.load({
896
975
  'button',
897
976
  {
898
977
  type: 'button',
899
- disabled: !!store.busy || !usable,
978
+ disabled: !!store.busy || !!store.pickBusy || !usable,
900
979
  onClick: function () { addDirectory(); },
901
980
  style: {
902
981
  flex: 1,
@@ -905,11 +984,11 @@ window.__ModuleLoader__.load({
905
984
  border: '1px solid ' + TOKEN.border,
906
985
  background: TOKEN.fill,
907
986
  color: TOKEN.ink,
908
- cursor: store.busy || !usable ? 'default' : 'pointer',
909
- opacity: store.busy || !usable ? 0.6 : 1,
987
+ cursor: store.busy || store.pickBusy || !usable ? 'default' : 'pointer',
988
+ opacity: store.busy || store.pickBusy || !usable ? 0.6 : 1,
910
989
  },
911
990
  },
912
- store.busy ? t('panel.adding') : t('panel.add'),
991
+ store.busy ? t('panel.adding') : (store.pickBusy ? t('panel.addBusy') : t('panel.add')),
913
992
  ),
914
993
  React.createElement(
915
994
  'button',
@@ -936,6 +1015,26 @@ window.__ModuleLoader__.load({
936
1015
  t('panel.refresh'),
937
1016
  ),
938
1017
  ),
1018
+ React.createElement(
1019
+ 'button',
1020
+ {
1021
+ type: 'button',
1022
+ disabled: !!store.busy || !!store.pickBusy || !usable,
1023
+ onClick: function () { openOwnedBrowser(); },
1024
+ style: {
1025
+ marginBottom: 6,
1026
+ padding: 0,
1027
+ border: '1px solid transparent',
1028
+ background: 'transparent',
1029
+ color: TOKEN.inkMuted,
1030
+ fontSize: 11,
1031
+ textAlign: 'left',
1032
+ cursor: store.busy || store.pickBusy || !usable ? 'default' : 'pointer',
1033
+ opacity: store.busy || store.pickBusy || !usable ? 0.5 : 1,
1034
+ },
1035
+ },
1036
+ t('panel.addBrowse'),
1037
+ ),
939
1038
  React.createElement(
940
1039
  'div',
941
1040
  {
@@ -1030,7 +1129,7 @@ window.__ModuleLoader__.load({
1030
1129
  /** Click-outside catcher for an open popover. */
1031
1130
  function Backdrop() {
1032
1131
  return React.createElement('div', {
1033
- onClick: function () { patch({ open: false }); },
1132
+ onClick: function () { closePanel(); },
1034
1133
  style: { position: 'fixed', inset: 0, zIndex: 30 },
1035
1134
  });
1036
1135
  }
@@ -1201,7 +1300,7 @@ window.__ModuleLoader__.load({
1201
1300
  label: count !== null && count > 0 ? t('label.withCount', { count: count }) : t('label'),
1202
1301
  onClick: function () {
1203
1302
  if (open) {
1204
- patch({ open: false });
1303
+ closePanel();
1205
1304
  } else {
1206
1305
  openForWorkspace(workspacePath, 'dock');
1207
1306
  }
@@ -1315,7 +1414,7 @@ window.__ModuleLoader__.load({
1315
1414
  label: count !== null && count > 0 ? t('label.withCount', { count: count }) : t('label'),
1316
1415
  onClick: function () {
1317
1416
  if (open) {
1318
- patch({ open: false });
1417
+ closePanel();
1319
1418
  } else {
1320
1419
  openForWorkspace(workspacePath, 'extras');
1321
1420
  }
package/lib/index.js CHANGED
@@ -68,19 +68,10 @@
68
68
  * <secondary>` launched from the primary workspace cannot write the
69
69
  * repo) gets a workdir-fix hint attached as an additional context at
70
70
  * the `tools/post-execute` boundary.
71
- * 8. Owned directory browser: "Add directory" opens a browser drawn by the
72
- * client half and served by `browse`/`makeDir` above. Listing rides the
73
- * `fs` seam (`fs.listDir`), which — unlike the host's directory-picker
74
- * seam — is composed in EVERY deployment, so one interaction covers the
75
- * native-chooser composition, the browse composition (LAN / remote
76
- * clients / desktop shells) and shells that compose no picker at all.
77
- * The framework offers no plugin-facing alternative: `uiWorkspace`'s
78
- * `pickDirectory()` is native-only (it answers `directory-picker/unavailable`
79
- * under the browse composition), its `listDirectory`/`createDirectory`
80
- * twins are browse-only, and the shipped in-app browser is reachable only
81
- * by the shell's own workspace surfaces. Directory creation mirrors the
82
- * shipped browse backend, which uses Node's `mkdir` (the fs seam has no
83
- * 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.
84
75
  * 9. `@` file discovery for the configured directories (see the
85
76
  * "secondary-directory file discovery" section). The shipped `@`
86
77
  * file-reference menu is single-root — its provider walks the session cwd
@@ -92,7 +83,9 @@
92
83
  */
93
84
 
94
85
  import { mkdir } from 'node:fs/promises'
95
- 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'
96
89
  import os from 'node:os'
97
90
 
98
91
  export const name = 'dsh-multi-folder'
@@ -395,6 +388,238 @@ export function apply(ctx) {
395
388
  return { path: target, parent }
396
389
  }
397
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
+
398
623
  // ------------------------------------- secondary-directory file discovery
399
624
  // The shipped `@` file-reference menu is SINGLE-ROOT by construction: its
400
625
  // provider (`dsh-file-reference-local`) builds one `WorkspaceFileSearch` per
@@ -688,6 +913,9 @@ export function apply(ctx) {
688
913
  const remoteErrorMessage = (e) =>
689
914
  'multi-folder: ' + String(e && e.message ? e.message : e).replace(/^multi-folder:\s*/, '')
690
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
+
691
919
  const multiFolderApi = {
692
920
  async list(workspace) {
693
921
  try {
@@ -738,7 +966,48 @@ export function apply(ctx) {
738
966
  throw new Error(remoteErrorMessage(e))
739
967
  }
740
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
+ },
741
1009
  }
1010
+
742
1011
  Object.defineProperty(multiFolderApi, 'typertRemote', {
743
1012
  value: Object.freeze({
744
1013
  service: multiFolderApi,
@@ -770,6 +1039,8 @@ export function apply(ctx) {
770
1039
  remoteInvocation('browse', ['path']),
771
1040
  remoteInvocation('makeDir', ['parent', 'name']),
772
1041
  remoteInvocation('listFiles', ['workspace', 'query']),
1042
+ { ...remoteInvocation('pick', ['cwd']), cancellation: { parameter: 'signal' } },
1043
+ remoteInvocation('reveal', ['path']),
773
1044
  ],
774
1045
  }
775
1046
 
@@ -0,0 +1,290 @@
1
+ #requires -Version 5.1
2
+ <#
3
+ native-picker.ps1 -- a modern Windows folder chooser for non-GUI processes.
4
+
5
+ Why this exists: `System.Windows.Forms.FolderBrowserDialog` is the LEGACY
6
+ tree-view dialog (SHBrowseForFolder) and `System.Windows.Forms.OpenFolderDialog`
7
+ (.NET 8+) is not present in every PowerShell build, so neither can be relied on
8
+ when the goal is the SAME dialog Explorer and other apps show -- the Vista+
9
+ common item dialog (`IFileOpenDialog` with `FOS_PICKFOLDERS`). Shell helpers
10
+ such as Listary hook that dialog, not the legacy tree.
11
+
12
+ The dialog runs on a dedicated STA thread (PowerShell 7 starts MTA, where the
13
+ common dialog is not guaranteed to work), gets a bounded foreground assist
14
+ (the caller is usually a background host process, so a fresh window would
15
+ otherwise open behind the user's windows), and can auto-close after a timeout
16
+ so a request can never be blocked forever by an unanswered dialog.
17
+
18
+ Output: the selected absolute path on stdout, or NOTHING when the user
19
+ cancelled or the timeout dismissed the dialog; both of those exit 0, so the
20
+ caller can tell them from a helper that never ran. A non-zero exit therefore
21
+ means the helper itself failed (an ExecutionPolicy block, a failed Add-Type)
22
+ and must be reported instead of read as a cancellation.
23
+ #>
24
+ [CmdletBinding()]
25
+ param(
26
+ [string]$InitialDirectory = '',
27
+ [int]$TimeoutMs = 0,
28
+ [switch]$DryRun
29
+ )
30
+
31
+ $ErrorActionPreference = 'Stop'
32
+
33
+ $source = @'
34
+ using System;
35
+ using System.Runtime.InteropServices;
36
+ using System.Text;
37
+ using System.Threading;
38
+
39
+ public static class DshFolderPicker
40
+ {
41
+ [ComImport, Guid("DC1C5A9C-E88A-4DDE-A5A1-60F82A20AEF7")]
42
+ private class FileOpenDialogRCW
43
+ {
44
+ }
45
+
46
+ [ComImport, Guid("D57C7288-D4AD-4768-BE02-9D969532D960"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
47
+ private interface IFileOpenDialog
48
+ {
49
+ [PreserveSig]
50
+ int Show(IntPtr hwndParent);
51
+ void SetFileTypes(uint cFileTypes, IntPtr rgFilterSpec);
52
+ void SetFileTypeIndex(uint iFileType);
53
+ void GetFileTypeIndex(out uint piFileType);
54
+ void Advise(IntPtr pfde, out uint pdwCookie);
55
+ void Unadvise(uint dwCookie);
56
+ void SetOptions(uint fos);
57
+ void GetOptions(out uint pfos);
58
+ void SetDefaultFolder(IShellItem psi);
59
+ void SetFolder(IShellItem psi);
60
+ void GetFolder(out IShellItem ppsi);
61
+ void GetCurrentSelection(out IShellItem ppsi);
62
+ void SetFileName([MarshalAs(UnmanagedType.LPWStr)] string pszName);
63
+ void GetFileName([MarshalAs(UnmanagedType.LPWStr)] out string pszName);
64
+ void SetTitle([MarshalAs(UnmanagedType.LPWStr)] string pszTitle);
65
+ void SetOkButtonLabel([MarshalAs(UnmanagedType.LPWStr)] string pszText);
66
+ void SetFileNameLabel([MarshalAs(UnmanagedType.LPWStr)] string pszLabel);
67
+ void GetResult(out IShellItem ppsi);
68
+ void AddPlace(IShellItem psi, int fdap);
69
+ void SetDefaultExtension([MarshalAs(UnmanagedType.LPWStr)] string pszDefaultExtension);
70
+ void Close([MarshalAs(UnmanagedType.Error)] int hr);
71
+ void SetClientGuid(ref Guid guid);
72
+ void ClearClientData();
73
+ void SetFilter(IntPtr pFilter);
74
+ void GetResults(out IntPtr ppenum);
75
+ void GetSelectedItems(out IntPtr ppsai);
76
+ }
77
+
78
+ [ComImport, Guid("43826D1E-E718-42EE-BC55-A1E261C37BFE"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
79
+ private interface IShellItem
80
+ {
81
+ void BindToHandler(IntPtr pbc, ref Guid bhid, ref Guid riid, out IntPtr ppv);
82
+ void GetParent(out IShellItem ppsi);
83
+ void GetDisplayName(uint sigdnName, [MarshalAs(UnmanagedType.LPWStr)] out string ppszName);
84
+ void GetAttributes(uint sfgaoMask, out uint psfgaoAttribs);
85
+ void Compare(IShellItem psi, uint hint, out int piOrder);
86
+ }
87
+
88
+ [DllImport("shell32.dll", CharSet = CharSet.Unicode, PreserveSig = false)]
89
+ private static extern void SHCreateItemFromParsingName(
90
+ [MarshalAs(UnmanagedType.LPWStr)] string pszPath,
91
+ IntPtr pbc,
92
+ ref Guid riid,
93
+ [MarshalAs(UnmanagedType.Interface)] out IShellItem ppv);
94
+
95
+ private delegate bool EnumProc(IntPtr hWnd, IntPtr param);
96
+
97
+ [DllImport("kernel32.dll")]
98
+ private static extern uint GetCurrentProcessId();
99
+
100
+ [DllImport("user32.dll")]
101
+ private static extern bool EnumWindows(EnumProc callback, IntPtr param);
102
+
103
+ [DllImport("user32.dll")]
104
+ private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint pid);
105
+
106
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)]
107
+ private static extern int GetClassName(IntPtr hWnd, StringBuilder text, int count);
108
+
109
+ [DllImport("user32.dll")]
110
+ private static extern bool SetForegroundWindow(IntPtr hWnd);
111
+
112
+ [DllImport("user32.dll")]
113
+ private static extern bool BringWindowToTop(IntPtr hWnd);
114
+
115
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)]
116
+ private static extern bool PostMessage(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
117
+
118
+ [DllImport("user32.dll")]
119
+ private static extern IntPtr SetThreadDpiAwarenessContext(IntPtr context);
120
+
121
+ private const uint FOS_PICKFOLDERS = 0x00000020;
122
+ private const uint FOS_FORCEFILESYSTEM = 0x00000040;
123
+ private const uint FOS_PATHMUSTEXIST = 0x00000800;
124
+ private const uint SIGDN_FILESYSPATH = 0x80058000;
125
+ private const uint WM_CLOSE = 0x0010;
126
+ /** DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 / _PER_MONITOR_AWARE. */
127
+ private static readonly IntPtr PER_MONITOR_AWARE_V2 = new IntPtr(-4);
128
+ private static readonly IntPtr PER_MONITOR_AWARE = new IntPtr(-3);
129
+
130
+ /// The folder dialog is a plain #32770 window created BY THIS PROCESS, so it
131
+ /// is found by pid + class rather than by its title: a title is optional
132
+ /// (Windows then supplies its own localized one), and matching on one could
133
+ /// pick up a window that is not ours.
134
+ private static IntPtr FindOwnDialog()
135
+ {
136
+ uint me = GetCurrentProcessId();
137
+ IntPtr found = IntPtr.Zero;
138
+ EnumWindows(delegate(IntPtr hWnd, IntPtr param)
139
+ {
140
+ uint pid;
141
+ GetWindowThreadProcessId(hWnd, out pid);
142
+ if (pid != me) return true;
143
+ StringBuilder cls = new StringBuilder(64);
144
+ GetClassName(hWnd, cls, cls.Capacity);
145
+ if (cls.ToString() != "#32770") return true;
146
+ found = hWnd;
147
+ return false;
148
+ }, IntPtr.Zero);
149
+ return found;
150
+ }
151
+
152
+ public static string Pick(string initial, int timeoutMs)
153
+ {
154
+ string result = null;
155
+ Exception failure = null;
156
+ Thread thread = new Thread(delegate()
157
+ {
158
+ IntPtr previous = IntPtr.Zero;
159
+ try
160
+ {
161
+ // Render at the monitor's REAL DPI. The PowerShell host is
162
+ // DPI-UNAWARE, so an untouched dialog is drawn at 96 DPI and then
163
+ // BITMAP-STRETCHED by Windows to the display scaling (150% on the
164
+ // machine this was written on) — which is exactly the soft, fuzzy
165
+ // text users notice. Thread-level awareness is the fix that always
166
+ // applies: a process manifest may already have fixed the PROCESS
167
+ // context (SetProcessDpiAwarenessContext would then be refused), but
168
+ // a thread context may be set at any time, and the dialog is created
169
+ // on this thread.
170
+ previous = SetThreadDpiAwarenessContext(PER_MONITOR_AWARE_V2);
171
+ if (previous == IntPtr.Zero) previous = SetThreadDpiAwarenessContext(PER_MONITOR_AWARE);
172
+ }
173
+ catch
174
+ {
175
+ // A Windows build without the thread-scoped DPI API still shows the
176
+ // dialog, only bitmap-stretched at the process DPI — not worth
177
+ // failing the pick over, and an exception here would have escaped
178
+ // this thread and killed the helper outright.
179
+ }
180
+ try { result = Show(initial, timeoutMs); }
181
+ catch (Exception error) { failure = error; }
182
+ finally
183
+ {
184
+ if (previous != IntPtr.Zero) SetThreadDpiAwarenessContext(previous);
185
+ }
186
+ });
187
+ thread.SetApartmentState(ApartmentState.STA);
188
+ thread.IsBackground = true;
189
+ thread.Start();
190
+ bool finished = timeoutMs > 0
191
+ ? thread.Join(timeoutMs + 5000)
192
+ : thread.Join(Timeout.Infinite);
193
+ if (failure != null) throw failure;
194
+ return finished ? result : null;
195
+ }
196
+
197
+ private static string Show(string initial, int timeoutMs)
198
+ {
199
+ IFileOpenDialog dialog = (IFileOpenDialog)new FileOpenDialogRCW();
200
+ uint options;
201
+ dialog.GetOptions(out options);
202
+ dialog.SetOptions(options | FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_PATHMUSTEXIST);
203
+ if (!String.IsNullOrEmpty(initial))
204
+ {
205
+ try
206
+ {
207
+ Guid iid = typeof(IShellItem).GUID;
208
+ IShellItem folder;
209
+ SHCreateItemFromParsingName(initial, IntPtr.Zero, ref iid, out folder);
210
+ dialog.SetFolder(folder);
211
+ }
212
+ catch
213
+ {
214
+ // An unusable start directory is not worth failing the pick over.
215
+ }
216
+ }
217
+ // One watchdog owns both jobs, because they need the same window handle
218
+ // but NOT the same apartment: `IFileDialog::Close` cannot be called on
219
+ // the dialog from another thread (COM refuses the cross-apartment call
220
+ // and the dialog simply stayed on screen until the process died), while
221
+ // the plain Win32 calls below work from any thread. WM_CLOSE is what the
222
+ // dialog's own X button sends, so Show() returns the ordinary cancel
223
+ // HRESULT afterwards.
224
+ // `watchdog` is declared first and assigned second on purpose: a lambda
225
+ // that captures a local must find it definitely assigned when the
226
+ // delegate is created, so the single-statement form does not compile.
227
+ Timer watchdog = null;
228
+ DateTime deadline = timeoutMs > 0 ? DateTime.UtcNow.AddMilliseconds(timeoutMs) : DateTime.MinValue;
229
+ // The foreground assist is BOUNDED: it exists to bring a fresh dialog in
230
+ // front of a user whose host process is in the background, not to fight
231
+ // that user for the foreground for the dialog's whole life. Roughly three
232
+ // seconds of nudges, after which this timer only watches the deadline.
233
+ int assistTicks = 12;
234
+ int windowWaits = 240;
235
+ watchdog = new Timer(delegate(object state)
236
+ {
237
+ IntPtr window = FindOwnDialog();
238
+ if (window == IntPtr.Zero)
239
+ {
240
+ // A dialog that never appears leaves nothing to watch.
241
+ if (--windowWaits <= 0 && watchdog != null) watchdog.Dispose();
242
+ return;
243
+ }
244
+ if (deadline != DateTime.MinValue && DateTime.UtcNow >= deadline)
245
+ {
246
+ PostMessage(window, WM_CLOSE, IntPtr.Zero, IntPtr.Zero);
247
+ if (watchdog != null) watchdog.Dispose();
248
+ return;
249
+ }
250
+ if (assistTicks > 0)
251
+ {
252
+ SetForegroundWindow(window);
253
+ BringWindowToTop(window);
254
+ assistTicks--;
255
+ }
256
+ }, null, 250, 250);
257
+ int hr;
258
+ try { hr = dialog.Show(IntPtr.Zero); }
259
+ finally
260
+ {
261
+ if (watchdog != null) watchdog.Dispose();
262
+ }
263
+ if (hr != 0)
264
+ {
265
+ // 0x800704C7 (HRESULT_FROM_WIN32(ERROR_CANCELLED)) is the ordinary
266
+ // dismiss path; every other HRESULT means the dialog could not be
267
+ // shown at all, which the caller needs to hear about.
268
+ if (hr == unchecked((int)0x800704C7)) return null;
269
+ throw new COMException("dsh-native-picker: dialog returned 0x" + hr.ToString("X8"), hr);
270
+ }
271
+ IShellItem item;
272
+ dialog.GetResult(out item);
273
+ string path;
274
+ item.GetDisplayName(SIGDN_FILESYSPATH, out path);
275
+ return path;
276
+ }
277
+ }
278
+ '@
279
+
280
+ if ($DryRun) {
281
+ # Compile only: proves the helper builds in this PowerShell without showing UI.
282
+ if (-not ('DshFolderPicker' -as [type])) { Add-Type -TypeDefinition $source -Language CSharp }
283
+ Write-Output 'compiled'
284
+ exit 0
285
+ }
286
+
287
+ if (-not ('DshFolderPicker' -as [type])) { Add-Type -TypeDefinition $source -Language CSharp }
288
+ $selected = [DshFolderPicker]::Pick($InitialDirectory, $TimeoutMs)
289
+ if ($selected) { Write-Output $selected }
290
+ exit 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-multi-folder",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "DeepSeek Harness plugin: secondary working directories for a project. The agent keeps the primary workspace as cwd, gains equal write/exec permissions on configured secondary directories under workspace-write mode, and is notified of configuration changes at the next message boundary. Configurable from the session header AND from the session-creation page (before the first message) through a sessionless multiFolder remote API.",
5
5
  "keywords": [
6
6
  "dsh-plugin",
@@ -42,6 +42,8 @@
42
42
  "compatibility": {
43
43
  "node": ">=20",
44
44
  "dshReleases": {
45
+ "0.2.1-alpha.2": "compatible",
46
+ "0.2.1-alpha.1": "compatible",
45
47
  "0.2.0-rc.1": "compatible",
46
48
  "0.1.7-rc.2": "compatible",
47
49
  "0.1.6-alpha.1": "compatible",