dsh-multi-folder 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,6 +17,7 @@ A [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) plugin bun
17
17
  - The directory list is **injected into the system prompt** and re-rendered per session assembly.
18
18
  - Configuration changes notify the agent through a **non-interrupting message queue** — delivered at the next message boundary (user send or tool-call end), and **only when the directory set actually changed**.
19
19
  - Configurable **before the session starts**: the session-creation page (new-session screen) offers a Multi-folder entry that reads and edits the same per-workspace configuration through a **sessionless remote API** (`multiFolder/*` endpoints) — no session id required.
20
+ - The **`@` file menu finds files in the configured directories**. The shipped `@` menu only ever searches the primary workspace, so this plugin contributes its own `@` group listing the secondary directories — headed by each directory.
20
21
  - **No new tools.** Everything is a framework-level change (tool-pipeline interception) plus a UI-level change (a session-scoped header entry).
21
22
 
22
23
  ## Requirements
@@ -49,7 +50,8 @@ A Multi-folder button appears in the session header, and a second entry appears
49
50
 
50
51
  | Action | Behavior |
51
52
  | ------ | -------- |
52
- | 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 |
53
55
  | Remove / refresh | Applies immediately |
54
56
  | Switch session | The panel auto-switches to that session's directories |
55
57
  | Reopen panel | Uses the per-session cache — no redundant command rows |
@@ -65,6 +67,21 @@ Equivalent slash command for the user:
65
67
 
66
68
  The agent needs nothing extra: `read` / `glob` / `grep` work everywhere, and `write` / `edit` / `pwsh` / `bash` are intercepted and re-rooted automatically when the target path (or `workdir`) falls inside a configured secondary directory.
67
69
 
70
+ ### Finding secondary files with `@`
71
+
72
+ Typing `@` in the composer offers the usual primary-workspace files **and** a group per configured secondary directory, fed by the plugin's own `multiFolder/listFiles` endpoint:
73
+
74
+ | You type | You get |
75
+ | -------- | ------- |
76
+ | `@` | one row per configured directory — the entry points |
77
+ | `@probe` | any matching file or directory across every configured directory, headed by its directory |
78
+ | `@secondary-spike/src/` | that level, listed (directories first) |
79
+ | `@D:/repos/other/src/` | the same level by its absolute spelling |
80
+
81
+ Picking a row inserts an **absolute-path** mention (`@D:/repos/other/src/main.ts`), because a secondary directory lies outside the primary workspace and no relative path from it can reach one. Directory rows offer the usual `Tab` drill to descend.
82
+
83
+ This is a **companion group, not a change to the shipped menu**: DSH's own `@` provider searches the session workspace only, and it is left exactly as it is. A workspace with no secondary directories configured behaves precisely as before.
84
+
68
85
  ## Permission model
69
86
 
70
87
  Each confined command runs under **exactly ONE writable root** — the workspace root the call is re-rooted to (the Windows ACL runner grants a single workspace write SID per process tree). Consequences:
@@ -82,8 +99,9 @@ When a shell run ends in such a denial and references a configured secondary dir
82
99
  - **Prompt injection** — one ordered `systemPrompt` section with a text provider evaluated per assembly, rendering only for sessions whose workspace has configured directories.
83
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.
84
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).
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`).
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`.
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.
87
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.
88
106
 
89
107
  ## Project layout
@@ -91,8 +109,9 @@ When a shell run ends in such a denial and references a configured secondary dir
91
109
  | Path | Purpose |
92
110
  | ---- | ------- |
93
111
  | `cordis.patch.yml` | Profile patch layer inserting the `dsh-multi-folder` row |
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" |
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 |
96
115
  | `test/` | Runtime-free behavior tests (see Development) |
97
116
  | `docs/` | Design and analysis documents |
98
117
 
package/README.zh.md CHANGED
@@ -17,6 +17,7 @@
17
17
  - 目录列表**注入系统提示词**,每次组装按会话求值;
18
18
  - 配置变更通过**不打断的消息队列**通知 Agent——在下一次消息边界(用户发送或工具调用结束)送达,且**仅在目录集合实际变化时**发送;
19
19
  - **会话开始前即可配置**:会话创建页(新会话界面)提供「多工作目录」入口(英文界面显示 "Multi-folder"),通过**无会话远程 API**(`multiFolder/*` 端点)读写同一份 per-workspace 配置——无需 session id;
20
+ - **`@` 文件菜单能搜到副目录里的文件**:DSH 自带的 `@` 菜单只在主工作区内检索,因此本插件额外贡献自己的 `@` 分组,按目录成组列出副工作目录中的文件;
20
21
  - **不新增任何工具**:改动全部位于框架级(工具流水线拦截)与 UI 级(会话级头部入口)。
21
22
 
22
23
  ## 环境要求
@@ -49,7 +50,8 @@ dsh plugin --profile web add dsh-multi-folder
49
50
 
50
51
  | 操作 | 行为 |
51
52
  | ---- | ---- |
52
- | 添加目录 | 打开插件自带的目录浏览器(路径输入框 + 一级子目录列表 + 可新建文件夹) |
53
+ | 添加目录 | `native` 使用系统文件夹对话框,失败后尝试宿主对话框;`browse` 直接打开插件自带浏览器(路径输入框 + 一级子目录列表 + 可新建文件夹) |
54
+ | 在文件管理器中打开 | 在宿主机器的文件管理器(资源管理器 / Finder)中打开某个已配置目录 |
53
55
  | 移除 / 刷新 | 立即生效 |
54
56
  | 切换会话 | 面板自动切换为该会话的副工作目录 |
55
57
  | 重新打开面板 | 使用会话级缓存,不产生冗余命令行 |
@@ -65,6 +67,21 @@ dsh plugin --profile web add dsh-multi-folder
65
67
 
66
68
  Agent 无需任何额外操作:`read` / `glob` / `grep` 随处可用;`write` / `edit` / `pwsh` / `bash` 在路径(或 `workdir`)落入副目录时自动拦截并以该目录为沙箱根执行。
67
69
 
70
+ ### 用 `@` 查找副目录文件
71
+
72
+ 在输入框输入 `@`,除主工作区文件外,还会按每个已配置的副工作目录显示一个分组——数据来自插件自己的 `multiFolder/listFiles` 端点:
73
+
74
+ | 输入 | 结果 |
75
+ | ---- | ---- |
76
+ | `@` | 每个已配置目录一行——即入口 |
77
+ | `@probe` | 所有副目录中匹配的文件与目录,按其所在目录分组显示 |
78
+ | `@secondary-spike/src/` | 列出该层级(目录优先) |
79
+ | `@D:/repos/other/src/` | 用绝对路径写出同一层级 |
80
+
81
+ 选中一行会插入**绝对路径**引用(`@D:/repos/other/src/main.ts`)——因为副目录位于主工作区之外,从主工作区出发的任何相对路径都无法到达。目录行同样支持 `Tab` 逐层下钻。
82
+
83
+ 这是一个**并列分组,而不是对自带菜单的改动**:DSH 自己的 `@` provider 只检索会话工作区,本插件完全不改它。未配置任何副目录的工作区行为与从前完全一致。
84
+
68
85
  ## 权限模型
69
86
 
70
87
  每条受沙箱约束的命令只拥有**唯一一个可写根**——即本次调用被换根到的那个目录(Windows ACL runner 为每个进程树只授予一个工作区写 SID)。由此:
@@ -82,8 +99,9 @@ Agent 无需任何额外操作:`read` / `glob` / `grep` 随处可用;`write`
82
99
  - **提示词注入**——一个有序 `systemPrompt` 段落,text provider 每次组装按会话求值,仅为配置了副目录的会话渲染。
83
100
  - **通知**——命令处理器仅在目录集合实际变化时置位 pending notice;`agent/pre-step`(前置注入进入批次)与 `tools/post-execute`(附加为 `additionalContexts`)两个通道中先触发者消费——均使用框架原生的插件来源 `notice` 上下文。
84
101
  - **配置与安全边界**——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` 命令共享同一套校验核心,因此会话尚未建立时创建页也能直接配置。`browse`/`makeDir` 属于同一命名空间:它们只服务插件自带的目录浏览器,不触碰配置存储。
86
- - **自带目录浏览器**——「添加目录」由插件自己绘制、经 `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`。
104
+ - **`@` 发现**——同一 `fs` seam 的第二种用法:`multiFolder/listFiles` 为已配置目录建立索引(广度优先、按规范化路径去重以免 junction 绕回、排除生成物/依赖目录名、按工作区限量并短 TTL 缓存),客户端再据此注册一个并列的 `@` source。自带 provider 不做任何修改,自带分组也不受影响:触发器注册表以 `(trigger, name)` 为键,每个 source 各渲染一个分组。
87
105
  - **客户端**——手写维护的 factory bundle(`window.__ModuleLoader__.load`),无需构建工具链;面板经两条通道驱动宿主:会话内走 Remote BFF(`ctx.remote.commands.execute`),无会话端点走共享 `/api` RPC 通道(`ctx.connection.rpc.call`)。
88
106
 
89
107
  ## 目录结构
@@ -91,8 +109,9 @@ Agent 无需任何额外操作:`read` / `glob` / `grep` 随处可用;`write`
91
109
  | 路径 | 作用 |
92
110
  | ---- | ---- |
93
111
  | `cordis.patch.yml` | profile patch 层,插入 `dsh-multi-folder` 行 |
94
- | `lib/index.js` | 宿主插件:配置存储、工具流水线拦截、提示词注入、双通道通知、`/multi-folder` 命令、无会话 `multiFolder/*` 远程 API(配置端点 + 自带浏览器用的 `browse`/`makeDir`) |
95
- | `lib/client.js` | 客户端插件(factory bundle):会话头部按钮 + 覆盖层面板 + 会话创建页入口(输入框上方的 dock 胶囊 / 上游 hero chip / 右下角兜底浮动按钮)+「添加目录」背后的自带目录浏览器 |
112
+ | `lib/index.js` | 宿主插件:配置、工具拦截、远程 API、目录选择器和文件管理器 |
113
+ | `lib/client.js` | 客户端面板、自带浏览器和并列 `@` 来源 |
114
+ | `lib/native-picker.ps1` | Windows 文件夹选择器助手 |
96
115
  | `test/` | 免 DSH 运行时的行为测试(见开发) |
97
116
  | `docs/` | 设计与分析文档 |
98
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
@@ -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 (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" |
17
+ | Host | `lib/index.js` | Config store, tool-pipeline interception, prompt section, notifications, `/multi-folder` command, sessionless `multiFolder/*` remote API (configuration, the browser's `browse`/`makeDir`, and the `@` menu's `listFiles`) |
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"; the companion `@` source |
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"]`).
@@ -36,13 +36,15 @@ A listener on the `tools/execute` around-dispatch waterfall handles `write`, `ed
36
36
  operation directly with `{ ...standingPolicy, workspaceRoot: <secondary dir> }`:
37
37
  - `write`/`edit` → `fs.writeText` / `fs.editText`;
38
38
  - `pwsh`/`bash`, foreground → `shell.resolve({ command, workdir, dshEnv,
39
- sandboxPolicy })` + `shell.run`, with the canonical workdir so the confinement
40
- root and the process cwd agree exactly;
39
+ sandboxPolicy })` + the seam's foreground call — `shell.run(spec)` up to
40
+ 0.1.6-alpha.1, `(await shell.execute(spec)).result()` from 0.1.7-alpha.1 on —
41
+ with the canonical workdir so the confinement root and the process cwd agree
42
+ exactly;
41
43
  - `pwsh`/`bash`, background (`run_in_background: true`) → the same re-rooted
42
44
  request registered through the generic jobs runtime (`ctx.jobs`) exactly like
43
- the shipped shell tools (`kind` = tool name, `owner` = calling agent, streamed
45
+ the shipped shell tools (`kind` = tool name, `owner` = the calling session's id, streamed
44
46
  reads shaped for `job_output` with sandbox markers, terminal outcome in the
45
- `completed`/`killed`/`failed` vocabulary). `shell.start` is **async** (it
47
+ `completed`/`killed`/`failed` vocabulary). The launch is **async** (it
46
48
  publishes the handle only once launch preparation — Windows ACL grants
47
49
  included — succeeded, and rejects when preparation is cancelled or fails), so
48
50
  the launch is adapted to the jobs runtime's synchronous `run(): JobHooks`
@@ -52,6 +54,24 @@ A listener on the `tools/execute` around-dispatch waterfall handles `write`, `ed
52
54
  preparation settles the job as `failed` with the real cause, and a read before
53
55
  publication is empty. A caller-aborted call falls through to the
54
56
  default pipeline, which raises the canonical abort error.
57
+ - Both shell paths ride one version-adaptive seam (`shellUsesExecute`). DSH
58
+ **0.1.7-alpha.1** (commit `d6bebc5783`, "converge on execute()") deleted
59
+ `ShellExecutor.run` and `ShellExecutor.start` in favour of a single
60
+ `execute(spec): Promise<ShellExecution>`, and turned `JobSpec.owner` from the
61
+ calling `Agent` into its `SessionId` — `jobs-local` resolves that id through
62
+ `agents.get(id)`, so an Agent object throws
63
+ `session "[object Object]" has no live agent`. The retired calls map onto the
64
+ new seam exactly: `await shell.run(spec)` becomes
65
+ `await (await shell.execute(spec)).result()`, and `await shell.start(spec)`
66
+ becomes `await shell.execute({ ...spec, onExpiry: 'none' })` — the retired
67
+ `start` armed no deadline at all, while `resolve()` defaults `onExpiry` to
68
+ `'kill'`, so a background run that inherited the default would be killed at
69
+ the executor's timeout. One probe on the **presence of `execute`** selects the
70
+ shape (never the absence of `run`, so a release that keeps the retired methods
71
+ as shims still takes the modern path), which is what keeps every release from
72
+ 0.1.2-alpha through 0.2.0-rc.1+ served by the same code. A `ShellExecution`
73
+ *is* the `ShellProcess` the background path already consumed, so the jobs
74
+ adaptation itself needed no change.
55
75
  The result carries the same canonical value/content shapes as the shipped tools, so
56
76
  downstream presentation keeps working.
57
77
  5. Anything else — unknown tools, paths outside every secondary directory, missing
@@ -120,7 +140,7 @@ opens its own **sessionless** endpoints on the shared `/api` RPC channel:
120
140
  `ctx.inject(['typert'], (t) => t.typert.register(REMOTE_CONTRIBUTION))` —
121
141
  the sanctioned manual path documented by `dsh-typert-loader` ("Manual
122
142
  `ctx.typert.register()` remains available for contributions that do not use
123
- 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
124
144
  schemas needed) with `invocation: { kind: 'direct' }`:
125
145
 
126
146
  | Endpoint | Parameters (wire) | Result |
@@ -131,9 +151,14 @@ opens its own **sessionless** endpoints on the shared `/api` RPC channel:
131
151
  | `multiFolder/set` | `workspace`, `dirs` | `{ workspace, dirs, changed }` |
132
152
  | `multiFolder/browse` | `path` | `{ path, parent, home, entries, truncated }` |
133
153
  | `multiFolder/makeDir` | `parent`, `name` | `{ path, parent }` |
154
+ | `multiFolder/listFiles` | `workspace`, `query` | `{ workspace, dirs, candidates, truncated }` |
155
+ | `multiFolder/pick` | `cwd` (plus transport cancellation) | `{ path, via }` |
156
+ | `multiFolder/reveal` | `path` | `{ path, via }` |
134
157
 
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.
158
+ The four configuration endpoints are keyed by workspace; `browse`/`makeDir`
159
+ serve the plugin's own directory browser and are keyed by path instead;
160
+ `listFiles` serves the `@` menu (see its own section below) and is keyed by
161
+ workspace plus the live query.
137
162
 
138
163
  The workspace argument is a **path**, not a session id; the client derives
139
164
  it from the workspaces store (`WorkspaceView.path`). Business errors throw
@@ -155,6 +180,85 @@ opens its own **sessionless** endpoints on the shared `/api` RPC channel:
155
180
  does (type checks, absolute-path requirement, canonicalization, sanitization,
156
181
  primary-workspace exclusion).
157
182
 
183
+ ## Host: `@` discovery for secondary directories
184
+
185
+ ### Why this exists
186
+
187
+ DSH's `@` file menu is **single-root by construction**, not by omission. Its
188
+ provider (`@deepseek-ai/dsh-file-reference-local`) builds exactly one
189
+ `WorkspaceFileSearch` per agent from `agent.session.header.cwd`, and that
190
+ searcher refuses every candidate outside its root — a directory query resolves
191
+ against the root and answers `undefined` for a path that escapes it (`..`
192
+ check). Browser-side, `@deepseek-ai/dsh-client-ui-reference` registers the only
193
+ `@` source and forwards each query to `remote.fileReferences.list`. A configured
194
+ secondary directory is by definition outside the primary workspace, so no
195
+ amount of typing can make the shipped menu offer one.
196
+
197
+ The plugin therefore does not touch that provider at all. It publishes its own
198
+ discovery endpoint over the files it already has read access to and contributes
199
+ a companion menu group, which keeps both failure modes separate: a broken
200
+ discovery path degrades to an empty group and can never affect the shipped
201
+ files/sessions group.
202
+
203
+ ### Index and query rules
204
+
205
+ `multiFolder/listFiles(workspace, query)` rides the same sessionless remote
206
+ namespace as the browser endpoints and the same `fs` seam (`fs.resolve` +
207
+ `fs.listDir`), so it needs no session and no new capability:
208
+
209
+ | Rule | Why |
210
+ | ---- | --- |
211
+ | Breadth-first, bounded by `MAX_INDEX_ENTRIES` across all directories | one workspace's index cannot grow without limit |
212
+ | Canonical-path deduplication (`fs.resolve` + `fs.processPath` per level) | a junction/symlink pointing back up the tree cannot re-enter the walk |
213
+ | `INDEX_EXCLUDED` basenames never traversed | mirrors the shipped provider's defaults (`.git`, `node_modules`, build output); `lib` is deliberately absent there and therefore here |
214
+ | Hidden entries indexed but invisible to a global query | parity with the shipped provider: `.foo` needs an explicit `.` query |
215
+ | Per-workspace index cached with `FILE_INDEX_TTL_MS` (4 s) and a directory signature | config changes invalidate immediately; on-disk changes are caught within the TTL. Autocomplete is advisory, so staleness is invisible while rebuild cost stays bounded to one traversal per window |
216
+ | An empty query yields the configured directories themselves | the menu's entry points; `@` alone stays cheap and does not list 30 files |
217
+
218
+ Ranking mirrors the shipped provider (name beat path, directories win ties,
219
+ then shorter paths, then name order) with **one deliberate deviation**: the
220
+ path and subsequence rules read the *in-directory relative* path, never the
221
+ absolute one. Every absolute path on a host shares its prefix (`D:/…`), so
222
+ scoring it would make a one-character query match literally every candidate.
223
+ The configured directory's own basename stays searchable at the lowest rank,
224
+ which is what lets `@secondary-spike` narrow to that directory.
225
+
226
+ A query carrying `/` lists a level instead of ranking one, and accepts two
227
+ spellings — the absolute path a drill inserted, and a leading basename segment
228
+ (`@secondary-spike/src/`). An ambiguous basename (two configured directories
229
+ sharing one) resolves to nothing rather than to a guess. The decoded
230
+ in-directory path is normalized (redundant separators and `.` segments drop)
231
+ but **any `..` segment is refused**: without that, `@secondary/../../etc/` would
232
+ list a directory outside every configured root while still claiming the
233
+ configured directory as the candidates' owner — a listing that escaped the set
234
+ the user granted, wearing a name that lies about it. The shipped provider
235
+ refuses the same escape for the same reason.
236
+
237
+ ### Mention semantics
238
+
239
+ A secondary directory lies outside the workspace root, so no relative path from
240
+ that root can reach one: candidates are **absolute** paths (forward-slashed,
241
+ which is the spelling the mention grammar and the prompt guidance already use
242
+ for host paths). `formatMention` in the client half re-implements the shipped
243
+ grammar — whitespace quotes the path, a quoted directory keeps its quote open
244
+ so completion can descend, and control characters or an embedded quote make a
245
+ path unrepresentable, so that row is dropped rather than inserted broken. The
246
+ bundle is standalone (no build step) and cannot import that package's module,
247
+ hence the re-implementation.
248
+
249
+ ### Client contribution
250
+
251
+ | Decision | Reason |
252
+ | -------- | ------ |
253
+ | A **companion source** (`trigger: '@'`, `name: 'multi-folder'`), not a replacement | the registry keys sources by `(trigger, name)` and throws on a duplicate; the shipped `reference` source keeps answering untouched |
254
+ | `order: 10` | the shipped group declares no order and defaults to `0`, so the secondary group sits below it |
255
+ | `showGroupTitle: false` | the menu derives a group's title by looking its **source name** up in its own dictionary, so a visible title would read `multi-folder` — and an empty result would render that heading over an empty list |
256
+ | A `section` on every row = the directory | rows are headed by the directory they came from, and the abbreviation of a path under the host home (`~`) keeps that heading readable |
257
+ | `ctx.inject(['inputTriggers'], …)`, not a hard `inject` entry | a shell composing no trigger service must keep every panel surface; this contribution then simply never activates |
258
+ | Own `codec` (identity) | `serializeReference` resolves the owner by **source name** and rejects a codec-less owner, so the source must own one even though the mention IS the model form |
259
+ | Failures resolve to `[]` | discovery is advisory: it must never break the composer or the groups beside it |
260
+ | Queries are per keystroke, like the shipped source | the host index makes each call an in-memory rank |
261
+
158
262
  ## Host: prompt injection and notifications
159
263
 
160
264
  - One global `systemPrompt.section` (`multi-folder:secondary-dirs`, order 160) whose
@@ -192,7 +296,11 @@ window.__ModuleLoader__.load({
192
296
  - `inject: ['remote', 'remote.commands', 'slots', 'workspaces', 'connection', 'sessions', 'locale']`; the package's
193
297
  `dsh.client.inject` lists the packages providing them
194
298
  (`@deepseek-ai/dsh-api-gateway`, `@deepseek-ai/dsh-api-remotes`,
195
- `@deepseek-ai/dsh-client-connection`, `@deepseek-ai/dsh-client-locale`).
299
+ `@deepseek-ai/dsh-client-connection`, `@deepseek-ai/dsh-client-locale`,
300
+ `@deepseek-ai/dsh-client-ui-input-trigger`). The trigger registry is
301
+ deliberately **not** in that hard list: the `@` source is contributed through
302
+ `ctx.inject(['inputTriggers'], …)`, so shells without that service keep every
303
+ panel surface (see the `@` discovery section).
196
304
  - UI registrations: `conversation.session.header.actions` (session-scoped button),
197
305
  `shell.overlay` panel, `conversation.input.dock` chip row (session-scoped
198
306
  list entry above the composer card — the session-creation page's shipped
@@ -224,25 +332,33 @@ window.__ModuleLoader__.load({
224
332
  'multiFolder/<op>', { args })` against the sessionless remote endpoints.
225
333
  The panel runs in either mode according to how it was opened; mutations
226
334
  and refreshes route per mode, and both modes share the same row/error UI.
227
- - Owned directory browser ("Add directory"): the plugin draws the picking
228
- interaction itself and serves it from `multiFolder/browse` +
229
- `multiFolder/makeDir` over the shared RPC channel, instead of asking the host
230
- for a picker. One interaction therefore covers every deployment, which no
231
- host picker does: `uiWorkspace.pickDirectory()` is native-only (the host
232
- answers `directory-picker/unavailable` when it composed the browse backend —
233
- a LAN bind, a remote client, a desktop shell), its
234
- `listDirectory`/`createDirectory` twins are refused under the native
235
- composition, and the shipped in-app browser is reachable only by the shell's
236
- own workspace surfaces (its `directoryFlow` holes are declared and driven by
237
- ui-workspace, not by plugins). Listing rides the **`fs` seam**
238
- (`fs.resolve` + `fs.listDir`), which every composition provides; only
239
- directories are returned, hidden entries are flagged, the level is capped at
240
- 1000 with a `truncated` flag, and paths must be fully qualified. Creation
241
- mirrors the shipped browse backend (`dsh-host-directory-picker-browse`) by
242
- calling Node's `mkdir` on a validated single segment, because the `fs` seam
243
- exposes no creation primitive. Neither endpoint touches the configuration
244
- store: choosing a level still commits through the mode's own channel
245
- (`/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.
355
+ - `@` source: registered through `ctx.inject(['inputTriggers'], …)` (see the
356
+ `@` discovery section for the full decision table). It resolves the addressed
357
+ session's workspace from the `sessions` snapshot (`byId[sessionId].cwd`), calls
358
+ `multiFolder/listFiles` over the shared RPC channel, and projects each
359
+ candidate onto a row — a `section` naming its directory, an in-directory
360
+ `description`, and a `value` carrying the mention the pick inserts. Directory
361
+ rows set `drill`, so the shipped drill gesture descends a level.
246
362
  - Session switch: a `React.useEffect` on `sessionId` re-points the open panel
247
363
  to the current session (reusing the per-session cache) — this also folds a
248
364
  workspace-mode panel back into session mode once the first message creates
@@ -375,6 +491,34 @@ window.__ModuleLoader__.load({
375
491
  business validation server-side. DSH versions that change the Typert
376
492
  registry contract would need this contribution revisited (the tests assert
377
493
  the descriptor shape).
494
+ - `@` discovery is a **companion group, never a merged list**. The shipped
495
+ provider stays single-root, so a secondary file appears under the plugin's
496
+ own group and its mention is an ABSOLUTE path — a relative one could not
497
+ reach outside the workspace root. Consequences worth knowing: the workspace
498
+ file list and the secondary list are ranked separately (the shipped group's
499
+ relevance order never mixes with ours, and only the first 30 secondary
500
+ candidates of a query are offered), a session whose workspace has no
501
+ configured directories gains an empty group that renders nothing, and the
502
+ index is advisory by design — it is rebuilt lazily (4 s TTL) rather than
503
+ invalidated on every tool result, so a file created in a secondary directory
504
+ can take a few seconds to appear in the menu.
505
+ - A **symlinked or junctioned level inside a configured directory is indexed and
506
+ listed through the link**, where the shipped provider skips symlinked
507
+ directories. The fs seam's `listDir` reports only an entry's `type`, so a link
508
+ is not distinguishable from a directory without a second canonicalization per
509
+ child — and rejecting on that basis would also reject junctions that are
510
+ ordinary project structure (and a secondary directory reached through a
511
+ junctioned ancestor). The walk stays bounded regardless: canonical-path
512
+ deduplication prevents a link from re-entering it, and `MAX_INDEX_ENTRIES`
513
+ caps it. Reads are unfenced in DSH, so this grants no access the plugin's own
514
+ directory browser does not already have.
515
+ - The `@` group depends on the trigger registry's source contract
516
+ (`trigger`/`name`/`order`/`showGroupTitle`, `candidates`/`onPick`/`codec`,
517
+ candidate `section`/`icon`/`drill`, and the `(trigger, name)` uniqueness key).
518
+ A DSH release that changes that contract would need this contribution
519
+ revisited; because the registration rides `ctx.inject`, a release that drops
520
+ the service entirely degrades to "no `@` group" instead of breaking the
521
+ plugin's panel.
378
522
 
379
523
  ## Tests
380
524
 
@@ -397,3 +541,14 @@ shape, in-flow row, hero-only visibility, RPC routing, anchored popover), the
397
541
  upstream hero chip taking over the moment its slot is declared, and the fixed
398
542
  launcher returning once both declarations collapse — asserting at each step that
399
543
  the other two surfaces stand down.
544
+
545
+ `@` discovery is covered on both halves against a fake secondary tree behind the
546
+ `fs` seam: the host test asserts the empty-query entry points, bare-fragment
547
+ ranking, directory-basename narrowing, level listing by both spellings,
548
+ recursion, hidden-entry visibility, `node_modules` exclusion, index caching
549
+ inside the TTL, the workspace fence, config-store isolation, and that clearing
550
+ the configuration withdraws the surface; the client test asserts the source's
551
+ identity/order/`showGroupTitle`, its RPC routing and keying, row projection
552
+ (section heading, `~` abbreviation, in-directory description, drill flag,
553
+ folder icon), quote handling, the pick inserts, the identity codec, and that
554
+ both an unrepresentable path and an RPC failure degrade quietly.