dsh-multi-folder 0.3.1 → 0.3.2

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
@@ -65,6 +66,21 @@ Equivalent slash command for the user:
65
66
 
66
67
  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
68
 
69
+ ### Finding secondary files with `@`
70
+
71
+ 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:
72
+
73
+ | You type | You get |
74
+ | -------- | ------- |
75
+ | `@` | one row per configured directory — the entry points |
76
+ | `@probe` | any matching file or directory across every configured directory, headed by its directory |
77
+ | `@secondary-spike/src/` | that level, listed (directories first) |
78
+ | `@D:/repos/other/src/` | the same level by its absolute spelling |
79
+
80
+ 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.
81
+
82
+ 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.
83
+
68
84
  ## Permission model
69
85
 
70
86
  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:
@@ -84,6 +100,7 @@ When a shell run ends in such a denial and references a configured secondary dir
84
100
  - **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
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.
86
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`).
103
+ - **`@` 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
104
  - **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
105
 
89
106
  ## Project layout
@@ -91,8 +108,8 @@ When a shell run ends in such a denial and references a configured secondary dir
91
108
  | Path | Purpose |
92
109
  | ---- | ------- |
93
110
  | `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" |
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 |
96
113
  | `test/` | Runtime-free behavior tests (see Development) |
97
114
  | `docs/` | Design and analysis documents |
98
115
 
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
  ## 环境要求
@@ -65,6 +66,21 @@ dsh plugin --profile web add dsh-multi-folder
65
66
 
66
67
  Agent 无需任何额外操作:`read` / `glob` / `grep` 随处可用;`write` / `edit` / `pwsh` / `bash` 在路径(或 `workdir`)落入副目录时自动拦截并以该目录为沙箱根执行。
67
68
 
69
+ ### 用 `@` 查找副目录文件
70
+
71
+ 在输入框输入 `@`,除主工作区文件外,还会按每个已配置的副工作目录显示一个分组——数据来自插件自己的 `multiFolder/listFiles` 端点:
72
+
73
+ | 输入 | 结果 |
74
+ | ---- | ---- |
75
+ | `@` | 每个已配置目录一行——即入口 |
76
+ | `@probe` | 所有副目录中匹配的文件与目录,按其所在目录分组显示 |
77
+ | `@secondary-spike/src/` | 列出该层级(目录优先) |
78
+ | `@D:/repos/other/src/` | 用绝对路径写出同一层级 |
79
+
80
+ 选中一行会插入**绝对路径**引用(`@D:/repos/other/src/main.ts`)——因为副目录位于主工作区之外,从主工作区出发的任何相对路径都无法到达。目录行同样支持 `Tab` 逐层下钻。
81
+
82
+ 这是一个**并列分组,而不是对自带菜单的改动**:DSH 自己的 `@` provider 只检索会话工作区,本插件完全不改它。未配置任何副目录的工作区行为与从前完全一致。
83
+
68
84
  ## 权限模型
69
85
 
70
86
  每条受沙箱约束的命令只拥有**唯一一个可写根**——即本次调用被换根到的那个目录(Windows ACL runner 为每个进程树只授予一个工作区写 SID)。由此:
@@ -84,6 +100,7 @@ Agent 无需任何额外操作:`read` / `glob` / `grep` 随处可用;`write`
84
100
  - **配置与安全边界**——per-workspace 配置存储于 Agent 沙箱之外的宿主自有目录(`<DSH_HOME>/storages/multi-folder/<workspace-key>.json`)。对配置文件的任何直接 `write`/`edit` 都会收到显式拒绝——**Agent 永远无法自我授予目录,配置权仅属于用户**。详见 [SECURITY.md](SECURITY.md)。
85
101
  - **无会话远程 API**——经 `ctx.typert.register` 注册 `multiFolder` 命名空间(手写 `src-json` 描述符),并以普通对象服务 `multiFolder` 提供;`list`/`add`/`remove`/`set` 以工作区**路径**为键,与 `/multi-folder` 命令共享同一套校验核心,因此会话尚未建立时创建页也能直接配置。`browse`/`makeDir` 属于同一命名空间:它们只服务插件自带的目录浏览器,不触碰配置存储。
86
102
  - **自带目录浏览器**——「添加目录」由插件自己绘制、经 `browse`/`makeDir` 提供服务,因此在任何部署下行为一致:宿主使用原生选择器的组合、使用 browse 后端的组合(局域网/远程客户端、桌面壳),以及完全没有选择器的壳。列目录走宿主 `fs` seam(`fs.resolve` + `fs.listDir`)。
103
+ - **`@` 发现**——同一 `fs` seam 的第二种用法:`multiFolder/listFiles` 为已配置目录建立索引(广度优先、按规范化路径去重以免 junction 绕回、排除生成物/依赖目录名、按工作区限量并短 TTL 缓存),客户端再据此注册一个并列的 `@` source。自带 provider 不做任何修改,自带分组也不受影响:触发器注册表以 `(trigger, name)` 为键,每个 source 各渲染一个分组。
87
104
  - **客户端**——手写维护的 factory bundle(`window.__ModuleLoader__.load`),无需构建工具链;面板经两条通道驱动宿主:会话内走 Remote BFF(`ctx.remote.commands.execute`),无会话端点走共享 `/api` RPC 通道(`ctx.connection.rpc.call`)。
88
105
 
89
106
  ## 目录结构
@@ -91,8 +108,8 @@ Agent 无需任何额外操作:`read` / `glob` / `grep` 随处可用;`write`
91
108
  | 路径 | 作用 |
92
109
  | ---- | ---- |
93
110
  | `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 / 右下角兜底浮动按钮)+「添加目录」背后的自带目录浏览器 |
111
+ | `lib/index.js` | 宿主插件:配置存储、工具流水线拦截、提示词注入、双通道通知、`/multi-folder` 命令、无会话 `multiFolder/*` 远程 API(配置端点 + 自带浏览器用的 `browse`/`makeDir` + `@` 菜单用的 `listFiles`) |
112
+ | `lib/client.js` | 客户端插件(factory bundle):会话头部按钮 + 覆盖层面板 + 会话创建页入口(输入框上方的 dock 胶囊 / 上游 hero chip / 右下角兜底浮动按钮)+「添加目录」背后的自带目录浏览器 + 并列的 `@` source |
96
113
  | `test/` | 免 DSH 运行时的行为测试(见开发) |
97
114
  | `docs/` | 设计与分析文档 |
98
115
 
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
@@ -131,9 +151,12 @@ 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 }` |
134
155
 
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.
156
+ The four configuration endpoints are keyed by workspace; `browse`/`makeDir`
157
+ serve the plugin's own directory browser and are keyed by path instead;
158
+ `listFiles` serves the `@` menu (see its own section below) and is keyed by
159
+ workspace plus the live query.
137
160
 
138
161
  The workspace argument is a **path**, not a session id; the client derives
139
162
  it from the workspaces store (`WorkspaceView.path`). Business errors throw
@@ -155,6 +178,85 @@ opens its own **sessionless** endpoints on the shared `/api` RPC channel:
155
178
  does (type checks, absolute-path requirement, canonicalization, sanitization,
156
179
  primary-workspace exclusion).
157
180
 
181
+ ## Host: `@` discovery for secondary directories
182
+
183
+ ### Why this exists
184
+
185
+ DSH's `@` file menu is **single-root by construction**, not by omission. Its
186
+ provider (`@deepseek-ai/dsh-file-reference-local`) builds exactly one
187
+ `WorkspaceFileSearch` per agent from `agent.session.header.cwd`, and that
188
+ searcher refuses every candidate outside its root — a directory query resolves
189
+ against the root and answers `undefined` for a path that escapes it (`..`
190
+ check). Browser-side, `@deepseek-ai/dsh-client-ui-reference` registers the only
191
+ `@` source and forwards each query to `remote.fileReferences.list`. A configured
192
+ secondary directory is by definition outside the primary workspace, so no
193
+ amount of typing can make the shipped menu offer one.
194
+
195
+ The plugin therefore does not touch that provider at all. It publishes its own
196
+ discovery endpoint over the files it already has read access to and contributes
197
+ a companion menu group, which keeps both failure modes separate: a broken
198
+ discovery path degrades to an empty group and can never affect the shipped
199
+ files/sessions group.
200
+
201
+ ### Index and query rules
202
+
203
+ `multiFolder/listFiles(workspace, query)` rides the same sessionless remote
204
+ namespace as the browser endpoints and the same `fs` seam (`fs.resolve` +
205
+ `fs.listDir`), so it needs no session and no new capability:
206
+
207
+ | Rule | Why |
208
+ | ---- | --- |
209
+ | Breadth-first, bounded by `MAX_INDEX_ENTRIES` across all directories | one workspace's index cannot grow without limit |
210
+ | Canonical-path deduplication (`fs.resolve` + `fs.processPath` per level) | a junction/symlink pointing back up the tree cannot re-enter the walk |
211
+ | `INDEX_EXCLUDED` basenames never traversed | mirrors the shipped provider's defaults (`.git`, `node_modules`, build output); `lib` is deliberately absent there and therefore here |
212
+ | Hidden entries indexed but invisible to a global query | parity with the shipped provider: `.foo` needs an explicit `.` query |
213
+ | 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 |
214
+ | An empty query yields the configured directories themselves | the menu's entry points; `@` alone stays cheap and does not list 30 files |
215
+
216
+ Ranking mirrors the shipped provider (name beat path, directories win ties,
217
+ then shorter paths, then name order) with **one deliberate deviation**: the
218
+ path and subsequence rules read the *in-directory relative* path, never the
219
+ absolute one. Every absolute path on a host shares its prefix (`D:/…`), so
220
+ scoring it would make a one-character query match literally every candidate.
221
+ The configured directory's own basename stays searchable at the lowest rank,
222
+ which is what lets `@secondary-spike` narrow to that directory.
223
+
224
+ A query carrying `/` lists a level instead of ranking one, and accepts two
225
+ spellings — the absolute path a drill inserted, and a leading basename segment
226
+ (`@secondary-spike/src/`). An ambiguous basename (two configured directories
227
+ sharing one) resolves to nothing rather than to a guess. The decoded
228
+ in-directory path is normalized (redundant separators and `.` segments drop)
229
+ but **any `..` segment is refused**: without that, `@secondary/../../etc/` would
230
+ list a directory outside every configured root while still claiming the
231
+ configured directory as the candidates' owner — a listing that escaped the set
232
+ the user granted, wearing a name that lies about it. The shipped provider
233
+ refuses the same escape for the same reason.
234
+
235
+ ### Mention semantics
236
+
237
+ A secondary directory lies outside the workspace root, so no relative path from
238
+ that root can reach one: candidates are **absolute** paths (forward-slashed,
239
+ which is the spelling the mention grammar and the prompt guidance already use
240
+ for host paths). `formatMention` in the client half re-implements the shipped
241
+ grammar — whitespace quotes the path, a quoted directory keeps its quote open
242
+ so completion can descend, and control characters or an embedded quote make a
243
+ path unrepresentable, so that row is dropped rather than inserted broken. The
244
+ bundle is standalone (no build step) and cannot import that package's module,
245
+ hence the re-implementation.
246
+
247
+ ### Client contribution
248
+
249
+ | Decision | Reason |
250
+ | -------- | ------ |
251
+ | 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 |
252
+ | `order: 10` | the shipped group declares no order and defaults to `0`, so the secondary group sits below it |
253
+ | `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 |
254
+ | 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 |
255
+ | `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 |
256
+ | 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 |
257
+ | Failures resolve to `[]` | discovery is advisory: it must never break the composer or the groups beside it |
258
+ | Queries are per keystroke, like the shipped source | the host index makes each call an in-memory rank |
259
+
158
260
  ## Host: prompt injection and notifications
159
261
 
160
262
  - One global `systemPrompt.section` (`multi-folder:secondary-dirs`, order 160) whose
@@ -192,7 +294,11 @@ window.__ModuleLoader__.load({
192
294
  - `inject: ['remote', 'remote.commands', 'slots', 'workspaces', 'connection', 'sessions', 'locale']`; the package's
193
295
  `dsh.client.inject` lists the packages providing them
194
296
  (`@deepseek-ai/dsh-api-gateway`, `@deepseek-ai/dsh-api-remotes`,
195
- `@deepseek-ai/dsh-client-connection`, `@deepseek-ai/dsh-client-locale`).
297
+ `@deepseek-ai/dsh-client-connection`, `@deepseek-ai/dsh-client-locale`,
298
+ `@deepseek-ai/dsh-client-ui-input-trigger`). The trigger registry is
299
+ deliberately **not** in that hard list: the `@` source is contributed through
300
+ `ctx.inject(['inputTriggers'], …)`, so shells without that service keep every
301
+ panel surface (see the `@` discovery section).
196
302
  - UI registrations: `conversation.session.header.actions` (session-scoped button),
197
303
  `shell.overlay` panel, `conversation.input.dock` chip row (session-scoped
198
304
  list entry above the composer card — the session-creation page's shipped
@@ -243,6 +349,13 @@ window.__ModuleLoader__.load({
243
349
  exposes no creation primitive. Neither endpoint touches the configuration
244
350
  store: choosing a level still commits through the mode's own channel
245
351
  (`/multi-folder add` in a session, `multiFolder/add` on the creation page).
352
+ - `@` source: registered through `ctx.inject(['inputTriggers'], …)` (see the
353
+ `@` discovery section for the full decision table). It resolves the addressed
354
+ session's workspace from the `sessions` snapshot (`byId[sessionId].cwd`), calls
355
+ `multiFolder/listFiles` over the shared RPC channel, and projects each
356
+ candidate onto a row — a `section` naming its directory, an in-directory
357
+ `description`, and a `value` carrying the mention the pick inserts. Directory
358
+ rows set `drill`, so the shipped drill gesture descends a level.
246
359
  - Session switch: a `React.useEffect` on `sessionId` re-points the open panel
247
360
  to the current session (reusing the per-session cache) — this also folds a
248
361
  workspace-mode panel back into session mode once the first message creates
@@ -375,6 +488,34 @@ window.__ModuleLoader__.load({
375
488
  business validation server-side. DSH versions that change the Typert
376
489
  registry contract would need this contribution revisited (the tests assert
377
490
  the descriptor shape).
491
+ - `@` discovery is a **companion group, never a merged list**. The shipped
492
+ provider stays single-root, so a secondary file appears under the plugin's
493
+ own group and its mention is an ABSOLUTE path — a relative one could not
494
+ reach outside the workspace root. Consequences worth knowing: the workspace
495
+ file list and the secondary list are ranked separately (the shipped group's
496
+ relevance order never mixes with ours, and only the first 30 secondary
497
+ candidates of a query are offered), a session whose workspace has no
498
+ configured directories gains an empty group that renders nothing, and the
499
+ index is advisory by design — it is rebuilt lazily (4 s TTL) rather than
500
+ invalidated on every tool result, so a file created in a secondary directory
501
+ can take a few seconds to appear in the menu.
502
+ - A **symlinked or junctioned level inside a configured directory is indexed and
503
+ listed through the link**, where the shipped provider skips symlinked
504
+ directories. The fs seam's `listDir` reports only an entry's `type`, so a link
505
+ is not distinguishable from a directory without a second canonicalization per
506
+ child — and rejecting on that basis would also reject junctions that are
507
+ ordinary project structure (and a secondary directory reached through a
508
+ junctioned ancestor). The walk stays bounded regardless: canonical-path
509
+ deduplication prevents a link from re-entering it, and `MAX_INDEX_ENTRIES`
510
+ caps it. Reads are unfenced in DSH, so this grants no access the plugin's own
511
+ directory browser does not already have.
512
+ - The `@` group depends on the trigger registry's source contract
513
+ (`trigger`/`name`/`order`/`showGroupTitle`, `candidates`/`onPick`/`codec`,
514
+ candidate `section`/`icon`/`drill`, and the `(trigger, name)` uniqueness key).
515
+ A DSH release that changes that contract would need this contribution
516
+ revisited; because the registration rides `ctx.inject`, a release that drops
517
+ the service entirely degrades to "no `@` group" instead of breaking the
518
+ plugin's panel.
378
519
 
379
520
  ## Tests
380
521
 
@@ -397,3 +538,14 @@ shape, in-flow row, hero-only visibility, RPC routing, anchored popover), the
397
538
  upstream hero chip taking over the moment its slot is declared, and the fixed
398
539
  launcher returning once both declarations collapse — asserting at each step that
399
540
  the other two surfaces stand down.
541
+
542
+ `@` discovery is covered on both halves against a fake secondary tree behind the
543
+ `fs` seam: the host test asserts the empty-query entry points, bare-fragment
544
+ ranking, directory-basename narrowing, level listing by both spellings,
545
+ recursion, hidden-entry visibility, `node_modules` exclusion, index caching
546
+ inside the TTL, the workspace fence, config-store isolation, and that clearing
547
+ the configuration withdraws the surface; the client test asserts the source's
548
+ identity/order/`showGroupTitle`, its RPC routing and keying, row projection
549
+ (section heading, `~` abbreviation, in-directory description, drill flag,
550
+ folder icon), quote handling, the pick inserts, the identity codec, and that
551
+ both an unrepresentable path and an RPC failure degrade quietly.
package/lib/client.js CHANGED
@@ -39,6 +39,17 @@
39
39
  * chooser worker, whose crash surfaces as `directory picker failed: … worker
40
40
  * exited …`.
41
41
  *
42
+ * `@` discovery for the configured directories. The shipped `@` menu is
43
+ * SINGLE-ROOT: `ui-reference` feeds it from `remote.fileReferences.list`, whose
44
+ * provider walks the addressed session's cwd and refuses every candidate
45
+ * outside that root, so a secondary directory can never appear there. This
46
+ * bundle therefore registers a COMPANION source on the same trigger (see "@
47
+ * discovery for secondaries" in `apply`) and answers it from
48
+ * `multiFolder/listFiles`. Sources are keyed by `(trigger, name)` and rendered
49
+ * one group each, so the shipped files/sessions group is untouched. The
50
+ * registration rides `ctx.inject` — a shell composing no input-trigger service
51
+ * keeps every panel surface and simply gains no `@` group.
52
+ *
42
53
  * Layout shape: an entry in the session header action row, one chip row above
43
54
  * the composer card on the session-creation page, and a frame-wide overlay
44
55
  * panel. Every surface reads one tiny module-scoped store; opening the panel
@@ -1361,6 +1372,154 @@ window.__ModuleLoader__.load({
1361
1372
  release();
1362
1373
  };
1363
1374
  });
1375
+
1376
+ // ------------------------------------- @ discovery for secondaries
1377
+ // The shipped `@` menu is SINGLE-ROOT: `ui-reference` feeds it from
1378
+ // `remote.fileReferences.list`, whose provider walks the addressed
1379
+ // session's cwd and refuses any candidate outside that root. A configured
1380
+ // secondary directory is by definition outside it, so this plugin
1381
+ // registers a COMPANION source on the same trigger and answers it from
1382
+ // `multiFolder/listFiles`.
1383
+ //
1384
+ // The trigger registry keys sources by `(trigger, name)` and renders one
1385
+ // group per source, so the shipped files/sessions group is untouched and
1386
+ // this group appears beside it (order 10 = below the shipped group, which
1387
+ // defaults to 0). `showGroupTitle: false` is deliberate: the menu derives
1388
+ // a group's title by looking its SOURCE NAME up in its own dictionary, so
1389
+ // a visible title would read "multi-folder", and a group whose query found
1390
+ // nothing would otherwise render that heading over an empty list. Every
1391
+ // row instead carries a `section`, which heads the rows by directory.
1392
+ //
1393
+ // `ctx.inject` rather than a hard `inject` entry: a shell that composes
1394
+ // no input-trigger service must keep its panel, and on such a shell this
1395
+ // contribution simply never activates.
1396
+
1397
+ /** Last path segment, tolerant of either separator. */
1398
+ function lastSegment(path) {
1399
+ var parts = String(path).replace(/[\\/]+$/, '').split(/[\\/]/);
1400
+ return parts.length === 0 ? '' : parts[parts.length - 1];
1401
+ }
1402
+
1403
+ /** Drop the host home prefix from a heading when the path sits under it. */
1404
+ function abbreviate(path) {
1405
+ var home = remote && remote.$host ? remote.$host.home : undefined;
1406
+ if (!home) return path;
1407
+ var flat = String(path).replace(/\\/g, '/');
1408
+ var prefix = String(home).replace(/\\/g, '/');
1409
+ if (flat.toLowerCase().indexOf(prefix.toLowerCase()) !== 0) return path;
1410
+ return '~' + flat.slice(prefix.length);
1411
+ }
1412
+
1413
+ /** Mention text for one discovered path. Mirrors the shipped
1414
+ * `formatFileMention` grammar — whitespace quotes the path, a quoted
1415
+ * directory keeps its quote open so completion can descend, and a path
1416
+ * with control characters or an embedded quote is unrepresentable.
1417
+ * Reimplemented here because this bundle is standalone (no build step)
1418
+ * and cannot import that package's module. */
1419
+ function formatMention(path, kind, preserveQuote) {
1420
+ var text = kind === 'directory' ? path + '/' : path;
1421
+ if (/[\u0000-\u001f\u007f-\u009f"]/.test(text)) return undefined;
1422
+ if (!preserveQuote && !/\s/.test(text)) return '@' + text;
1423
+ return kind === 'directory' ? '@"' + text : '@"' + text + '"';
1424
+ }
1425
+
1426
+ /** The source-owned payload one menu row carries. */
1427
+ function rowValue(value) {
1428
+ if (typeof value !== 'string') return undefined;
1429
+ try {
1430
+ var parsed = JSON.parse(value);
1431
+ return parsed && typeof parsed.mention === 'string' ? parsed : undefined;
1432
+ } catch (e) {
1433
+ return undefined;
1434
+ }
1435
+ }
1436
+
1437
+ /** Project one host candidate onto a menu row (or nothing when the path
1438
+ * cannot be written back as mention text). */
1439
+ function fileRow(candidate, preserveQuote, heading) {
1440
+ var path = String(candidate.path || '').replace(/\\/g, '/');
1441
+ var kind = candidate.kind === 'directory' ? 'directory' : 'file';
1442
+ var mention = formatMention(path, kind, preserveQuote);
1443
+ if (mention === undefined) return [];
1444
+ var rel = String(candidate.rel || '');
1445
+ var name = rel === '' ? lastSegment(path) : lastSegment(rel);
1446
+ var slash = rel.lastIndexOf('/');
1447
+ var parent = slash < 0 ? '' : rel.slice(0, slash);
1448
+ return [{
1449
+ name: kind === 'directory' ? name + '/' : name,
1450
+ // The in-directory location only: the heading above the row already
1451
+ // names the directory, so repeating it there says nothing.
1452
+ ...(parent === '' ? {} : { description: parent }),
1453
+ icon: kind === 'directory' ? 'folder' : 'file',
1454
+ section: heading,
1455
+ value: JSON.stringify({ kind: kind, path: path, mention: mention }),
1456
+ ...(kind === 'directory' ? { drill: true } : {}),
1457
+ }];
1458
+ }
1459
+
1460
+ ctx.inject(['inputTriggers'], function (scope) {
1461
+ var registry = scope.inputTriggers;
1462
+ if (!registry || typeof registry.registerSource !== 'function') return;
1463
+ scope.effect(function () {
1464
+ return registry.registerSource({
1465
+ trigger: '@',
1466
+ name: NS,
1467
+ order: 10,
1468
+ // The menu titles a group from its source name via its OWN
1469
+ // dictionary (an unknown key renders verbatim), so the title row is
1470
+ // switched off: rows are headed by their directory section instead,
1471
+ // and an empty result renders nothing at all.
1472
+ showGroupTitle: false,
1473
+ candidates: function (session, request) {
1474
+ var summary = sessions.list.getSnapshot().byId[session.sessionId];
1475
+ var workspace = summary && summary.cwd;
1476
+ if (!workspace) return Promise.resolve([]);
1477
+ var preserveQuote = request.quoted === true;
1478
+ return remoteCall('multiFolder/listFiles', { workspace: workspace, query: request.query }).then(
1479
+ function (value) {
1480
+ var items = value && Array.isArray(value.candidates) ? value.candidates : [];
1481
+ var rows = [];
1482
+ for (var i = 0; i < items.length; i += 1) {
1483
+ var item = items[i];
1484
+ rows = rows.concat(fileRow(item, preserveQuote, abbreviate(String(item.dir || ''))));
1485
+ }
1486
+ return rows;
1487
+ },
1488
+ // Autocomplete is advisory: a discovery failure is an empty
1489
+ // group, never a broken composer and never a failed send.
1490
+ function () { return []; },
1491
+ );
1492
+ },
1493
+ onPick: function (pick) {
1494
+ var chosen = rowValue(pick.candidate.value);
1495
+ if (chosen === undefined) return undefined;
1496
+ var name = lastSegment(chosen.path);
1497
+ // Tab / the row chevron refines the query in place instead of
1498
+ // settling the pick, so a directory descends one level: the
1499
+ // mention text becomes the live query, and the host resolves the
1500
+ // absolute spelling it just produced.
1501
+ if (chosen.kind === 'directory' && pick.action === 'drill') {
1502
+ return { text: chosen.mention, continue: true };
1503
+ }
1504
+ return {
1505
+ insert: {
1506
+ source: NS,
1507
+ ref: chosen.mention,
1508
+ label: chosen.kind === 'directory' ? name + '/' : name,
1509
+ appearance: chosen.kind === 'directory' ? 'folder' : 'file',
1510
+ clipboardText: chosen.mention,
1511
+ },
1512
+ };
1513
+ },
1514
+ // The mention IS the model form (an `@path` token the prompt
1515
+ // grammar already defines), so serialization is the identity.
1516
+ codec: {
1517
+ clipboardText: function (ref) { return ref; },
1518
+ serialize: function (ref) { return Promise.resolve(ref); },
1519
+ },
1520
+ });
1521
+ }, 'dsh-multi-folder: @ discovery source');
1522
+ });
1364
1523
  }
1365
1524
 
1366
1525
  exports.name = name;
package/lib/index.js CHANGED
@@ -32,13 +32,16 @@
32
32
  * Background shell runs (`run_in_background: true`) register with the
33
33
  * generic jobs runtime (`ctx.jobs`) under the same re-rooted policy,
34
34
  * mirroring the shipped pwsh/bash tools so `job_output` / `job_kill` and
35
- * finish notices keep working. `shell.start` is ASYNC (it publishes the
36
- * handle only once launch preparation, Windows ACL grants included,
37
- * succeeded), so the launcher is adapted to the jobs runtime's synchronous
38
- * hooks contract exactly like the shipped tools' `processJob`: the job-owned
39
- * AbortSignal drives preparation cancellation and a rejected preparation
40
- * settles the job as `failed`. Reads (read/glob/grep) are unfenced and
41
- * already work.
35
+ * finish notices keep working. The launch is ASYNC (the handle is published
36
+ * only once launch preparation, Windows ACL grants included, succeeded), so
37
+ * the launcher is adapted to the jobs runtime's synchronous hooks contract
38
+ * exactly like the shipped tools' `processJob`: the job-owned AbortSignal
39
+ * drives preparation cancellation and a rejected preparation settles the job
40
+ * as `failed`. Reads (read/glob/grep) are unfenced and already work.
41
+ * Both shell paths are addressed through the version-adaptive seam
42
+ * {@link shellUsesExecute}: DSH 0.1.7-alpha.1 retired `shell.run` /
43
+ * `shell.start` in favour of a single `execute()`, and turned
44
+ * `JobSpec.owner` from the calling Agent into its SessionId.
42
45
  * 3. Prompt injection: one ordered system-prompt section rendered per
43
46
  * assembly from the configured directories of the assembling session.
44
47
  * 4. Non-interrupting change notification: configuration changes made via
@@ -78,6 +81,14 @@
78
81
  * by the shell's own workspace surfaces. Directory creation mirrors the
79
82
  * shipped browse backend, which uses Node's `mkdir` (the fs seam has no
80
83
  * creation primitive).
84
+ * 9. `@` file discovery for the configured directories (see the
85
+ * "secondary-directory file discovery" section). The shipped `@`
86
+ * file-reference menu is single-root — its provider walks the session cwd
87
+ * only and refuses candidates outside it — so secondary directories are
88
+ * invisible to it. `multiFolder/listFiles` indexes them over the `fs` seam
89
+ * instead, and the client half registers a companion `@` group fed by that
90
+ * endpoint. The shipped provider is untouched: with no secondary
91
+ * directories configured, behavior is exactly upstream's.
81
92
  */
82
93
 
83
94
  import { mkdir } from 'node:fs/promises'
@@ -102,6 +113,35 @@ const CONFIG_GUARD_TEXT =
102
113
  'This file is managed by the dsh-multi-folder plugin. Secondary working directories may only be ' +
103
114
  'configured by the user through the UI (session header or session-creation page) or the /multi-folder command; direct edits are rejected.'
104
115
 
116
+ /**
117
+ * Which `ctx.shell` seam shape this DSH release exposes.
118
+ *
119
+ * DSH 0.1.7-alpha.1 (commit `d6bebc5783`, "converge on execute()") deleted BOTH
120
+ * `ShellExecutor.run` and `ShellExecutor.start` and replaced them with one
121
+ * `execute(spec): Promise<ShellExecution>`. `ShellExecution` extends
122
+ * `ShellProcess` (status/exitCode/signal/done/kill/readOutput/sandbox) and adds
123
+ * the foreground projection `result(): Promise<ShellRunResult>`, so the two old
124
+ * entry points map onto it exactly:
125
+ *
126
+ * old `await shell.run(spec)` -> `await (await shell.execute(spec)).result()`
127
+ * old `proc = await shell.start(spec)` -> `proc = await shell.execute({ ...spec, onExpiry: 'none' })`
128
+ * (the retired `start` armed no deadline,
129
+ * while `resolve()` defaults `onExpiry` to `'kill'`)
130
+ *
131
+ * The same release changed `JobSpec.owner` from the calling `Agent` to its
132
+ * `SessionId`, and `jobs-local` now resolves that id through `agents.get(id)`
133
+ * (`session "[object Object]" has no live agent`). One probe therefore decides
134
+ * both shapes: an executor old enough to still require `run`/`start` also
135
+ * expects the Agent owner. The probe is the PRESENCE of `execute` — that method
136
+ * was introduced by the same commit that retired the other two, so it can never
137
+ * mean anything else — never the ABSENCE of `run`, so a release that keeps the
138
+ * retired methods as deprecated shims would still take the modern path.
139
+ *
140
+ * @param shell - the resolved `ctx.shell` service, or undefined without one.
141
+ * @returns true when the seam is the post-0.1.7 `execute()` contract.
142
+ */
143
+ const shellUsesExecute = (shell) => shell !== undefined && typeof shell.execute === 'function'
144
+
105
145
  export function apply(ctx) {
106
146
  const { fs, sandboxPolicy, systemPrompt } = ctx
107
147
  // NOTE: shell, shellEnv, and commands are deliberately NOT captured here.
@@ -355,6 +395,289 @@ export function apply(ctx) {
355
395
  return { path: target, parent }
356
396
  }
357
397
 
398
+ // ------------------------------------- secondary-directory file discovery
399
+ // The shipped `@` file-reference menu is SINGLE-ROOT by construction: its
400
+ // provider (`dsh-file-reference-local`) builds one `WorkspaceFileSearch` per
401
+ // agent from `agent.session.header.cwd` and refuses every candidate outside
402
+ // that root (`resolveDisplayDirectory` answers undefined for a path that
403
+ // escapes it). A configured secondary directory is by definition outside the
404
+ // primary workspace, so the shipped menu cannot reach it — no upstream
405
+ // incompatibility, simply a scope the provider does not cover.
406
+ //
407
+ // This plugin therefore publishes its own discovery endpoint over the files
408
+ // it already has read access to (`fs.listDir`, the same seam the owned
409
+ // directory browser uses) and the client half registers a companion `@`
410
+ // group fed by it. Nothing here alters the shipped provider: a workspace with
411
+ // no secondary directories keeps exactly the upstream behavior, and the
412
+ // shipped group keeps answering on its own.
413
+
414
+ /**
415
+ * Directory basenames never traversed. Mirrors the shipped provider's
416
+ * defaults (`dsh-file-reference-local`): version-control and dependency
417
+ * stores plus build-output names whose generated files would otherwise
418
+ * spend the entry budget twice and rank beside their own sources. `lib` is
419
+ * deliberately absent there and therefore here.
420
+ */
421
+ const INDEX_EXCLUDED = new Set([
422
+ '.git', 'node_modules', 'dist', 'build', 'out', 'coverage', 'target',
423
+ '.next', '.nuxt', '.turbo', '.venv', '__pycache__', '.pytest_cache',
424
+ '.mypy_cache', '.gradle',
425
+ ])
426
+ /** Entries retained per workspace across every configured directory. */
427
+ const MAX_INDEX_ENTRIES = 20000
428
+ /** Entries retained for one level listing. */
429
+ const MAX_LEVEL_ENTRIES = 500
430
+ /** Candidates one query returns. */
431
+ const MAX_FILE_RESULTS = 30
432
+ /**
433
+ * How long one workspace's index answers before the next query rebuilds it.
434
+ * Autocomplete is advisory, so a few seconds of staleness is invisible while
435
+ * it bounds rebuild cost to one traversal per window — a config change is
436
+ * caught immediately instead, by the directory signature.
437
+ */
438
+ const FILE_INDEX_TTL_MS = 4000
439
+ /** wsKey(workspace) -> { sig, builtAt, entries } */
440
+ const filesCache = new Map()
441
+
442
+ const lastSegment = (p) => {
443
+ const parts = String(p).replace(/[\\/]+$/, '').split(/[\\/]/)
444
+ return parts.length === 0 ? '' : parts[parts.length - 1]
445
+ }
446
+ /** Absolute path in the forward-slash spelling the mention grammar carries. */
447
+ const slashed = (p) => String(p).replace(/\\/g, '/')
448
+
449
+ /**
450
+ * Index one workspace's secondary directories: breadth-first, canonical-path
451
+ * deduplicated (a junction/symlink pointing back up the tree cannot re-enter
452
+ * the walk), excluding `INDEX_EXCLUDED` basenames, bounded by
453
+ * `MAX_INDEX_ENTRIES` across all directories.
454
+ * @param dirs - configured secondary directories.
455
+ * @returns entries as `{ dir, rel, kind }`, `rel` forward-slashed inside `dir`.
456
+ */
457
+ const scanFiles = async (dirs) => {
458
+ const entries = []
459
+ const visited = new Set()
460
+ for (const dir of dirs) {
461
+ const queue = [{ abs: dir, rel: '' }]
462
+ for (let cursor = 0; cursor < queue.length && entries.length < MAX_INDEX_ENTRIES; cursor += 1) {
463
+ const level = queue[cursor]
464
+ let target
465
+ let absolute
466
+ try {
467
+ target = await fs.resolve(level.abs)
468
+ absolute = fs.processPath(target)
469
+ } catch {
470
+ continue // an unresolvable level contributes nothing
471
+ }
472
+ const canonical = wsKey(absolute)
473
+ if (visited.has(canonical)) continue
474
+ visited.add(canonical)
475
+ let children
476
+ try {
477
+ children = await fs.listDir(target)
478
+ } catch {
479
+ continue // an unreadable branch costs its own entries; the rest stay useful
480
+ }
481
+ const api = pathApiFor(absolute)
482
+ for (const child of children ?? []) {
483
+ if (child === null || child === undefined) continue
484
+ const childName = String(child.name)
485
+ const rel = level.rel === '' ? childName : level.rel + '/' + childName
486
+ if (child.type === 'directory') {
487
+ if (INDEX_EXCLUDED.has(childName)) continue
488
+ entries.push({ dir, rel, kind: 'directory' })
489
+ queue.push({ abs: api.join(absolute, childName), rel })
490
+ } else if (child.type === 'file') {
491
+ entries.push({ dir, rel, kind: 'file' })
492
+ }
493
+ if (entries.length >= MAX_INDEX_ENTRIES) break
494
+ }
495
+ }
496
+ }
497
+ return entries
498
+ }
499
+
500
+ /** One workspace's index, rebuilt when its directory set changes or the TTL lapses. */
501
+ const indexFor = async (ws, dirs) => {
502
+ const key = wsKey(ws)
503
+ const sig = dirs.map(wsKey).join('|')
504
+ const cached = filesCache.get(key)
505
+ if (cached !== undefined && cached.sig === sig && Date.now() - cached.builtAt < FILE_INDEX_TTL_MS) {
506
+ return cached.entries
507
+ }
508
+ const entries = await scanFiles(dirs)
509
+ filesCache.set(key, { sig, builtAt: Date.now(), entries })
510
+ return entries
511
+ }
512
+
513
+ /** Longest common subsequence gap score, mirroring the shipped provider. */
514
+ const subsequenceScore = (target, needle) => {
515
+ let at = 0
516
+ let gap = 0
517
+ for (const character of needle) {
518
+ const found = target.indexOf(character, at)
519
+ if (found < 0) return undefined
520
+ gap += found - at
521
+ at = found + 1
522
+ }
523
+ return Math.max(0, 100 - gap)
524
+ }
525
+
526
+ /**
527
+ * Score one entry against a query. Ranking mirrors the shipped provider
528
+ * (name beat path, directories win ties) with ONE deliberate deviation: the
529
+ * path/path-subsequence rules read the IN-DIRECTORY relative path, never the
530
+ * absolute one. Every absolute path shares the host prefix (`D:/…`), so
531
+ * scoring it would make a one-character query match every candidate. The
532
+ * directory's own basename stays searchable at the lowest precedence, which
533
+ * is what lets `@secondary-spike` narrow to that directory.
534
+ */
535
+ const scoreEntry = (entry, needle) => {
536
+ if (needle === '') return 0
537
+ const rel = entry.rel.toLowerCase()
538
+ const name = lastSegment(rel).toLowerCase()
539
+ const bonus = entry.kind === 'directory' ? 25 : 0
540
+ if (name === needle) return 1000 + bonus
541
+ if (name.startsWith(needle)) return 900 + bonus
542
+ if (name.includes(needle)) return 700 + bonus
543
+ if (rel.includes(needle)) return 500 + bonus
544
+ const sub = subsequenceScore(rel, needle)
545
+ if (sub !== undefined) return 300 + sub + bonus
546
+ if (lastSegment(entry.dir).toLowerCase().includes(needle)) return 200 + bonus
547
+ return undefined
548
+ }
549
+
550
+ /** Rank entries deterministically and project the wire shape. */
551
+ const rankEntries = (entries, query) => {
552
+ const needle = query.toLowerCase()
553
+ const ranked = []
554
+ for (const entry of entries) {
555
+ const score = scoreEntry(entry, needle)
556
+ if (score !== undefined) ranked.push({ entry, score })
557
+ }
558
+ ranked.sort((left, right) =>
559
+ right.score - left.score
560
+ || (left.entry.kind === right.entry.kind ? 0 : left.entry.kind === 'directory' ? -1 : 1)
561
+ || (needle === '' ? 0 : left.entry.rel.length - right.entry.rel.length)
562
+ || (left.entry.rel < right.entry.rel ? -1 : left.entry.rel > right.entry.rel ? 1 : 0))
563
+ return ranked.slice(0, MAX_FILE_RESULTS).map(({ entry }) => ({
564
+ path: entry.rel === '' ? slashed(entry.dir) : slashed(entry.dir) + '/' + entry.rel,
565
+ kind: entry.kind,
566
+ dir: entry.dir,
567
+ rel: entry.rel,
568
+ }))
569
+ }
570
+
571
+ /**
572
+ * Decode the in-directory part of a level query into a normalized relative
573
+ * path, or reject it. `.` and empty segments are normalized away, and ANY
574
+ * `..` segment is refused: without that, `@secondary/../../etc/` would list a
575
+ * directory outside every configured root while still claiming the configured
576
+ * directory as its owner — a candidate whose `dir` field lies, and a level
577
+ * listing that escaped the set the user actually granted. The shipped
578
+ * provider refuses the same escape for the same reason.
579
+ * @returns the normalized relative path, or null when the query escapes.
580
+ */
581
+ const decodeInside = (inside) => {
582
+ const segments = String(inside).split('/').filter((segment) => segment !== '' && segment !== '.')
583
+ return segments.includes('..') ? null : segments.join('/')
584
+ }
585
+
586
+ /**
587
+ * Resolve the directory part of a query to `(configured dir, in-dir path)`.
588
+ * Two spellings are accepted: a fully qualified path inside a configured
589
+ * directory (what a drill inserts), and a path whose first segment names a
590
+ * configured directory by basename (`@secondary-spike/src/`). An ambiguous
591
+ * basename (two configured directories sharing one) is refused, never
592
+ * guessed.
593
+ * @returns the resolved level, or null when the query names no configured directory.
594
+ */
595
+ const resolveLevel = (dirs, directory) => {
596
+ const trimmed = String(directory).replace(/\/+$/, '')
597
+ if (trimmed === '') return null
598
+ const query = slashed(trimmed).toLowerCase()
599
+ if (fullyQualified(trimmed)) {
600
+ for (const dir of longestRootFirst(dirs)) {
601
+ const root = slashed(dir).toLowerCase()
602
+ if (query === root) return { dir, inside: '' }
603
+ if (query.startsWith(root + '/')) {
604
+ const inside = decodeInside(slashed(trimmed).slice(slashed(dir).length + 1))
605
+ return inside === null ? null : { dir, inside }
606
+ }
607
+ }
608
+ return null
609
+ }
610
+ const cut = trimmed.indexOf('/')
611
+ const head = cut < 0 ? trimmed : trimmed.slice(0, cut)
612
+ const inside = decodeInside(cut < 0 ? '' : trimmed.slice(cut + 1))
613
+ if (inside === null) return null
614
+ const matches = dirs.filter((dir) => lastSegment(dir).toLowerCase() === head.toLowerCase())
615
+ if (matches.length !== 1) return null
616
+ return { dir: matches[0], inside }
617
+ }
618
+
619
+ /** List one level of a configured secondary directory (files and directories). */
620
+ const listLevel = async (dirs, directory, fragment) => {
621
+ const empty = { candidates: [], truncated: false }
622
+ const level = resolveLevel(dirs, directory)
623
+ if (level === null) return empty
624
+ const absolute = level.inside === '' ? level.dir : pathApiFor(level.dir).join(level.dir, ...level.inside.split('/'))
625
+ let children
626
+ try {
627
+ children = await fs.listDir(await fs.resolve(absolute))
628
+ } catch {
629
+ return empty
630
+ }
631
+ const shown = []
632
+ for (const child of children ?? []) {
633
+ if (child === null || child === undefined) continue
634
+ const childName = String(child.name)
635
+ const kind = child.type === 'directory' ? 'directory' : child.type === 'file' ? 'file' : null
636
+ if (kind === null) continue
637
+ if (kind === 'directory' && INDEX_EXCLUDED.has(childName)) continue
638
+ // Hidden entries stay reachable by asking for them explicitly, exactly
639
+ // like the shipped provider (and this plugin's own directory browser).
640
+ if (childName.startsWith('.') && !fragment.startsWith('.')) continue
641
+ shown.push({ dir: level.dir, rel: level.inside === '' ? childName : level.inside + '/' + childName, kind })
642
+ }
643
+ const truncated = shown.length > MAX_LEVEL_ENTRIES
644
+ if (truncated) shown.length = MAX_LEVEL_ENTRIES
645
+ return { candidates: rankEntries(shown, fragment), truncated }
646
+ }
647
+
648
+ /**
649
+ * Discover candidates for one `@` query across a workspace's configured
650
+ * secondary directories. An empty query yields the directories themselves
651
+ * (the menu's entry points); a query carrying a separator lists that level;
652
+ * a bare fragment fuzzy-ranks the index.
653
+ * @param ws - primary workspace path (configuration key).
654
+ * @param query - path text following `@` or `@"`.
655
+ * @returns the workspace, its configured directories, and the ranked candidates.
656
+ */
657
+ const coreListFiles = async (ws, query) => {
658
+ ws = requireWorkspace(ws)
659
+ const entry = await loadDirs(ws)
660
+ const dirs = [...entry.dirs]
661
+ const out = { workspace: ws, dirs, candidates: [], truncated: false }
662
+ if (dirs.length === 0) return out
663
+ const q = typeof query === 'string' ? query.replace(/\\/g, '/') : ''
664
+ if (q === '') {
665
+ out.candidates = dirs.map((dir) => ({ path: slashed(dir), kind: 'directory', dir, rel: '' }))
666
+ return out
667
+ }
668
+ const slash = q.lastIndexOf('/')
669
+ if (slash >= 0) {
670
+ const level = await listLevel(dirs, q.slice(0, slash + 1), q.slice(slash + 1))
671
+ out.candidates = level.candidates
672
+ out.truncated = level.truncated
673
+ return out
674
+ }
675
+ const index = await indexFor(ws, dirs)
676
+ const visible = q.startsWith('.') ? index : index.filter((item) => !item.rel.split('/').some((segment) => segment.startsWith('.')))
677
+ out.candidates = rankEntries(visible, q)
678
+ return out
679
+ }
680
+
358
681
  // ----------------------------------------------- sessionless remote API
359
682
  // `multiFolder/*` endpoints over the Typert gateway. Hand-written
360
683
  // `src-json` descriptors registered through ctx.typert.register (the
@@ -408,6 +731,13 @@ export function apply(ctx) {
408
731
  throw new Error(remoteErrorMessage(e))
409
732
  }
410
733
  },
734
+ async listFiles(workspace, query) {
735
+ try {
736
+ return await coreListFiles(workspace, query)
737
+ } catch (e) {
738
+ throw new Error(remoteErrorMessage(e))
739
+ }
740
+ },
411
741
  }
412
742
  Object.defineProperty(multiFolderApi, 'typertRemote', {
413
743
  value: Object.freeze({
@@ -439,6 +769,7 @@ export function apply(ctx) {
439
769
  remoteInvocation('set', ['workspace', 'dirs']),
440
770
  remoteInvocation('browse', ['path']),
441
771
  remoteInvocation('makeDir', ['parent', 'name']),
772
+ remoteInvocation('listFiles', ['workspace', 'query']),
442
773
  ],
443
774
  }
444
775
 
@@ -665,24 +996,34 @@ export function apply(ctx) {
665
996
  * Adapt one asynchronous background launch to the jobs runtime's SYNCHRONOUS
666
997
  * hooks contract, mirroring the shipped pwsh/bash tools' `processJob`.
667
998
  *
668
- * `shell.start` is ASYNC — it resolves the process handle only after launch
669
- * preparation (Windows ACL grants included) and rejects when preparation is
670
- * cancelled or fails — so the handle can never be dereferenced from `run()`.
671
- * Calling it as if it returned a process made every background run in a
672
- * secondary directory fail immediately with
999
+ * The launch is ASYNC in every supported release — it resolves the process
1000
+ * handle only after launch preparation (Windows ACL grants included) and
1001
+ * rejects when preparation is cancelled or fails — so the handle can never be
1002
+ * dereferenced from `run()`. Treating it as synchronous made every background
1003
+ * run in a secondary directory fail immediately with
673
1004
  * `Cannot read properties of undefined (reading 'then')` (`proc.done` read
674
1005
  * off the un-awaited promise). The job-owned AbortSignal travels into
675
1006
  * `shell.resolve`, so `cancel` stops a launch that has not published a handle
676
1007
  * yet, and a rejected preparation settles the job as `failed` instead of
677
1008
  * leaving it running forever. A background process outlives the tool call, so
678
- * no CALLER signal is forwarded; `shell.start` ignores `timeoutMs` by design.
1009
+ * no CALLER signal is forwarded, and it must not inherit a deadline:
1010
+ * `onExpiry: 'none'` is what makes the post-0.1.7 `execute()` path ignore
1011
+ * `timeoutMs`, which the retired `start()` did by construction. Without it a
1012
+ * background command would be killed at the executor's default timeout — a
1013
+ * silent behavior regression the shipped tools avoid the same way.
679
1014
  */
680
1015
  const startBackgroundJob = (shell, request) => {
681
1016
  const controller = new AbortController()
1017
+ const modern = shellUsesExecute(shell)
682
1018
  let proc
683
1019
  const done = (async () => {
684
1020
  try {
685
- proc = await shell.start(shell.resolve({ ...request, signal: controller.signal }))
1021
+ const spec = shell.resolve({
1022
+ ...request,
1023
+ signal: controller.signal,
1024
+ ...(modern ? { onExpiry: 'none' } : {}),
1025
+ })
1026
+ proc = modern ? await shell.execute(spec) : await shell.start(spec)
686
1027
  try {
687
1028
  if (controller.signal.aborted) proc.kill()
688
1029
  } finally {
@@ -889,6 +1230,7 @@ export function apply(ctx) {
889
1230
  if (exec.name === 'pwsh' || exec.name === 'bash') {
890
1231
  const shell = ctx.get('shell')
891
1232
  if (shell === undefined) return next()
1233
+ const modernShell = shellUsesExecute(shell)
892
1234
  if (dirs === null) return next()
893
1235
  const rawWorkdir = args && typeof args.workdir === 'string' ? args.workdir : null
894
1236
  const joined = rawWorkdir === null
@@ -923,10 +1265,16 @@ export function apply(ctx) {
923
1265
  const jobs = ctx.get('jobs')
924
1266
  if (jobs === undefined) return next()
925
1267
  owned = { policy }
1268
+ // `JobSpec.owner` is the owner's SessionId since 0.1.7-alpha.1
1269
+ // (`jobs-local` resolves it through `agents.get(id)`); the older seam
1270
+ // took the Agent itself. Passing the wrong shape throws
1271
+ // `session "[object Object]" has no live agent` and the background run
1272
+ // never starts. `Agent.id` IS the session id, which is the spelling
1273
+ // the shipped pwsh/bash tools register with.
926
1274
  const jobId = jobs.start({
927
1275
  kind: exec.name,
928
1276
  label: String(args.command),
929
- ...(exec.agent ? { owner: exec.agent } : {}),
1277
+ ...(exec.agent ? { owner: modernShell ? exec.agent.id : exec.agent } : {}),
930
1278
  run: () => startBackgroundJob(shell, request),
931
1279
  })
932
1280
  return {
@@ -937,7 +1285,12 @@ export function apply(ctx) {
937
1285
  }
938
1286
 
939
1287
  owned = { policy }
940
- const result = await shell.run(shell.resolve({ ...request, signal: exec.signal }))
1288
+ // One call site, two release shapes: 0.1.7-alpha.1 replaced
1289
+ // `shell.run(spec)` with `execute(spec)` + the handle's foreground
1290
+ // projection `result()`. See {@link shellUsesExecute}.
1291
+ const result = modernShell
1292
+ ? await (await shell.execute(shell.resolve({ ...request, signal: exec.signal }))).result()
1293
+ : await shell.run(shell.resolve({ ...request, signal: exec.signal }))
941
1294
  if (result.aborted) {
942
1295
  return {
943
1296
  isError: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-multi-folder",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
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",
@@ -62,7 +62,8 @@
62
62
  "@deepseek-ai/dsh-api-gateway",
63
63
  "@deepseek-ai/dsh-api-remotes",
64
64
  "@deepseek-ai/dsh-client-connection",
65
- "@deepseek-ai/dsh-client-locale"
65
+ "@deepseek-ai/dsh-client-locale",
66
+ "@deepseek-ai/dsh-client-ui-input-trigger"
66
67
  ]
67
68
  }
68
69
  },