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 +19 -2
- package/README.zh.md +19 -2
- package/docs/design.md +161 -9
- package/lib/client.js +159 -0
- package/lib/index.js +369 -16
- package/package.json +3 -2
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
|
|
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
|
|
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 })` +
|
|
40
|
-
|
|
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
|
|
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).
|
|
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;
|
|
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.
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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
|
-
*
|
|
669
|
-
* preparation (Windows ACL grants included) and
|
|
670
|
-
* cancelled or fails — so the handle can never be
|
|
671
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
},
|