dsh-multi-folder 0.2.4 → 0.3.0

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
@@ -49,7 +49,7 @@ A Multi-folder button appears in the session header, and a second entry appears
49
49
 
50
50
  | Action | Behavior |
51
51
  | ------ | -------- |
52
- | Add directory | Opens the native directory picker |
52
+ | Add directory | Opens the plugin's own directory browser (path field, one level of child directories, optional new folder) |
53
53
  | Remove / refresh | Applies immediately |
54
54
  | Switch session | The panel auto-switches to that session's directories |
55
55
  | Reopen panel | Uses the per-session cache — no redundant command rows |
@@ -82,7 +82,8 @@ When a shell run ends in such a denial and references a configured secondary dir
82
82
  - **Prompt injection** — one ordered `systemPrompt` section with a text provider evaluated per assembly, rendering only for sessions whose workspace has configured directories.
83
83
  - **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.
84
84
  - **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).
85
- - **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.
85
+ - **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.
86
+ - **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`).
86
87
  - **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.
87
88
 
88
89
  ## Project layout
@@ -90,8 +91,8 @@ When a shell run ends in such a denial and references a configured secondary dir
90
91
  | Path | Purpose |
91
92
  | ---- | ------- |
92
93
  | `cordis.patch.yml` | Profile patch layer inserting the `dsh-multi-folder` row |
93
- | `lib/index.js` | Host plugin: config store, tool-pipeline interception, prompt injection, dual-channel notifications, `/multi-folder` command, sessionless `multiFolder/*` remote API |
94
- | `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) |
94
+ | `lib/index.js` | Host plugin: config store, tool-pipeline interception, prompt injection, dual-channel notifications, `/multi-folder` command, sessionless `multiFolder/*` remote API (configuration plus `browse`/`makeDir` for the owned browser) |
95
+ | `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" |
95
96
  | `test/` | Runtime-free behavior tests (see Development) |
96
97
  | `docs/` | Design and analysis documents |
97
98
 
package/README.zh.md CHANGED
@@ -49,7 +49,7 @@ dsh plugin --profile web add dsh-multi-folder
49
49
 
50
50
  | 操作 | 行为 |
51
51
  | ---- | ---- |
52
- | 添加目录 | 打开原生目录选择器 |
52
+ | 添加目录 | 打开插件自带的目录浏览器(路径输入框 + 一级子目录列表 + 可新建文件夹) |
53
53
  | 移除 / 刷新 | 立即生效 |
54
54
  | 切换会话 | 面板自动切换为该会话的副工作目录 |
55
55
  | 重新打开面板 | 使用会话级缓存,不产生冗余命令行 |
@@ -82,7 +82,8 @@ Agent 无需任何额外操作:`read` / `glob` / `grep` 随处可用;`write`
82
82
  - **提示词注入**——一个有序 `systemPrompt` 段落,text provider 每次组装按会话求值,仅为配置了副目录的会话渲染。
83
83
  - **通知**——命令处理器仅在目录集合实际变化时置位 pending notice;`agent/pre-step`(前置注入进入批次)与 `tools/post-execute`(附加为 `additionalContexts`)两个通道中先触发者消费——均使用框架原生的插件来源 `notice` 上下文。
84
84
  - **配置与安全边界**——per-workspace 配置存储于 Agent 沙箱之外的宿主自有目录(`<DSH_HOME>/storages/multi-folder/<workspace-key>.json`)。对配置文件的任何直接 `write`/`edit` 都会收到显式拒绝——**Agent 永远无法自我授予目录,配置权仅属于用户**。详见 [SECURITY.md](SECURITY.md)。
85
- - **无会话远程 API**——经 `ctx.typert.register` 注册 `multiFolder` 命名空间(手写 `src-json` 描述符),并以普通对象服务 `multiFolder` 提供;`list`/`add`/`remove`/`set` 以工作区**路径**为键,与 `/multi-folder` 命令共享同一套校验核心,因此会话尚未建立时创建页也能直接配置。
85
+ - **无会话远程 API**——经 `ctx.typert.register` 注册 `multiFolder` 命名空间(手写 `src-json` 描述符),并以普通对象服务 `multiFolder` 提供;`list`/`add`/`remove`/`set` 以工作区**路径**为键,与 `/multi-folder` 命令共享同一套校验核心,因此会话尚未建立时创建页也能直接配置。`browse`/`makeDir` 属于同一命名空间:它们只服务插件自带的目录浏览器,不触碰配置存储。
86
+ - **自带目录浏览器**——「添加目录」由插件自己绘制、经 `browse`/`makeDir` 提供服务,因此在任何部署下行为一致:宿主使用原生选择器的组合、使用 browse 后端的组合(局域网/远程客户端、桌面壳),以及完全没有选择器的壳。列目录走宿主 `fs` seam(`fs.resolve` + `fs.listDir`)。
86
87
  - **客户端**——手写维护的 factory bundle(`window.__ModuleLoader__.load`),无需构建工具链;面板经两条通道驱动宿主:会话内走 Remote BFF(`ctx.remote.commands.execute`),无会话端点走共享 `/api` RPC 通道(`ctx.connection.rpc.call`)。
87
88
 
88
89
  ## 目录结构
@@ -90,8 +91,8 @@ Agent 无需任何额外操作:`read` / `glob` / `grep` 随处可用;`write`
90
91
  | 路径 | 作用 |
91
92
  | ---- | ---- |
92
93
  | `cordis.patch.yml` | profile patch 层,插入 `dsh-multi-folder` 行 |
93
- | `lib/index.js` | 宿主插件:配置存储、工具流水线拦截、提示词注入、双通道通知、`/multi-folder` 命令、无会话 `multiFolder/*` 远程 API |
94
- | `lib/client.js` | 客户端插件(factory bundle):会话头部按钮 + 覆盖层面板 + 会话创建页入口(输入框上方的 dock 胶囊 / 上游 hero chip / 右下角兜底浮动按钮) |
94
+ | `lib/index.js` | 宿主插件:配置存储、工具流水线拦截、提示词注入、双通道通知、`/multi-folder` 命令、无会话 `multiFolder/*` 远程 API(配置端点 + 自带浏览器用的 `browse`/`makeDir`) |
95
+ | `lib/client.js` | 客户端插件(factory bundle):会话头部按钮 + 覆盖层面板 + 会话创建页入口(输入框上方的 dock 胶囊 / 上游 hero chip / 右下角兜底浮动按钮)+「添加目录」背后的自带目录浏览器 |
95
96
  | `test/` | 免 DSH 运行时的行为测试(见开发) |
96
97
  | `docs/` | 设计与分析文档 |
97
98
 
package/docs/design.md CHANGED
@@ -14,8 +14,8 @@ agent informed. No new tools are added.
14
14
 
15
15
  | Half | File | Role |
16
16
  | ---- | ---- | ---- |
17
- | Host | `lib/index.js` | Config store, tool-pipeline interception, prompt section, notifications, `/multi-folder` command, sessionless `multiFolder/*` remote API |
18
- | Client | `lib/client.js` | Session-header button + overlay panel; session-creation page entry (input-dock chip, upstream hero chip, or fixed fallback launcher — one at a time), all driving the host through the Remote BFF / shared RPC channel |
17
+ | Host | `lib/index.js` | Config store, tool-pipeline interception, prompt section, notifications, `/multi-folder` command, sessionless `multiFolder/*` remote API (configuration plus the browser's `browse`/`makeDir`) |
18
+ | Client | `lib/client.js` | Session-header button + overlay panel; session-creation page entry (input-dock chip, upstream hero chip, or fixed fallback launcher — one at a time), all driving the host through the Remote BFF / shared RPC channel; the owned directory browser behind "Add directory" |
19
19
 
20
20
  The package declares both faces: `dsh.bundle.patch` (the host row inserted by
21
21
  `cordis.patch.yml`) and `dsh.client` (the web bundle at `exports["./client"]`).
@@ -120,7 +120,7 @@ opens its own **sessionless** endpoints on the shared `/api` RPC channel:
120
120
  `ctx.inject(['typert'], (t) => t.typert.register(REMOTE_CONTRIBUTION))` —
121
121
  the sanctioned manual path documented by `dsh-typert-loader` ("Manual
122
122
  `ctx.typert.register()` remains available for contributions that do not use
123
- a `./typert` artifact"). All four descriptors use `src-json` codecs (no zod
123
+ a `./typert` artifact"). All six descriptors use `src-json` codecs (no zod
124
124
  schemas needed) with `invocation: { kind: 'direct' }`:
125
125
 
126
126
  | Endpoint | Parameters (wire) | Result |
@@ -129,6 +129,11 @@ opens its own **sessionless** endpoints on the shared `/api` RPC channel:
129
129
  | `multiFolder/add` | `workspace`, `path` | `{ workspace, dirs, changed }` |
130
130
  | `multiFolder/remove` | `workspace`, `path` | `{ workspace, dirs, changed }` |
131
131
  | `multiFolder/set` | `workspace`, `dirs` | `{ workspace, dirs, changed }` |
132
+ | `multiFolder/browse` | `path` | `{ path, parent, home, entries, truncated }` |
133
+ | `multiFolder/makeDir` | `parent`, `name` | `{ path, parent }` |
134
+
135
+ The four configuration endpoints are keyed by workspace; the last two serve
136
+ the plugin's own directory browser and are keyed by path instead.
132
137
 
133
138
  The workspace argument is a **path**, not a session id; the client derives
134
139
  it from the workspaces store (`WorkspaceView.path`). Business errors throw
@@ -208,6 +213,25 @@ window.__ModuleLoader__.load({
208
213
  'multiFolder/<op>', { args })` against the sessionless remote endpoints.
209
214
  The panel runs in either mode according to how it was opened; mutations
210
215
  and refreshes route per mode, and both modes share the same row/error UI.
216
+ - Owned directory browser ("Add directory"): the plugin draws the picking
217
+ interaction itself and serves it from `multiFolder/browse` +
218
+ `multiFolder/makeDir` over the shared RPC channel, instead of asking the host
219
+ for a picker. One interaction therefore covers every deployment, which no
220
+ host picker does: `uiWorkspace.pickDirectory()` is native-only (the host
221
+ answers `directory-picker/unavailable` when it composed the browse backend —
222
+ a LAN bind, a remote client, a desktop shell), its
223
+ `listDirectory`/`createDirectory` twins are refused under the native
224
+ composition, and the shipped in-app browser is reachable only by the shell's
225
+ own workspace surfaces (its `directoryFlow` holes are declared and driven by
226
+ ui-workspace, not by plugins). Listing rides the **`fs` seam**
227
+ (`fs.resolve` + `fs.listDir`), which every composition provides; only
228
+ directories are returned, hidden entries are flagged, the level is capped at
229
+ 1000 with a `truncated` flag, and paths must be fully qualified. Creation
230
+ mirrors the shipped browse backend (`dsh-host-directory-picker-browse`) by
231
+ calling Node's `mkdir` on a validated single segment, because the `fs` seam
232
+ exposes no creation primitive. Neither endpoint touches the configuration
233
+ store: choosing a level still commits through the mode's own channel
234
+ (`/multi-folder add` in a session, `multiFolder/add` on the creation page).
211
235
  - Session switch: a `React.useEffect` on `sessionId` re-points the open panel
212
236
  to the current session (reusing the per-session cache) — this also folds a
213
237
  workspace-mode panel back into session mode once the first message creates
@@ -349,7 +373,12 @@ canonicalization, the config guard, both notification channels, notice
349
373
  gating, command flows, the panel's session-switch/caching behavior, the
350
374
  sessionless remote contribution shape and behavior (list/add/set/remove,
351
375
  idempotence, sanitization, error prefixing, cross-channel cache coherence),
352
- and the hero/workspace-mode client flows. The client test's `slots.inject` mock
376
+ the owned browser's host half (`browse` filtering/sorting/hidden flags, the
377
+ fully-qualified-path fence, `makeDir` segment validation against a real
378
+ temporary directory, and that neither endpoint writes the config store),
379
+ and the hero/workspace-mode client flows — including the browser's own client
380
+ flow (open at the panel workspace, enter a level, commit through the mode's
381
+ channel, create a child and enter it, and return without a remote call). The client test's `slots.inject` mock
353
382
  is declaration-aware like the real service (a wait fires only while its slot is
354
383
  declared, and a collapse disposes the registration), so it covers all three
355
384
  session-creation seats: the dock chip on an rc.6-style shell (registration
package/lib/client.js CHANGED
@@ -27,6 +27,18 @@
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 …`.
41
+ *
30
42
  * Layout shape: an entry in the session header action row, one chip row above
31
43
  * the composer card on the session-creation page, and a frame-wide overlay
32
44
  * panel. Every surface reads one tiny module-scoped store; opening the panel
@@ -74,6 +86,16 @@ window.__ModuleLoader__.load({
74
86
  heroClaims: [],
75
87
  /** Which surface owns the open panel: 'overlay' | 'dock' | 'extras'. */
76
88
  anchor: 'overlay',
89
+ /** The owned directory browser (the "Add directory" interaction). */
90
+ browseOpen: false,
91
+ /** The level on display: { path, parent, home, entries, truncated } | null. */
92
+ browse: null,
93
+ browseBusy: false,
94
+ browseError: null,
95
+ /** Editable path field, seeded from the level on display. */
96
+ browseDraft: '',
97
+ /** "new folder" name draft. */
98
+ newDirName: '',
77
99
  /** Bumped on every workspaceCache write so chips re-read their count. */
78
100
  cacheRev: 0,
79
101
  };
@@ -186,6 +208,19 @@ window.__ModuleLoader__.load({
186
208
  'panel.remove': '移除',
187
209
  'panel.refresh': '刷新',
188
210
  'panel.footnote': 'Agent 的主工作目录不变;在 Workspace Write 模式下,Agent 对上述目录拥有与主工作目录同等的读写与命令执行权限。配置变更会在下一条消息或工具调用结束时通知 Agent。',
211
+ 'browse.title': '选择目录',
212
+ 'browse.pathLabel': '目录路径',
213
+ 'browse.go': '前往',
214
+ 'browse.up': '↑ 上级',
215
+ 'browse.use': '选择此目录',
216
+ 'browse.back': '← 返回',
217
+ 'browse.loading': '读取中…',
218
+ 'browse.empty': '此目录下没有子目录。',
219
+ 'browse.truncated': '仅显示前 {count} 个子目录。',
220
+ 'browse.enter': '进入此目录',
221
+ 'browse.newDirPlaceholder': '新建文件夹名称',
222
+ 'browse.newDirCreate': '创建',
223
+ 'browse.hint': '浏览宿主机文件系统;选中的目录将作为副工作目录添加。',
189
224
  };
190
225
  /** English dictionary — checked complete against the zh key set. */
191
226
  var enDict = {
@@ -210,17 +245,29 @@ window.__ModuleLoader__.load({
210
245
  'panel.remove': 'Remove',
211
246
  'panel.refresh': 'Refresh',
212
247
  'panel.footnote': "The agent's primary working directory stays unchanged; under Workspace Write mode the agent has the same read/write and command-execution rights on the listed directories as on the primary workspace. Configuration changes are announced at the next message or tool-call boundary.",
248
+ 'browse.title': 'Choose a directory',
249
+ 'browse.pathLabel': 'Directory path',
250
+ 'browse.go': 'Go',
251
+ 'browse.up': '↑ Up',
252
+ 'browse.use': 'Use this directory',
253
+ 'browse.back': '← Back',
254
+ 'browse.loading': 'Loading…',
255
+ 'browse.empty': 'No subdirectories here.',
256
+ 'browse.truncated': 'Showing the first {count} subdirectories.',
257
+ 'browse.enter': 'Enter this directory',
258
+ 'browse.newDirPlaceholder': 'New folder name',
259
+ 'browse.newDirCreate': 'Create',
260
+ 'browse.hint': 'Browses the host filesystem; the chosen directory is added as a secondary working directory.',
213
261
  };
214
262
 
215
263
  // ------------------------------------------------------------- plugin
216
264
  var name = 'dsh-multi-folder';
217
- var inject = ['remote', 'remote.commands', 'slots', 'workspaces', 'uiWorkspace', 'connection', 'sessions', 'locale'];
265
+ var inject = ['remote', 'remote.commands', 'slots', 'workspaces', 'connection', 'sessions', 'locale'];
218
266
 
219
267
  function apply(ctx) {
220
268
  var slots = ctx.slots;
221
269
  var remote = ctx.remote;
222
270
  var workspaces = ctx.workspaces;
223
- var uiWorkspace = ctx.uiWorkspace;
224
271
  var connection = ctx.connection;
225
272
  var sessions = ctx.sessions;
226
273
  var locale = ctx.locale;
@@ -324,6 +371,7 @@ window.__ModuleLoader__.load({
324
371
  * renders it as its own popover, everything else uses the overlay. */
325
372
  function openForWorkspace(workspacePath, anchor) {
326
373
  var seat = anchor || 'overlay';
374
+ patch(browserReset());
327
375
  if (!workspacePath) {
328
376
  patch({ open: true, mode: 'workspace', sessionId: null, workspace: null, dirs: [], error: null, anchor: seat });
329
377
  return;
@@ -398,28 +446,94 @@ window.__ModuleLoader__.load({
398
446
  });
399
447
  }
400
448
 
449
+ // -------------------------------------------- owned directory browser
450
+ // "Add directory" is served entirely by this plugin (host item 8): the
451
+ // browser reads levels over `multiFolder/browse` and may create one child
452
+ // through `multiFolder/makeDir`, so the interaction never depends on the
453
+ // host's composed directory picker — the native chooser, the browse
454
+ // backend and a shell with no picker at all all behave the same here.
455
+
456
+ /** Read one level into the store. A blank path asks the host for its
457
+ * home directory, so the browser always has somewhere to start. */
458
+ function browseTo(path) {
459
+ patch({ browseBusy: true, browseError: null });
460
+ return remoteCall('multiFolder/browse', { path: typeof path === 'string' ? path : '' }).then(function (value) {
461
+ var level = value && typeof value === 'object'
462
+ ? value
463
+ : { path: '', parent: null, home: '', entries: [], truncated: false };
464
+ patch({
465
+ browseBusy: false,
466
+ browse: level,
467
+ browseDraft: level.path,
468
+ browseError: null,
469
+ newDirName: '',
470
+ });
471
+ return level;
472
+ }).catch(function (e) {
473
+ patch({ browseBusy: false, browseError: String(e && e.message ? e.message : e) });
474
+ return null;
475
+ });
476
+ }
477
+
478
+ /** Open the browser at the panel's workspace (or the host home when the
479
+ * panel has no workspace path yet) and read its first level. */
401
480
  function addDirectory() {
402
- // DSH 0.1.2 moved the directory picker onto `uiWorkspace`; earlier
403
- // shells kept it on `workspaces`. Pick whichever service actually
404
- // exposes it (and call it with the right receiver), surfacing a clear
405
- // error when neither does instead of throwing synchronously.
406
- var picker = (uiWorkspace && typeof uiWorkspace.pickDirectory === 'function')
407
- ? uiWorkspace
408
- : (workspaces && typeof workspaces.pickDirectory === 'function') ? workspaces : null;
409
- if (picker === null) {
410
- patch({ error: 'multi-folder: the directory picker service is unavailable' });
411
- return;
481
+ var snapshot = getSnapshot();
482
+ patch({
483
+ browseOpen: true,
484
+ browse: null,
485
+ browseBusy: false,
486
+ browseError: null,
487
+ browseDraft: snapshot.workspace || '',
488
+ newDirName: '',
489
+ });
490
+ browseTo(snapshot.workspace || '');
491
+ }
492
+
493
+ /** Leave the browser without adding anything. */
494
+ function closeBrowser() {
495
+ patch(browserReset());
496
+ }
497
+
498
+ /** The store fields that put the panel back on its directory list. Any
499
+ * fresh open of the panel (header button, chip, launcher) starts there,
500
+ * so a browser left open by a dismissed panel never comes back stale. */
501
+ function browserReset() {
502
+ return { browseOpen: false, browse: null, browseBusy: false, browseError: null, newDirName: '' };
503
+ }
504
+
505
+ /** Adopt one path as a secondary working directory through the channel
506
+ * the panel already uses: the `/multi-folder add` command inside a
507
+ * session, the sessionless `multiFolder/add` endpoint on the
508
+ * session-creation page. The browser stays open when the Host refused,
509
+ * so the error sits beside the path the user was looking at. */
510
+ function commitBrowsePath(path) {
511
+ var snapshot = getSnapshot();
512
+ var settled;
513
+ if (snapshot.sessionId) {
514
+ settled = mutate(snapshot.sessionId, '/multi-folder add "' + String(path).replace(/"/g, '\\"') + '"');
515
+ } else if (snapshot.workspace) {
516
+ settled = mutateWorkspace(snapshot.workspace, 'multiFolder/add', { path: path });
517
+ } else {
518
+ return Promise.resolve();
412
519
  }
413
- picker.pickDirectory().then(function (path) {
414
- if (path === null || path === undefined) return;
415
- var snapshot = getSnapshot();
416
- if (snapshot.sessionId) {
417
- mutate(snapshot.sessionId, '/multi-folder add "' + path.replace(/"/g, '\\"') + '"');
418
- } else if (snapshot.workspace) {
419
- mutateWorkspace(snapshot.workspace, 'multiFolder/add', { path: path });
420
- }
520
+ return Promise.resolve(settled).then(function () {
521
+ if (!getSnapshot().error) closeBrowser();
522
+ });
523
+ }
524
+
525
+ /** Create one child directory and enter it. */
526
+ function createBrowseDir() {
527
+ var snapshot = getSnapshot();
528
+ var name = String(snapshot.newDirName || '').trim();
529
+ if (!snapshot.browse || name === '') return Promise.resolve();
530
+ patch({ browseBusy: true, browseError: null });
531
+ return remoteCall('multiFolder/makeDir', { parent: snapshot.browse.path, name: name }).then(function (created) {
532
+ patch({ browseBusy: false, newDirName: '' });
533
+ return browseTo(created && created.path ? created.path : snapshot.browse.path);
421
534
  }).catch(function (e) {
422
- patch({ error: String(e && e.message ? e.message : e) });
535
+ patch({ browseBusy: false, browseError: String(e && e.message ? e.message : e) });
536
+ return null;
423
537
  });
424
538
  }
425
539
 
@@ -428,6 +542,7 @@ window.__ModuleLoader__.load({
428
542
  function openFor(sessionId) {
429
543
  if (!sessionId) return;
430
544
  var cached = sessionCache[sessionId];
545
+ patch(browserReset());
431
546
  patch({
432
547
  open: true,
433
548
  mode: 'session',
@@ -491,6 +606,191 @@ window.__ModuleLoader__.load({
491
606
  return btn;
492
607
  }
493
608
 
609
+ /** The owned browser, rendered in place of the directory list: an
610
+ * editable path field, up/use/back actions, one level of child
611
+ * directories, and the optional "new folder" row. Returned as an ARRAY
612
+ * for the same reason `panelBody` is. */
613
+ function browseBody(store, t) {
614
+ var level = store.browse;
615
+ var busy = !!store.busy || !!store.browseBusy;
616
+ var entries = level && Array.isArray(level.entries) ? level.entries : null;
617
+ var inputStyle = {
618
+ flex: 1,
619
+ minWidth: 0,
620
+ padding: '4px 8px',
621
+ borderRadius: 6,
622
+ border: '1px solid ' + TOKEN.border,
623
+ background: 'transparent',
624
+ color: TOKEN.ink,
625
+ fontSize: 12,
626
+ fontFamily: 'inherit',
627
+ };
628
+ var smallButton = function (extra) {
629
+ return Object.assign({
630
+ padding: '4px 10px',
631
+ borderRadius: 6,
632
+ border: '1px solid ' + TOKEN.border,
633
+ background: 'transparent',
634
+ color: TOKEN.ink,
635
+ fontSize: 12,
636
+ fontFamily: 'inherit',
637
+ }, extra || {});
638
+ };
639
+ var rows = entries === null ? null : entries.map(function (entry, index) {
640
+ return React.createElement(
641
+ 'button',
642
+ {
643
+ key: index,
644
+ type: 'button',
645
+ title: t('browse.enter'),
646
+ disabled: busy,
647
+ onClick: function () { browseTo(entry.path); },
648
+ style: {
649
+ display: 'block',
650
+ width: '100%',
651
+ textAlign: 'left',
652
+ padding: '5px 8px',
653
+ marginBottom: 4,
654
+ borderRadius: 6,
655
+ border: '1px solid transparent',
656
+ background: TOKEN.subtle,
657
+ color: entry.hidden ? TOKEN.inkMuted : TOKEN.ink,
658
+ cursor: busy ? 'default' : 'pointer',
659
+ fontFamily: 'inherit',
660
+ fontSize: 12,
661
+ overflow: 'hidden',
662
+ textOverflow: 'ellipsis',
663
+ whiteSpace: 'nowrap',
664
+ opacity: busy ? 0.6 : 1,
665
+ },
666
+ },
667
+ '📁 ' + entry.name,
668
+ );
669
+ });
670
+ return [
671
+ React.createElement(
672
+ 'div',
673
+ { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 } },
674
+ React.createElement('div', { style: { fontWeight: 600, fontSize: 14, color: TOKEN.ink } }, t('browse.title')),
675
+ React.createElement(
676
+ 'button',
677
+ {
678
+ type: 'button',
679
+ title: t('title.close'),
680
+ onClick: function () { closeBrowser(); patch({ open: false }); },
681
+ style: { padding: '2px 8px', borderRadius: 6, border: '1px solid transparent', background: 'transparent', color: TOKEN.inkMuted, cursor: 'pointer' },
682
+ },
683
+ '✕',
684
+ ),
685
+ ),
686
+ React.createElement(
687
+ 'div',
688
+ { style: { display: 'flex', gap: 6, marginBottom: 6 } },
689
+ React.createElement('input', {
690
+ type: 'text',
691
+ value: store.browseDraft,
692
+ placeholder: t('browse.pathLabel'),
693
+ spellCheck: false,
694
+ onChange: function (event) { patch({ browseDraft: event.target.value }); },
695
+ onKeyDown: function (event) { if (event.key === 'Enter') browseTo(store.browseDraft); },
696
+ style: inputStyle,
697
+ }),
698
+ React.createElement(
699
+ 'button',
700
+ {
701
+ type: 'button',
702
+ disabled: busy,
703
+ onClick: function () { browseTo(store.browseDraft); },
704
+ style: smallButton({ background: TOKEN.fill, cursor: busy ? 'default' : 'pointer', opacity: busy ? 0.6 : 1 }),
705
+ },
706
+ t('browse.go'),
707
+ ),
708
+ ),
709
+ React.createElement(
710
+ 'div',
711
+ { style: { display: 'flex', gap: 6, marginBottom: 8 } },
712
+ React.createElement(
713
+ 'button',
714
+ {
715
+ type: 'button',
716
+ disabled: busy || !level || !level.parent,
717
+ onClick: function () { if (level && level.parent) browseTo(level.parent); },
718
+ style: smallButton({ cursor: busy || !level || !level.parent ? 'default' : 'pointer', opacity: busy || !level || !level.parent ? 0.5 : 1 }),
719
+ },
720
+ t('browse.up'),
721
+ ),
722
+ React.createElement(
723
+ 'button',
724
+ {
725
+ type: 'button',
726
+ disabled: busy || !level,
727
+ onClick: function () { if (level) commitBrowsePath(level.path); },
728
+ style: smallButton({ flex: 1, border: '1px solid ' + TOKEN.accent, background: TOKEN.fill, cursor: busy || !level ? 'default' : 'pointer', opacity: busy || !level ? 0.6 : 1 }),
729
+ },
730
+ t('browse.use'),
731
+ ),
732
+ React.createElement(
733
+ 'button',
734
+ {
735
+ type: 'button',
736
+ onClick: function () { closeBrowser(); },
737
+ style: smallButton({ border: '1px solid transparent', color: TOKEN.inkMuted, cursor: 'pointer' }),
738
+ },
739
+ t('browse.back'),
740
+ ),
741
+ ),
742
+ store.browseError
743
+ ? React.createElement('div', { style: { marginBottom: 8, color: TOKEN.danger, whiteSpace: 'pre-wrap', fontSize: 12 } }, String(store.browseError))
744
+ : null,
745
+ React.createElement(
746
+ 'div',
747
+ { style: { maxHeight: 200, overflowY: 'auto', marginBottom: 6 } },
748
+ rows === null
749
+ ? (store.browseError
750
+ ? null
751
+ : React.createElement('div', { style: { color: TOKEN.inkMuted, fontSize: 12 } }, t('browse.loading')))
752
+ : (rows.length > 0
753
+ ? rows
754
+ : React.createElement('div', { style: { color: TOKEN.inkMuted, fontSize: 12 } }, t('browse.empty'))),
755
+ ),
756
+ level && level.truncated
757
+ ? React.createElement(
758
+ 'div',
759
+ { style: { marginBottom: 6, color: TOKEN.inkMuted, fontSize: 11 } },
760
+ t('browse.truncated', { count: entries ? entries.length : 0 }),
761
+ )
762
+ : null,
763
+ React.createElement(
764
+ 'div',
765
+ { style: { display: 'flex', gap: 6, marginBottom: 8 } },
766
+ React.createElement('input', {
767
+ type: 'text',
768
+ value: store.newDirName,
769
+ placeholder: t('browse.newDirPlaceholder'),
770
+ spellCheck: false,
771
+ onChange: function (event) { patch({ newDirName: event.target.value }); },
772
+ onKeyDown: function (event) { if (event.key === 'Enter') createBrowseDir(); },
773
+ style: inputStyle,
774
+ }),
775
+ React.createElement(
776
+ 'button',
777
+ {
778
+ type: 'button',
779
+ disabled: busy || String(store.newDirName || '').trim() === '',
780
+ onClick: function () { createBrowseDir(); },
781
+ style: smallButton({ background: TOKEN.fill, cursor: busy ? 'default' : 'pointer', opacity: busy || String(store.newDirName || '').trim() === '' ? 0.5 : 1 }),
782
+ },
783
+ t('browse.newDirCreate'),
784
+ ),
785
+ ),
786
+ React.createElement(
787
+ 'div',
788
+ { style: { fontSize: 11, lineHeight: 1.5, color: TOKEN.inkMuted } },
789
+ t('browse.hint'),
790
+ ),
791
+ ];
792
+ }
793
+
494
794
  // Panel body ---------------------------------------------------------
495
795
  /** The panel's children, shared verbatim by the overlay panel and the
496
796
  * chip popover. Returned as an ARRAY so each wrapper can spread it as
@@ -498,6 +798,9 @@ window.__ModuleLoader__.load({
498
798
  function panelBody(store, t) {
499
799
  var sessionMode = store.mode === 'session';
500
800
  var usable = sessionMode || !!store.workspace;
801
+ // The owned browser replaces the directory list while it is open; the
802
+ // panel header and its placement stay the same.
803
+ if (store.browseOpen) return browseBody(store, t);
501
804
  var rows = (store.dirs || []).map(function (dir, index) {
502
805
  return React.createElement(
503
806
  'div',
package/lib/index.js CHANGED
@@ -56,16 +56,32 @@
56
56
  * — where no session exists yet — can read and edit the configuration
57
57
  * directly. The `/multi-folder` command and the remote methods share
58
58
  * one core so validation, canonicalization, and the config guard are
59
- * identical on both channels.
59
+ * identical on both channels. `browse` and `makeDir` ride the same
60
+ * namespace: they serve the plugin's own directory browser (see 8) rather
61
+ * than the configuration store.
60
62
  * 7. Failure diagnosis: a `pwsh`/`bash` run that ends in an OS-level
61
63
  * `Permission denied` touching a secondary working directory (the ACL
62
64
  * runner confines each process tree to ONE writable root, so `git -C
63
65
  * <secondary>` launched from the primary workspace cannot write the
64
66
  * repo) gets a workdir-fix hint attached as an additional context at
65
67
  * 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).
66
81
  */
67
82
 
68
- import { join } from 'node:path'
83
+ import { mkdir } from 'node:fs/promises'
84
+ import { join, posix, win32 } from 'node:path'
69
85
  import os from 'node:os'
70
86
 
71
87
  export const name = 'dsh-multi-folder'
@@ -248,6 +264,97 @@ export function apply(ctx) {
248
264
  return { workspace: ws, dirs: [...next], changed }
249
265
  }
250
266
 
267
+ // ------------------------------------------------- owned directory picker
268
+ // The plugin owns its picking interaction (see the file header, item 8):
269
+ // one in-app browser over these two endpoints, so "Add directory" behaves
270
+ // identically under every host picker composition instead of depending on
271
+ // the native-only `uiWorkspace.pickDirectory()` service.
272
+
273
+ /** Complete-result bound of one listing level (mirrors the shipped browse backend). */
274
+ const MAX_BROWSE_ENTRIES = 1000
275
+
276
+ /**
277
+ * Whether a path names one fixed filesystem location regardless of process
278
+ * state: POSIX-absolute on POSIX; on Windows only drive-qualified (`C:\…`)
279
+ * or complete UNC (`\\server\share…`) forms. Rooted drive-less forms
280
+ * (`\foo`, `/foo`) are `isAbsolute` yet still resolve against the host
281
+ * process's current drive, so they are refused rather than rebased.
282
+ */
283
+ const fullyQualified = (path) =>
284
+ process.platform === 'win32'
285
+ ? win32.isAbsolute(path) && /^(?:[A-Za-z]:[\\/]|[\\/]{2}[^\\/]+[\\/]+[^\\/]+)/.test(path)
286
+ : posix.isAbsolute(path)
287
+
288
+ /** The path flavour one fully qualified path belongs to (flavour, never the host platform). */
289
+ const pathApiFor = (path) => (/^[A-Za-z]:[\\/]|^\\\\/.test(path) ? win32 : posix)
290
+
291
+ /** The parent level of one absolute path, or null at a filesystem root. */
292
+ const parentOf = (path) => {
293
+ const parent = pathApiFor(path).dirname(path)
294
+ return parent === path ? null : parent
295
+ }
296
+
297
+ /**
298
+ * List the direct subdirectories of one level for the plugin's browser.
299
+ * An absent/blank path lists the host user's home directory. Only
300
+ * directories are returned (a file is not addable as a secondary working
301
+ * directory), hidden entries are flagged for the client to style, and the
302
+ * level is capped with a `truncated` flag.
303
+ * @param path - fully qualified directory path, or nothing for the home directory.
304
+ * @returns the level's canonical path, its parent, the home directory, the child directories, and the cap state.
305
+ */
306
+ const coreBrowse = async (path) => {
307
+ const requested = typeof path === 'string' && path.trim().length > 0 ? path.trim() : os.homedir()
308
+ if (!fullyQualified(requested)) {
309
+ throw new Error('browse requires a fully qualified path, got "' + requested + '"')
310
+ }
311
+ const target = await fs.resolve(requested)
312
+ const absolute = fs.processPath(target)
313
+ let children
314
+ try {
315
+ children = await fs.listDir(target)
316
+ } catch (e) {
317
+ throw new Error('cannot list "' + absolute + '": ' + String(e && e.message ? e.message : e))
318
+ }
319
+ const api = pathApiFor(absolute)
320
+ const entries = []
321
+ for (const child of children ?? []) {
322
+ if (child === null || child === undefined || child.type !== 'directory') continue
323
+ const entryName = String(child.name)
324
+ entries.push({ name: entryName, path: api.join(absolute, entryName), hidden: entryName.startsWith('.') })
325
+ }
326
+ entries.sort((a, b) => a.name.localeCompare(b.name))
327
+ const truncated = entries.length > MAX_BROWSE_ENTRIES
328
+ if (truncated) entries.length = MAX_BROWSE_ENTRIES
329
+ return { path: absolute, parent: parentOf(absolute), home: os.homedir(), entries, truncated }
330
+ }
331
+
332
+ /**
333
+ * Create one child directory for the plugin's browser. Mirrors the shipped
334
+ * browse backend: the parent must be fully qualified, the name one path
335
+ * segment, and the creation non-recursive (the browser shows the parent, so
336
+ * a missing level is a real failure, not a level to invent).
337
+ * @param parent - fully qualified existing parent directory.
338
+ * @param name - single path segment name.
339
+ * @returns the created directory's canonical path.
340
+ */
341
+ const coreMakeDir = async (parent, name) => {
342
+ if (typeof parent !== 'string' || !fullyQualified(parent)) {
343
+ throw new Error('makeDir requires a fully qualified parent path')
344
+ }
345
+ if (typeof name !== 'string' || name.trim() === '' || name === '.' || name === '..' || /[/\\]/.test(name)) {
346
+ throw new Error('makeDir requires a single path segment name')
347
+ }
348
+ const target = pathApiFor(parent).join(parent, name)
349
+ try {
350
+ await mkdir(target)
351
+ } catch (e) {
352
+ if (e && e.code === 'EEXIST') throw new Error('"' + target + '" already exists')
353
+ throw new Error('cannot create "' + target + '": ' + String(e && e.message ? e.message : e))
354
+ }
355
+ return { path: target, parent }
356
+ }
357
+
251
358
  // ----------------------------------------------- sessionless remote API
252
359
  // `multiFolder/*` endpoints over the Typert gateway. Hand-written
253
360
  // `src-json` descriptors registered through ctx.typert.register (the
@@ -287,6 +394,20 @@ export function apply(ctx) {
287
394
  throw new Error(remoteErrorMessage(e))
288
395
  }
289
396
  },
397
+ async browse(path) {
398
+ try {
399
+ return await coreBrowse(path)
400
+ } catch (e) {
401
+ throw new Error(remoteErrorMessage(e))
402
+ }
403
+ },
404
+ async makeDir(parent, name) {
405
+ try {
406
+ return await coreMakeDir(parent, name)
407
+ } catch (e) {
408
+ throw new Error(remoteErrorMessage(e))
409
+ }
410
+ },
290
411
  }
291
412
  Object.defineProperty(multiFolderApi, 'typertRemote', {
292
413
  value: Object.freeze({
@@ -316,6 +437,8 @@ export function apply(ctx) {
316
437
  remoteInvocation('add', ['workspace', 'path']),
317
438
  remoteInvocation('remove', ['workspace', 'path']),
318
439
  remoteInvocation('set', ['workspace', 'dirs']),
440
+ remoteInvocation('browse', ['path']),
441
+ remoteInvocation('makeDir', ['parent', 'name']),
319
442
  ],
320
443
  }
321
444
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-multi-folder",
3
- "version": "0.2.4",
3
+ "version": "0.3.0",
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.0-rc.1": "compatible",
46
+ "0.1.7-rc.2": "compatible",
45
47
  "0.1.6-alpha.1": "compatible",
46
48
  "0.1.2-alpha.5": "compatible",
47
49
  "0.1.2-alpha.4": "compatible",