dsh-multi-folder 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Yutong Zou
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,103 @@
1
+ # dsh-multi-folder
2
+
3
+ **English** | [中文](README.zh.md)
4
+
5
+ > Secondary working directories for a DeepSeek Harness project — edit a source repo, a test repo, and a docs repo side by side without leaving the primary workspace.
6
+
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
8
+ [![Node.js >= 20](https://img.shields.io/badge/Node.js-%3E%3D20-brightgreen)](https://nodejs.org/)
9
+
10
+ A [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) plugin bundle that gives one project (workspace) a set of **secondary working directories**:
11
+
12
+ - The agent's core `cwd` and every other core attribute keep pointing at the **primary workspace**.
13
+ - Under **Workspace Write** mode the agent gains the **same read / write / edit / execute permissions** on the configured secondary directories as on the primary workspace — enforced by re-rooting the session's own sandbox policy, so every mode keeps its semantics (`read-only` still denies, `workspace-write` allows, `danger-full-access` allows).
14
+ - The directory list is **injected into the system prompt** and re-rendered per session assembly.
15
+ - 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**.
16
+ - **No new tools.** Everything is a framework-level change (tool-pipeline interception) plus a UI-level change (a session-scoped header entry).
17
+
18
+ ## Requirements
19
+
20
+ - Node.js >= 20
21
+ - A DSH profile composed from `@deepseek-ai/dsh-base` + `@deepseek-ai/dsh-web-app` (or an equivalent composition that provides `fs`, `sandboxPolicy`, `systemPrompt`, `commands`, `shell`, `shellEnv`, and the standard web client modules).
22
+
23
+ ## Install
24
+
25
+ Link this repository into a DSH profile:
26
+
27
+ ```bash
28
+ dsh plugin --profile web add link:<path-to-this-repo>
29
+ ```
30
+
31
+ or install from npm and add `dsh-multi-folder` to the profile's `dsh.profile.bundles`:
32
+
33
+ ```bash
34
+ cd <dsh-home>/profiles/web
35
+ pnpm add dsh-multi-folder
36
+ ```
37
+
38
+ Then **restart the DSH backend** (host composition loads at process start) and **refresh the browser page** (the client bundle is served `no-cache`).
39
+
40
+ ## Usage
41
+
42
+ A **「多工作目录 / Multi-folder」** button appears in the session header. The panel lets you:
43
+
44
+ | Action | Behavior |
45
+ | ------ | -------- |
46
+ | Add directory | Opens the native directory picker |
47
+ | Remove / refresh | Applies immediately |
48
+ | Switch session | The panel auto-switches to that session's directories |
49
+ | Reopen panel | Uses the per-session cache — no redundant command rows |
50
+
51
+ Equivalent slash command for the user:
52
+
53
+ ```
54
+ /multi-folder list
55
+ /multi-folder add "D:\path\to\repo"
56
+ /multi-folder remove "D:\path\to\repo"
57
+ /multi-folder set "D:\a" "D:\b"
58
+ ```
59
+
60
+ 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.
61
+
62
+ ## How it works
63
+
64
+ - **Interception** — a listener on the `tools/execute` around-dispatch waterfall short-circuits `write` / `edit` / `pwsh` / `bash` calls whose resolved path (or `workdir`) lands inside a configured secondary directory, and executes them with the session's standing sandbox policy **re-rooted to that directory** (`{ ...standingPolicy, workspaceRoot: secondaryDir }`). The mode itself is untouched, which is what gives every sandbox mode its identical primary-workspace semantics for free. Paths are canonicalized through `fs.resolve` + `processPath` before matching, so `..`, symlinks, and case differences behave correctly.
65
+ - **Prompt injection** — one ordered `systemPrompt` section with a text provider evaluated per assembly, rendering only for sessions whose workspace has configured directories.
66
+ - **Notifications** — a pending notice armed by the command handler (only on actual change) is consumed at the next boundary by either the `agent/pre-step` waterfall (prepend into the entering message batch) or the `tools/post-execute` waterfall (attach as `additionalContexts`), whichever fires first — the framework's native plugin-sourced `notice` context.
67
+ - **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).
68
+ - **Client** — a hand-maintained factory bundle (`window.__ModuleLoader__.load`), no build toolchain required. The panel drives the host through the Remote BFF (`ctx.remote.commands.execute`).
69
+
70
+ ## Project layout
71
+
72
+ | Path | Purpose |
73
+ | ---- | ------- |
74
+ | `cordis.patch.yml` | Profile patch layer inserting the `dsh-multi-folder` row |
75
+ | `lib/index.js` | Host plugin: config store, tool-pipeline interception, prompt injection, dual-channel notifications, `/multi-folder` command |
76
+ | `lib/client.js` | Client plugin (factory bundle): session-header button + overlay panel |
77
+ | `test/` | Runtime-free behavior tests (see Development) |
78
+ | `docs/` | Design and analysis documents |
79
+
80
+ ## Development
81
+
82
+ No build step: the host half is plain ESM and `lib/client.js` is a hand-maintained factory bundle in the DSH client-modules format. Tests run with Node directly:
83
+
84
+ ```bash
85
+ node test/smoke-host.mjs # host apply smoke test
86
+ node test/intercept.mjs # interception / command / notification behavior
87
+ node test/smoke-client.mjs # client bundle + panel flows (React shim)
88
+ ```
89
+
90
+ Before modifying `lib/client.js`, see [docs/design.md](docs/design.md) for the bundle contract.
91
+
92
+ ## Documentation
93
+
94
+ - [docs/design.md](docs/design.md) — architecture and security model
95
+ - 中文说明:[README.zh.md](README.zh.md)
96
+
97
+ ## Contributing
98
+
99
+ See [CONTRIBUTING.md](CONTRIBUTING.md). Issues and pull requests are welcome.
100
+
101
+ ## License
102
+
103
+ [MIT](LICENSE)
package/README.zh.md ADDED
@@ -0,0 +1,103 @@
1
+ # dsh-multi-folder
2
+
3
+ [English](README.md) | **中文**
4
+
5
+ > 为 DeepSeek Harness 项目提供**副工作目录**——不离开主工作区,同时编辑源码库、测试库与文档库。
6
+
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
8
+ [![Node.js >= 20](https://img.shields.io/badge/Node.js-%3E%3D20-brightgreen)](https://nodejs.org/)
9
+
10
+ 一个 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 插件 bundle,为一个 Project(工作区)提供一组**副工作目录**:
11
+
12
+ - Agent 的核心 `cwd` 等属性始终指向**主工作目录**;
13
+ - 在 **Workspace Write** 模式下,Agent 对配置的副工作目录拥有与主工作目录**同等的读取、写入、编辑与命令执行权限**——实现方式是重定向会话自身的沙箱策略根,因此每种模式语义都自然保持(`read-only` 依旧拒绝、`workspace-write` 放行、`danger-full-access` 放行);
14
+ - 目录列表**注入系统提示词**,每次组装按会话求值;
15
+ - 配置变更通过**不打断的消息队列**通知 Agent——在下一次消息边界(用户发送或工具调用结束)送达,且**仅在目录集合实际变化时**发送;
16
+ - **不新增任何工具**:改动全部位于框架级(工具流水线拦截)与 UI 级(会话级头部入口)。
17
+
18
+ ## 环境要求
19
+
20
+ - Node.js >= 20
21
+ - 由 `@deepseek-ai/dsh-base` + `@deepseek-ai/dsh-web-app` 组成的 DSH profile(或提供 `fs`、`sandboxPolicy`、`systemPrompt`、`commands`、`shell`、`shellEnv` 及标准 Web 客户端模块的等价组合)。
22
+
23
+ ## 安装
24
+
25
+ 将本仓库链接进 DSH profile:
26
+
27
+ ```bash
28
+ dsh plugin --profile web add link:<本仓库路径>
29
+ ```
30
+
31
+ 或从 npm 安装并把 `dsh-multi-folder` 加入 profile 的 `dsh.profile.bundles`:
32
+
33
+ ```bash
34
+ cd <dsh-home>/profiles/web
35
+ pnpm add dsh-multi-folder
36
+ ```
37
+
38
+ 然后**重启 DSH 后端**(宿主组合在进程启动时装载)并**刷新浏览器页面**(客户端 bundle 以 `no-cache` 提供)。
39
+
40
+ ## 使用
41
+
42
+ 会话头部出现「多工作目录」按钮,打开面板即可:
43
+
44
+ | 操作 | 行为 |
45
+ | ---- | ---- |
46
+ | 添加目录 | 打开原生目录选择器 |
47
+ | 移除 / 刷新 | 立即生效 |
48
+ | 切换会话 | 面板自动切换为该会话的副工作目录 |
49
+ | 重新打开面板 | 使用会话级缓存,不产生冗余命令行 |
50
+
51
+ 等价的用户斜杠命令:
52
+
53
+ ```
54
+ /multi-folder list
55
+ /multi-folder add "D:\path\to\repo"
56
+ /multi-folder remove "D:\path\to\repo"
57
+ /multi-folder set "D:\a" "D:\b"
58
+ ```
59
+
60
+ Agent 无需任何额外操作:`read` / `glob` / `grep` 随处可用;`write` / `edit` / `pwsh` / `bash` 在路径(或 `workdir`)落入副目录时自动拦截并以该目录为沙箱根执行。
61
+
62
+ ## 工作原理
63
+
64
+ - **拦截**——监听 `tools/execute` 环绕分派瀑布,对解析路径(或 `workdir`)落在副目录内的 `write` / `edit` / `pwsh` / `bash` 调用短路,并以**换根后的会话站立策略**(`{ ...standingPolicy, workspaceRoot: secondaryDir }`)执行。模式本身不变,因此各种沙箱模式与主工作区的语义天然一致。匹配前先经 `fs.resolve` + `processPath` 规范化,`..`、符号链接与大小写差异均正确处理。
65
+ - **提示词注入**——一个有序 `systemPrompt` 段落,text provider 每次组装按会话求值,仅为配置了副目录的会话渲染。
66
+ - **通知**——命令处理器仅在目录集合实际变化时置位 pending notice;`agent/pre-step`(前置注入进入批次)与 `tools/post-execute`(附加为 `additionalContexts`)两个通道中先触发者消费——均使用框架原生的插件来源 `notice` 上下文。
67
+ - **配置与安全边界**——per-workspace 配置存储于 Agent 沙箱之外的宿主自有目录(`<DSH_HOME>/storages/multi-folder/<workspace-key>.json`)。对配置文件的任何直接 `write`/`edit` 都会收到显式拒绝——**Agent 永远无法自我授予目录,配置权仅属于用户**。详见 [SECURITY.md](SECURITY.md)。
68
+ - **客户端**——手写维护的 factory bundle(`window.__ModuleLoader__.load`),无需构建工具链;面板经 Remote BFF(`ctx.remote.commands.execute`)驱动宿主。
69
+
70
+ ## 目录结构
71
+
72
+ | 路径 | 作用 |
73
+ | ---- | ---- |
74
+ | `cordis.patch.yml` | profile patch 层,插入 `dsh-multi-folder` 行 |
75
+ | `lib/index.js` | 宿主插件:配置存储、工具流水线拦截、提示词注入、双通道通知、`/multi-folder` 命令 |
76
+ | `lib/client.js` | 客户端插件(factory bundle):会话头部按钮 + 覆盖层面板 |
77
+ | `test/` | 免 DSH 运行时的行为测试(见开发) |
78
+ | `docs/` | 设计与分析文档 |
79
+
80
+ ## 开发
81
+
82
+ 零构建步骤:宿主半边为纯 ESM,`lib/client.js` 为 DSH client-modules 格式的手写 factory bundle。测试直接用 Node 运行:
83
+
84
+ ```bash
85
+ node test/smoke-host.mjs # 宿主 apply 冒烟
86
+ node test/intercept.mjs # 拦截 / 命令 / 通知行为
87
+ node test/smoke-client.mjs # 客户端 bundle 与面板流程(React shim)
88
+ ```
89
+
90
+ 修改 `lib/client.js` 前请先阅读 [docs/design.md](docs/design.md) 中的 bundle 契约。
91
+
92
+ ## 文档
93
+
94
+ - [docs/design.md](docs/design.md) — 架构与安全模型(英文)
95
+ - English README: [README.md](README.md)
96
+
97
+ ## 参与贡献
98
+
99
+ 见 [CONTRIBUTING.md](CONTRIBUTING.md)。欢迎提交 issue 与 PR。
100
+
101
+ ## 许可证
102
+
103
+ [MIT](LICENSE)
package/SECURITY.md ADDED
@@ -0,0 +1,57 @@
1
+ # Security Policy
2
+
3
+ ## Security model
4
+
5
+ `dsh-multi-folder` widens the agent's filesystem reach **only** to directories that the
6
+ **user explicitly configured**, and **only** within the sandbox mode the user already
7
+ granted the session. The design enforces four boundaries:
8
+
9
+ 1. **User-only configuration.** Secondary directories can only be added or removed by
10
+ the user through the UI panel or the `/multi-folder` slash command. The agent has no
11
+ tool, command, or file path through which it can change the configuration.
12
+
13
+ 2. **Host-owned configuration store.** Per-workspace configuration lives in
14
+ `<DSH_HOME>/storages/multi-folder/<workspace-key>.json` — outside every agent
15
+ sandbox root. The agent's own tools cannot read-write there under `read-only` or
16
+ `workspace-write` (the sandbox fences the write path by the workspace root).
17
+
18
+ 3. **Explicit write guard.** `write` / `edit` calls targeting the configuration
19
+ file are short-circuited by the tool-pipeline interception with an explicit
20
+ rejection message, independent of the session mode. This turns any attempt at
21
+ self-escalation into a visible, explainable error instead of a silent no-op or a
22
+ silent success.
23
+
24
+ 4. **Mode parity, never escalation.** An intercepted secondary-directory operation runs
25
+ under the session's standing sandbox policy with only the `workspaceRoot` re-pointed
26
+ at the configured directory. The mode is preserved verbatim: a `read-only` session
27
+ is still denied in secondary directories, a `workspace-write` session gains the same
28
+ write/exec rights it has in its primary workspace, and only `danger-full-access`
29
+ bypasses confinement (as it already does for the primary workspace, by the user's
30
+ explicit choice).
31
+
32
+ ### What is deliberately out of scope
33
+
34
+ - In a `danger-full-access` session the agent can already touch the whole filesystem;
35
+ this plugin neither adds nor removes anything there.
36
+ - The agent can *read* the configuration file (reads are not policy-fenced in the DSH
37
+ filesystem backend). Reading reveals nothing the system prompt does not already list
38
+ for that session.
39
+
40
+ ## Reporting a vulnerability
41
+
42
+ If you believe you have found a security issue in this plugin, please report it
43
+ privately by opening a GitHub Security Advisory on the repository instead of a public
44
+ issue. Please include:
45
+
46
+ - the affected version,
47
+ - a minimal reproduction,
48
+ - the expected vs. observed behavior.
49
+
50
+ We will acknowledge the report within 7 days and aim to publish a fix (or a
51
+ documented mitigation) before public disclosure.
52
+
53
+ ## Supported versions
54
+
55
+ | Version | Supported |
56
+ | ------- | --------- |
57
+ | 0.1.x | ✅ |
@@ -0,0 +1,8 @@
1
+ # dsh-multi-folder bundle patch.
2
+ #
3
+ # Applied as a profile patch layer after every in-box bundle. Inserts the
4
+ # plugin row at the profile root; later layers (or the user's own
5
+ # cordis.patch.yml) can address it by id with the last write winning.
6
+ - insert:
7
+ - id: dsh-multi-folder
8
+ name: 'dsh-multi-folder'
package/docs/design.md ADDED
@@ -0,0 +1,133 @@
1
+ # Design
2
+
3
+ Architecture and invariants of `dsh-multi-folder`.
4
+
5
+ ## Goal
6
+
7
+ One DSH project (workspace) gains a user-managed set of **secondary working
8
+ directories**. The agent's core `cwd` stays the primary workspace; framework-level
9
+ interception makes the existing tools work inside the secondary directories under the
10
+ session's current sandbox mode; prompt injection and boundary notifications keep the
11
+ agent informed. No new tools are added.
12
+
13
+ ## Planes
14
+
15
+ | Half | File | Role |
16
+ | ---- | ---- | ---- |
17
+ | Host | `lib/index.js` | Config store, tool-pipeline interception, prompt section, notifications, `/multi-folder` command |
18
+ | Client | `lib/client.js` | Session-header button + overlay panel; drives the host through the Remote BFF |
19
+
20
+ The package declares both faces: `dsh.bundle.patch` (the host row inserted by
21
+ `cordis.patch.yml`) and `dsh.client` (the web bundle at `exports["./client"]`).
22
+
23
+ ## Host: interception
24
+
25
+ A listener on the `tools/execute` around-dispatch waterfall handles `write`, `edit`,
26
+ `pwsh`, and `bash`:
27
+
28
+ 1. Resolve the session's standing policy via
29
+ `sandboxPolicy.resolve({ session: exec.agent.session })`.
30
+ 2. Canonicalize the target path (`write`/`edit`: `fs.resolve(file_path, { cwd: primary })`
31
+ + `fs.processPath`; `pwsh`/`bash`: the same treatment for the resolved `workdir`).
32
+ This makes `..`, symlinks, and case differences match correctly.
33
+ 3. **Config guard**: if the canonical path equals the host-owned config file,
34
+ short-circuit with an explicit rejection (see Security).
35
+ 4. If the canonical path is inside a configured secondary directory, execute the
36
+ operation directly with `{ ...standingPolicy, workspaceRoot: <secondary dir> }`:
37
+ - `write`/`edit` → `fs.writeText` / `fs.editText`;
38
+ - `pwsh`/`bash` → `shell.resolve({ command, workdir, dshEnv, sandboxPolicy })` +
39
+ `shell.run`, with the canonical workdir so the confinement root and the process
40
+ cwd agree exactly.
41
+ The result carries the same canonical value/content shapes as the shipped tools, so
42
+ downstream presentation keeps working.
43
+ 5. Anything else — unknown tools, paths outside every secondary directory, escalation
44
+ arguments (`sandbox_permissions`), `run_in_background`, missing optional services,
45
+ or any error — falls through to `next()` and the default pipeline.
46
+
47
+ **Why mode parity is free:** the mode field of the standing policy is never touched.
48
+ The DSH sandbox backends treat the per-call policy as fully specified and fence by its
49
+ `workspaceRoot` + `mode`. `read-only` sessions therefore keep getting denied in
50
+ secondary directories exactly as in the primary workspace.
51
+
52
+ Reads (`read`, `glob`, `grep`) need no interception: the DSH filesystem backend does
53
+ not policy-fence read paths.
54
+
55
+ ### Service resolution must be lazy
56
+
57
+ Loader rows activate in dependency order, and this row deliberately declares no hard
58
+ dependency on the shell executor or the command registry. Capturing `ctx.get('shell')`
59
+ at apply time can yield `undefined` when the provider row activates later. Therefore:
60
+
61
+ - `shell` / `shellEnv` are resolved **per call** inside the listener;
62
+ - the `/multi-folder` command is registered through
63
+ `ctx.inject(['commands'], ctx => ctx.commands.register(...))`, which activates
64
+ whenever the service appears and is disposed with the plugin fiber.
65
+
66
+ ## Host: configuration store
67
+
68
+ - Canonical location: `<DSH_HOME>/storages/multi-folder/<workspace-key>.json`
69
+ (`DSH_HOME` falls back to `~/.dsh`), i.e. **outside every agent sandbox root**.
70
+ - Writes happen only from the command handler (user-initiated) with an explicit
71
+ `workspace-write` policy rooted at the config directory.
72
+ - A per-process cache keyed by normalized workspace path hydrates lazily (on
73
+ `agent/created`, `agent/pre-step`, and `tools/execute`).
74
+
75
+ ## Host: prompt injection and notifications
76
+
77
+ - One global `systemPrompt.section` (`multi-folder:secondary-dirs`, order 160) whose
78
+ text provider evaluates per assembly: it reads `context.agent.session.header.cwd`
79
+ and renders the configured directories only for sessions that have them.
80
+ - Change notifications use the framework's plugin-sourced `notice` context:
81
+ - the command handler arms a pending notice **only when the directory set changed**;
82
+ - the next boundary consumes it — `agent/pre-step` prepends it to the entering
83
+ message batch, or `tools/post-execute` attaches it as `additionalContexts` —
84
+ whichever fires first. No turn is ever interrupted.
85
+
86
+ ## Client
87
+
88
+ `lib/client.js` is a **hand-maintained factory bundle** in the DSH client-modules
89
+ format — no build toolchain:
90
+
91
+ ```js
92
+ window.__ModuleLoader__.load({
93
+ id: 'dsh-multi-folder',
94
+ factory: (require) => { /* CJS-style module body; exports = { name, inject, apply } */ },
95
+ })
96
+ ```
97
+
98
+ - `inject: ['remote', 'remote.commands', 'slots', 'workspaces']`; the package's
99
+ `dsh.client.inject` lists the packages providing them
100
+ (`@deepseek-ai/dsh-api-gateway`, `@deepseek-ai/dsh-api-remotes`,
101
+ `@deepseek-ai/dsh-client-runtime`).
102
+ - UI registrations: `conversation.session.header.actions` (session-scoped button) and
103
+ `shell.overlay` (root-scoped panel). One module-level store is shared by both.
104
+ - Host communication: `ctx.remote.commands.execute(sessionId, line)`. The return value
105
+ is the RPC envelope `{ ok, value }` where `value` is the `CommandExecution`; command
106
+ result text carries a `[MF:JSON] {…}` line the panel parses for structured state.
107
+ - Session switch: a `React.useEffect` on `sessionId` re-points the open panel to the
108
+ current session, reusing the per-session cache (no command row) or refreshing.
109
+ - Per-session caching keeps pure reads (`list`) off the conversation: the command runs
110
+ only on first open per session or on explicit refresh; mutations return their data
111
+ directly and remain visible change records (command lifecycle events are log-only
112
+ and never reach the model).
113
+
114
+ ## Known limitations
115
+
116
+ - Intercepted secondary-directory writes bypass the fs observation policy: they emit
117
+ no `fs/observed` event and do not participate in the `fs/write-intent` intent guard.
118
+ This is deliberate — secondary directories sit outside the primary workspace's
119
+ observation domain.
120
+ - `presentationMeta` is not computed on the short-circuit path; tool cards fall back to
121
+ their default presentation.
122
+ - `run_in_background` and `sandbox_permissions` escalation on `pwsh`/`bash` calls in
123
+ secondary directories are passed through to the default pipeline.
124
+ - The `/multi-folder` command lifecycle rows (`command/run`, `command/done`) are
125
+ visible in the conversation UI by framework design; they are log-only and never
126
+ reach the model.
127
+
128
+ ## Tests
129
+
130
+ `test/smoke-host.mjs`, `test/intercept.mjs`, and `test/smoke-client.mjs` run without
131
+ the DSH runtime using mock services and a React shim. They cover interception,
132
+ canonicalization, the config guard, both notification channels, notice
133
+ gating, command flows, and the panel's session-switch/caching behavior.
package/lib/client.js ADDED
@@ -0,0 +1,344 @@
1
+ /**
2
+ * dsh-multi-folder — client half (hand-written factory bundle, no build step).
3
+ *
4
+ * Session-scoped UI: a "多工作目录" button in the conversation session header
5
+ * (`conversation.session.header.actions`, scope: session) that opens a panel in
6
+ * `shell.overlay` listing the project's secondary working directories.
7
+ * Mutations go through the Host `/multi-folder` command via the Remote BFF
8
+ * (`ctx.remote.commands.execute(sessionId, line)`); the Host answers with a
9
+ * human-readable result carrying a `[MF:JSON]` line the panel parses for
10
+ * structured state.
11
+ *
12
+ * Layout shape: an entry in the session header action row plus a frame-wide
13
+ * overlay panel. Both read one tiny module-scoped store; opening the panel
14
+ * refreshes the list from the Host.
15
+ */
16
+ window.__ModuleLoader__.load({
17
+ id: 'dsh-multi-folder',
18
+ factory: (require) => {
19
+ var module = { exports: {} };
20
+ var exports = module.exports;
21
+ var React = require('react');
22
+
23
+ // ------------------------------------------------------------- store
24
+ var listeners = new Set();
25
+ var state = { open: false, sessionId: null, dirs: [], workspace: null, busy: false, error: null };
26
+ function patch(next) {
27
+ state = Object.assign({}, state, next);
28
+ listeners.forEach(function (fn) { fn(); });
29
+ }
30
+ function subscribe(fn) {
31
+ listeners.add(fn);
32
+ return function () { listeners.delete(fn); };
33
+ }
34
+ function getSnapshot() { return state; }
35
+ function useStore() { return React.useSyncExternalStore(subscribe, getSnapshot); }
36
+ /** sessionId -> { dirs, workspace }: avoids re-running the list command
37
+ * (and its conversation row) on every panel open. */
38
+ var sessionCache = {};
39
+
40
+ function parseJsonLine(text) {
41
+ if (typeof text !== 'string') return null;
42
+ var m = /\[MF:JSON\]\s*(\{.*\})\s*$/s.exec(text);
43
+ if (!m) return null;
44
+ try { return JSON.parse(m[1]); } catch (_) { return null; }
45
+ }
46
+
47
+ // ------------------------------------------------------------- plugin
48
+ var name = 'dsh-multi-folder';
49
+ var inject = ['remote', 'remote.commands', 'slots', 'workspaces'];
50
+
51
+ function apply(ctx) {
52
+ var slots = ctx.slots;
53
+ var remote = ctx.remote;
54
+ var workspaces = ctx.workspaces;
55
+
56
+ function runCommand(sessionId, line) {
57
+ return remote.commands.execute(sessionId, line).then(function (envelope) {
58
+ if (!envelope || envelope.ok !== true) {
59
+ throw new Error('multi-folder: ' + (envelope && envelope.error !== undefined ? String(envelope.error) : 'remote call failed'));
60
+ }
61
+ var execution = envelope.value;
62
+ if (execution === undefined || execution === null) {
63
+ throw new Error('multi-folder: command did not match — is the host plugin loaded?');
64
+ }
65
+ var result = execution.result;
66
+ if (!result) throw new Error('multi-folder: command returned no result');
67
+ if (result.kind === 'error') throw new Error(result.text || 'multi-folder: command failed');
68
+ return result.text || '';
69
+ });
70
+ }
71
+
72
+ function refresh(sessionId) {
73
+ if (!sessionId) return;
74
+ patch({ busy: true, error: null });
75
+ runCommand(sessionId, '/multi-folder list').then(function (text) {
76
+ var parsed = parseJsonLine(text);
77
+ if (parsed) {
78
+ sessionCache[sessionId] = { workspace: parsed.workspace, dirs: parsed.dirs };
79
+ }
80
+ patch({
81
+ busy: false,
82
+ workspace: parsed ? parsed.workspace : null,
83
+ dirs: parsed ? parsed.dirs : [],
84
+ });
85
+ }).catch(function (e) {
86
+ patch({ busy: false, error: String(e && e.message ? e.message : e) });
87
+ });
88
+ }
89
+
90
+ function mutate(sessionId, line) {
91
+ patch({ busy: true, error: null });
92
+ return runCommand(sessionId, line).then(function (text) {
93
+ var parsed = parseJsonLine(text);
94
+ if (parsed) {
95
+ sessionCache[sessionId] = { workspace: parsed.workspace, dirs: parsed.dirs };
96
+ patch({
97
+ busy: false,
98
+ workspace: parsed.workspace,
99
+ dirs: parsed.dirs,
100
+ });
101
+ } else {
102
+ refresh(sessionId);
103
+ }
104
+ }).catch(function (e) {
105
+ patch({ busy: false, error: String(e && e.message ? e.message : e) });
106
+ refresh(sessionId);
107
+ });
108
+ }
109
+
110
+ function addDirectory(sessionId) {
111
+ workspaces.pickDirectory().then(function (path) {
112
+ if (path === null || path === undefined) return;
113
+ mutate(sessionId, '/multi-folder add "' + path.replace(/"/g, '\\"') + '"');
114
+ }).catch(function (e) {
115
+ patch({ error: String(e && e.message ? e.message : e) });
116
+ });
117
+ }
118
+
119
+ /** Open the panel for a session, reusing cached data when present so
120
+ * pure reads do not produce conversation rows. */
121
+ function openFor(sessionId) {
122
+ if (!sessionId) return;
123
+ var cached = sessionCache[sessionId];
124
+ patch({
125
+ open: true,
126
+ sessionId: sessionId,
127
+ dirs: cached ? cached.dirs : [],
128
+ workspace: cached ? cached.workspace : null,
129
+ error: null,
130
+ });
131
+ if (cached === undefined) refresh(sessionId);
132
+ }
133
+
134
+ // Header button -----------------------------------------------------
135
+ function HeaderButton(props) {
136
+ var store = useStore();
137
+ var sessionId = props.sessionId;
138
+ var open = store.open && store.sessionId === sessionId;
139
+ // Session switch: keep the panel in sync with the session this
140
+ // header belongs to. On a changed sessionId (or first mount) with
141
+ // the panel open, switch the panel content to this session, reusing
142
+ // cached data (no conversation row) or refreshing from the Host.
143
+ React.useEffect(
144
+ function () {
145
+ var current = getSnapshot();
146
+ if (current.open && current.sessionId !== sessionId) {
147
+ openFor(sessionId);
148
+ }
149
+ },
150
+ [sessionId],
151
+ );
152
+ var btn = React.createElement(
153
+ 'button',
154
+ {
155
+ type: 'button',
156
+ title: '多工作目录(副工作目录)',
157
+ onClick: function () {
158
+ var current = getSnapshot();
159
+ var isOpen = current.open && current.sessionId === sessionId;
160
+ if (isOpen) {
161
+ patch({ open: false });
162
+ } else {
163
+ openFor(sessionId);
164
+ }
165
+ },
166
+ style: {
167
+ display: 'inline-flex',
168
+ alignItems: 'center',
169
+ gap: 6,
170
+ padding: '4px 10px',
171
+ borderRadius: 8,
172
+ border: '1px solid var(--color-border, rgba(127,127,127,0.35))',
173
+ background: open ? 'var(--color-bg-accent, rgba(80,120,255,0.18))' : 'transparent',
174
+ color: 'var(--color-text, inherit)',
175
+ fontSize: 13,
176
+ cursor: 'pointer',
177
+ },
178
+ },
179
+ open ? '多工作目录 ▾' : '多工作目录',
180
+ );
181
+ return btn;
182
+ }
183
+
184
+ // Overlay panel ------------------------------------------------------
185
+ function Panel() {
186
+ var store = useStore();
187
+ if (!store.open || !store.sessionId) return null;
188
+ var rows = (store.dirs || []).map(function (dir, index) {
189
+ return React.createElement(
190
+ 'div',
191
+ {
192
+ key: index,
193
+ style: {
194
+ display: 'flex',
195
+ alignItems: 'center',
196
+ gap: 8,
197
+ padding: '6px 8px',
198
+ borderRadius: 6,
199
+ background: 'var(--color-bg-subtle, rgba(127,127,127,0.08))',
200
+ marginBottom: 6,
201
+ },
202
+ },
203
+ React.createElement(
204
+ 'div',
205
+ {
206
+ title: dir,
207
+ style: { flex: 1, fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
208
+ },
209
+ dir,
210
+ ),
211
+ React.createElement(
212
+ 'button',
213
+ {
214
+ type: 'button',
215
+ title: '移除此副工作目录',
216
+ onClick: function () { mutate(store.sessionId, '/multi-folder remove "' + dir.replace(/"/g, '\\"') + '"'); },
217
+ style: { padding: '2px 8px', borderRadius: 6, border: '1px solid transparent', background: 'transparent', color: 'var(--color-danger, #c62828)', cursor: 'pointer' },
218
+ },
219
+ '移除',
220
+ ),
221
+ );
222
+ });
223
+ return React.createElement(
224
+ 'div',
225
+ {
226
+ style: {
227
+ position: 'fixed',
228
+ top: 64,
229
+ right: 20,
230
+ width: 380,
231
+ maxWidth: 'calc(100vw - 40px)',
232
+ zIndex: 400,
233
+ background: 'var(--color-bg-elevated, #ffffff)',
234
+ color: 'var(--color-text, #1a1a1a)',
235
+ border: '1px solid var(--color-border, rgba(127,127,127,0.35))',
236
+ borderRadius: 12,
237
+ boxShadow: '0 8px 32px rgba(0,0,0,0.18)',
238
+ padding: 14,
239
+ fontSize: 13,
240
+ },
241
+ },
242
+ React.createElement(
243
+ 'div',
244
+ { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 } },
245
+ React.createElement('div', { style: { fontWeight: 600, fontSize: 14 } }, '多工作目录(副工作目录)'),
246
+ React.createElement(
247
+ 'button',
248
+ {
249
+ type: 'button',
250
+ title: '关闭',
251
+ onClick: function () { patch({ open: false }); },
252
+ style: { padding: '2px 8px', borderRadius: 6, border: '1px solid transparent', background: 'transparent', cursor: 'pointer' },
253
+ },
254
+ '✕',
255
+ ),
256
+ ),
257
+ store.workspace
258
+ ? React.createElement(
259
+ 'div',
260
+ { style: { marginBottom: 8, color: 'var(--color-text-muted, #6b6b6b)', fontSize: 12, wordBreak: 'break-all' } },
261
+ '项目:' + store.workspace,
262
+ )
263
+ : null,
264
+ rows.length > 0 ? rows : React.createElement('div', { style: { marginBottom: 8, color: 'var(--color-text-muted, #6b6b6b)' } }, '尚未配置副工作目录。'),
265
+ store.error
266
+ ? React.createElement('div', { style: { marginBottom: 8, color: 'var(--color-danger, #c62828)', whiteSpace: 'pre-wrap' } }, String(store.error))
267
+ : null,
268
+ React.createElement(
269
+ 'div',
270
+ { style: { display: 'flex', gap: 8, marginTop: 4 } },
271
+ React.createElement(
272
+ 'button',
273
+ {
274
+ type: 'button',
275
+ disabled: !!store.busy,
276
+ onClick: function () { addDirectory(store.sessionId); },
277
+ style: {
278
+ flex: 1,
279
+ padding: '6px 10px',
280
+ borderRadius: 8,
281
+ border: '1px solid var(--color-border, rgba(127,127,127,0.35))',
282
+ background: 'var(--color-bg-accent, rgba(80,120,255,0.14))',
283
+ color: 'var(--color-text, inherit)',
284
+ cursor: store.busy ? 'default' : 'pointer',
285
+ opacity: store.busy ? 0.6 : 1,
286
+ },
287
+ },
288
+ store.busy ? '处理中…' : '+ 添加目录',
289
+ ),
290
+ React.createElement(
291
+ 'button',
292
+ {
293
+ type: 'button',
294
+ disabled: !!store.busy,
295
+ onClick: function () { refresh(store.sessionId); },
296
+ style: {
297
+ padding: '6px 10px',
298
+ borderRadius: 8,
299
+ border: '1px solid var(--color-border, rgba(127,127,127,0.35))',
300
+ background: 'transparent',
301
+ color: 'var(--color-text, inherit)',
302
+ cursor: store.busy ? 'default' : 'pointer',
303
+ opacity: store.busy ? 0.6 : 1,
304
+ },
305
+ },
306
+ '刷新',
307
+ ),
308
+ ),
309
+ React.createElement(
310
+ 'div',
311
+ {
312
+ style: {
313
+ marginTop: 10,
314
+ fontSize: 11,
315
+ lineHeight: 1.5,
316
+ color: 'var(--color-text-muted, #6b6b6b)',
317
+ },
318
+ },
319
+ 'Agent 的主工作目录不变;在 Workspace Write 模式下,Agent 对上述目录拥有与主工作目录同等的读写与命令执行权限。配置变更会在下一条消息或工具调用结束时通知 Agent。',
320
+ ),
321
+ );
322
+ }
323
+
324
+ // Registrations ------------------------------------------------------
325
+ slots.inject('conversation.session.header.actions', function () {
326
+ return slots.register(
327
+ { name: 'conversation.session.header.actions', id: 'multi-folder', order: 30, label: '多工作目录' },
328
+ function (props) { return React.createElement(HeaderButton, props); },
329
+ );
330
+ });
331
+ slots.inject('shell.overlay', function () {
332
+ return slots.register(
333
+ { name: 'shell.overlay', id: 'multi-folder', order: 100, label: '多工作目录' },
334
+ function () { return React.createElement(Panel); },
335
+ );
336
+ });
337
+ }
338
+
339
+ exports.name = name;
340
+ exports.inject = inject;
341
+ exports.apply = apply;
342
+ return module.exports;
343
+ },
344
+ });
package/lib/index.js ADDED
@@ -0,0 +1,496 @@
1
+ /**
2
+ * dsh-multi-folder — host half.
3
+ *
4
+ * Secondary working directories for a project, delivered as framework-level
5
+ * and UI-level changes only (no new tools):
6
+ *
7
+ * 1. Per-workspace config in a HOST-OWNED store outside the agent's sandbox
8
+ * (`<DSH_HOME>/storages/multi-folder/<workspace-key>.json`, JSON array of
9
+ * absolute secondary directory paths), cached in memory and hydrated
10
+ * lazily per session. Direct write/edit attempts against the config file
11
+ * are rejected with an explicit message, so the agent can NEVER
12
+ * self-grant directories — configuration is user-managed by design.
13
+ * 2. Tool-pipeline interception (`tools/execute` around-dispatch waterfall):
14
+ * `write` / `edit` / `pwsh` / `bash` calls whose path (or resolved
15
+ * `workdir`) lands inside a configured secondary directory are serviced
16
+ * here with the session's standing sandbox policy re-rooted to that
17
+ * directory — identical semantics to the primary workspace in every mode
18
+ * (read-only denies, workspace-write allows, danger-full-access allows).
19
+ * Reads (read/glob/grep) are unfenced and already work.
20
+ * 3. Prompt injection: one ordered system-prompt section rendered per
21
+ * assembly from the configured directories of the assembling session.
22
+ * 4. Non-interrupting change notification: configuration changes made via
23
+ * the `/multi-folder` command arm a pending notice — only when the
24
+ * directory set actually changed — delivered at the NEXT message
25
+ * boundary: the next `agent/pre-step` (user send) or the next
26
+ * `tools/post-execute` (tool-call end), through the framework's native
27
+ * plugin-sourced `notice` context channel.
28
+ * 5. `/multi-folder` command (list/add/remove/set): the human-command
29
+ * registry entry the browser UI drives through the Remote BFF.
30
+ */
31
+
32
+ import { join } from 'node:path'
33
+ import os from 'node:os'
34
+
35
+ export const name = 'dsh-multi-folder'
36
+ export const inject = ['fs', 'sandboxPolicy', 'systemPrompt']
37
+
38
+ /** Host-owned store, outside every agent sandbox root. */
39
+ const configDir = () => join(process.env.DSH_HOME || join(os.homedir(), '.dsh'), 'storages', 'multi-folder')
40
+ const configFileName = (ws) => String(ws).replace(/[^a-z0-9]+/gi, '-').toLowerCase() + '.json'
41
+ const configPathFor = (ws) => join(configDir(), configFileName(ws))
42
+ const SECTION_NAME = 'multi-folder:secondary-dirs'
43
+ /** Tool guidance sections use orders 100–129; sit clearly after them. */
44
+ const SECTION_ORDER = 160
45
+ const COMMAND_NAME = 'multi-folder'
46
+ const INTERCEPT_TOOLS = new Set(['write', 'edit', 'pwsh', 'bash'])
47
+ /** Marker line the browser UI parses out of command results. */
48
+ const JSON_MARK = '[MF:JSON]'
49
+ const CONFIG_GUARD_TEXT =
50
+ 'This file is managed by the dsh-multi-folder plugin. Secondary working directories may only be ' +
51
+ 'configured by the user through the UI panel or the /multi-folder command; direct edits are rejected.'
52
+
53
+ export function apply(ctx) {
54
+ const { fs, sandboxPolicy, systemPrompt } = ctx
55
+ // NOTE: shell, shellEnv, and commands are deliberately NOT captured here.
56
+ // Loader rows activate in dependency order, and this row declares no hard
57
+ // dependency on those services — capturing them at apply time can yield
58
+ // `undefined` when their provider rows activate later. The shell executor
59
+ // is resolved per call below, and the command is registered through
60
+ // ctx.inject so it activates whenever the commands service appears.
61
+
62
+ let noteSeq = 0
63
+ /** wsKey(primary) -> { dirs: string[] } */
64
+ const dirsCache = new Map()
65
+ /** String(sessionId) -> notice text awaiting the next message boundary. */
66
+ const pendingNotices = new Map()
67
+
68
+ // ---------------------------------------------------------------- helpers
69
+ const wsKey = (p) => String(p).replace(/\\/g, '/').toLowerCase().replace(/\/+$/, '')
70
+ const isAbsolute = (p) => /^[A-Za-z]:[\\/]/.test(p) || p.startsWith('/') || p.startsWith('\\\\')
71
+ const pathInside = (p, root) => {
72
+ const P = wsKey(p)
73
+ const R = wsKey(root)
74
+ return P === R || P.startsWith(R + '/')
75
+ }
76
+ const displayPathOf = (target, fallback) =>
77
+ target.displayPath !== undefined && target.displayPath !== null ? String(target.displayPath) : fallback
78
+ const longestRootFirst = (dirs) => [...dirs].sort((a, b) => b.length - a.length)
79
+
80
+ // ----------------------------------------------------------- config store
81
+ function sanitizeDirs(list, ws) {
82
+ const out = []
83
+ const seen = new Set()
84
+ for (const item of list) {
85
+ if (typeof item !== 'string' || item.trim().length === 0) continue
86
+ const abs = item.trim()
87
+ if (!isAbsolute(abs)) continue
88
+ if (wsKey(abs) === wsKey(ws)) continue // never the primary workspace itself
89
+ const key = wsKey(abs)
90
+ if (seen.has(key)) continue
91
+ seen.add(key)
92
+ out.push(abs)
93
+ }
94
+ return out
95
+ }
96
+
97
+ /** Canonical absolute path for a user-supplied directory (handles `..`, symlinks). */
98
+ async function canonicalizeAbs(path) {
99
+ try {
100
+ const target = await fs.resolve(path.trim())
101
+ return fs.processPath(target)
102
+ } catch {
103
+ return null
104
+ }
105
+ }
106
+
107
+ async function loadDirs(ws) {
108
+ if (typeof ws !== 'string' || ws.length === 0) return { dirs: [] }
109
+ const key = wsKey(ws)
110
+ const cached = dirsCache.get(key)
111
+ if (cached !== undefined) return cached
112
+ const fresh = { dirs: [] }
113
+ try {
114
+ const target = await fs.resolve(configPathFor(ws))
115
+ const raw = await fs.readText(target)
116
+ const parsed = JSON.parse(raw)
117
+ if (Array.isArray(parsed)) fresh.dirs = sanitizeDirs(parsed, ws)
118
+ } catch {
119
+ // absent or unreadable config -> empty list
120
+ }
121
+ dirsCache.set(key, fresh)
122
+ return fresh
123
+ }
124
+
125
+ function hydrate(ws) {
126
+ if (typeof ws === 'string' && ws.length > 0) void loadDirs(ws)
127
+ }
128
+
129
+ async function saveDirs(ws, dirs) {
130
+ const content = JSON.stringify(dirs, null, 2) + '\n'
131
+ const target = await fs.resolve(configPathFor(ws))
132
+ // Config writes are user-initiated (via the UI command); the store lives
133
+ // outside every agent sandbox root, so the explicit policy is rooted at
134
+ // the host-owned config directory itself.
135
+ await fs.writeText(target, content, undefined, undefined, {
136
+ mode: 'workspace-write',
137
+ workspaceRoot: configDir(),
138
+ })
139
+ }
140
+
141
+ function dirsForSync(ws) {
142
+ if (typeof ws !== 'string' || ws.length === 0) return null
143
+ const entry = dirsCache.get(wsKey(ws))
144
+ if (entry === undefined || entry.dirs.length === 0) return null
145
+ return entry.dirs
146
+ }
147
+
148
+ function dirsText(ws, dirs) {
149
+ if (dirs.length === 0) return 'No secondary working directories configured for ' + ws + '.'
150
+ return 'Secondary working directories for ' + ws + ':\n' + dirs.map((d) => '- ' + d).join('\n')
151
+ }
152
+
153
+ // -------------------------------------------------------- notice channel
154
+ const noticeMessage = (text) => ({
155
+ id: 'mf-note-' + (++noteSeq),
156
+ role: 'user',
157
+ content: [{ type: 'text', text }],
158
+ source: {
159
+ kind: 'plugin',
160
+ plugin: name,
161
+ form: 'notice',
162
+ summary: String(text).split('\n')[0].slice(0, 120),
163
+ },
164
+ })
165
+
166
+ const armNotice = (agent, text) => {
167
+ if (!agent || !agent.session) return
168
+ pendingNotices.set(String(agent.session.id), text)
169
+ }
170
+
171
+ const takeNotice = (agent) => {
172
+ if (!agent || !agent.session) return undefined
173
+ const key = String(agent.session.id)
174
+ const text = pendingNotices.get(key)
175
+ if (text !== undefined) pendingNotices.delete(key)
176
+ return text
177
+ }
178
+
179
+ // ------------------------------------------------------ prompt injection
180
+ systemPrompt.section({
181
+ name: SECTION_NAME,
182
+ order: SECTION_ORDER,
183
+ text: (context) => {
184
+ const ws =
185
+ context.agent && context.agent.session && context.agent.session.header
186
+ ? context.agent.session.header.cwd
187
+ : undefined
188
+ if (typeof ws !== 'string' || ws.length === 0) return ''
189
+ const dirs = dirsForSync(ws)
190
+ if (dirs === null) return ''
191
+ return (
192
+ 'Secondary working directories are available in this session (dsh-multi-folder plugin):\n' +
193
+ dirs.map((d) => '- ' + d).join('\n') +
194
+ '\nYou have the SAME read/write/edit and command-execution permissions on these directories as on the primary workspace under the current sandbox mode. ' +
195
+ 'Use absolute paths inside them (or pass `workdir` to shell tools). The primary workspace remains the default working directory.'
196
+ )
197
+ },
198
+ })
199
+
200
+ // ----------------------------------------------- notification (pre-step)
201
+ ctx.on('agent/pre-step', async (payload, next) => {
202
+ if (payload.agent && payload.agent.session && payload.agent.session.header) {
203
+ hydrate(payload.agent.session.header.cwd)
204
+ }
205
+ const decision = await next()
206
+ if (decision.kind !== 'enter') return decision
207
+ const text = takeNotice(payload.agent)
208
+ if (text === undefined) return decision
209
+ return { kind: 'enter', messages: [noticeMessage(text), ...decision.messages] }
210
+ })
211
+
212
+ // --------------------------------------- notification (tool-call boundary)
213
+ ctx.on('tools/post-execute', async (exec, result, next) => {
214
+ const decision = await next()
215
+ const text = takeNotice(exec.agent)
216
+ if (text === undefined) return decision
217
+ const msg = noticeMessage(text)
218
+ if (decision.kind === 'block') {
219
+ return {
220
+ kind: 'block',
221
+ feedback: decision.feedback,
222
+ additionalContexts: [msg, ...(decision.additionalContexts || [])],
223
+ }
224
+ }
225
+ return { ...decision, additionalContexts: [msg, ...(decision.additionalContexts || [])] }
226
+ })
227
+
228
+ // ------------------------------------------------- tool-pipeline intercept
229
+ const shellRender = (value) => {
230
+ let text = value.stdout && typeof value.stdout.text === 'string' ? value.stdout.text : ''
231
+ if (value.stderr && typeof value.stderr.text === 'string' && value.stderr.text.length > 0) {
232
+ text += text.endsWith('\n') ? '' : '\n'
233
+ text += value.stderr.text
234
+ }
235
+ if (value.exitCode !== 0) {
236
+ text += text.endsWith('\n') ? '' : '\n'
237
+ text += '[exit code: ' + value.exitCode + ']'
238
+ }
239
+ if (value.sandbox && value.sandbox.denied) {
240
+ text += '\n[sandbox: file access denied under ' + value.sandbox.mode + ' mode]'
241
+ }
242
+ return text
243
+ }
244
+
245
+ ctx.on('tools/execute', async (exec, next) => {
246
+ if (exec.agent && exec.agent.session && exec.agent.session.header) {
247
+ hydrate(exec.agent.session.header.cwd)
248
+ }
249
+ if (!INTERCEPT_TOOLS.has(exec.name)) return next()
250
+ try {
251
+ const args = exec.arguments
252
+ const standing = sandboxPolicy.resolve(exec.agent ? { session: exec.agent.session } : {})
253
+ const primary = standing.workspaceRoot
254
+ // An explicit escalation request belongs to the default pipeline.
255
+ if (args && args.sandbox_permissions !== undefined) return next()
256
+
257
+ if (exec.name === 'write' || exec.name === 'edit') {
258
+ const filePath = args && typeof args.file_path === 'string' ? args.file_path : null
259
+ if (filePath === null) return next()
260
+ // Resolve first so `..`, symlinks, and case differences canonicalize
261
+ // before containment matching (same cwd the shipped tools use).
262
+ const target = await fs.resolve(filePath, { cwd: primary })
263
+ const abs = fs.processPath(target)
264
+ // Security boundary: configuration is user-managed. Reject direct
265
+ // write/edit attempts against the host-owned config file, even before
266
+ // any directory matching.
267
+ if (wsKey(abs) === wsKey(configPathFor(primary))) {
268
+ return {
269
+ isError: true,
270
+ error: { message: 'multi-folder configuration is user-managed' },
271
+ content: [{ type: 'text', text: CONFIG_GUARD_TEXT }],
272
+ }
273
+ }
274
+ const dirs = dirsForSync(primary)
275
+ if (dirs === null) return next()
276
+ const hit = longestRootFirst(dirs).find((d) => pathInside(abs, d))
277
+ if (hit === undefined) return next()
278
+ const policy = { ...standing, workspaceRoot: hit }
279
+
280
+ if (exec.name === 'write') {
281
+ const outcome = await fs.writeText(target, String(args.content), undefined, exec.signal, policy)
282
+ const displayPath = displayPathOf(target, filePath)
283
+ const value = {
284
+ path: displayPath,
285
+ operation: outcome.operation === 'create' ? 'create' : 'update',
286
+ before: outcome.before === undefined || outcome.before === null ? null : outcome.before,
287
+ after: outcome.after === undefined ? null : outcome.after,
288
+ }
289
+ const content = [{
290
+ type: 'text',
291
+ text:
292
+ '<path>' + displayPath + '</path>\n<type>file</type>\n<content>\n' +
293
+ (outcome.operation === 'create' ? 'Created' : 'Updated') +
294
+ ' file\n</content>',
295
+ }]
296
+ return { isError: false, value, content }
297
+ }
298
+
299
+ const oldString = args && typeof args.old_string === 'string' ? args.old_string : null
300
+ const newString = args && typeof args.new_string === 'string' ? args.new_string : null
301
+ if (oldString === null || newString === null) return next()
302
+ const replaceAll = args.replace_all === true
303
+ const outcome = await fs.editText(
304
+ target,
305
+ { oldString, newString, replaceAll },
306
+ undefined,
307
+ exec.signal,
308
+ policy,
309
+ )
310
+ const displayPath = displayPathOf(target, filePath)
311
+ const value = { path: displayPath, before: outcome.before, after: outcome.after }
312
+ const text = replaceAll
313
+ ? 'The file ' + displayPath + ' has been updated. All occurrences were successfully replaced.'
314
+ : 'The file ' + displayPath + ' has been updated successfully.'
315
+ return { isError: false, value, content: [{ type: 'text', text }] }
316
+ }
317
+
318
+ if (exec.name === 'pwsh' || exec.name === 'bash') {
319
+ const shell = ctx.get('shell')
320
+ if (shell === undefined) return next()
321
+ if (args && args.run_in_background === true) return next()
322
+ const dirs = dirsForSync(primary)
323
+ if (dirs === null) return next()
324
+ const rawWorkdir = args && typeof args.workdir === 'string' ? args.workdir : null
325
+ const joined = rawWorkdir === null
326
+ ? String(primary)
327
+ : isAbsolute(rawWorkdir)
328
+ ? rawWorkdir
329
+ : String(primary).replace(/[\\/]+$/, '') + '/' + rawWorkdir
330
+ // Canonicalize before containment matching, then run in the canonical
331
+ // directory so confinement root and process cwd agree exactly.
332
+ const workdirTarget = await fs.resolve(joined, { cwd: primary })
333
+ const absWorkdir = fs.processPath(workdirTarget)
334
+ const hit = longestRootFirst(dirs).find((d) => pathInside(absWorkdir, d))
335
+ if (hit === undefined) return next()
336
+ const policy = { ...standing, workspaceRoot: hit }
337
+ const shellEnv = ctx.get('shellEnv')
338
+ const request = {
339
+ command: String(args.command),
340
+ workdir: absWorkdir,
341
+ ...(args && args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {}),
342
+ ...(shellEnv !== undefined ? { dshEnv: shellEnv.collect(exec) } : {}),
343
+ sandboxPolicy: policy,
344
+ }
345
+ const result = await shell.run(shell.resolve({ ...request, signal: exec.signal }))
346
+ if (result.aborted) {
347
+ return {
348
+ isError: true,
349
+ error: { message: 'tool call aborted' },
350
+ content: [{ type: 'text', text: '[aborted]' }],
351
+ }
352
+ }
353
+ const stream = (s) => ({
354
+ text: s && typeof s.text === 'string' ? s.text : '',
355
+ truncated: !!(s && s.truncated),
356
+ ...(s && s.spillPath !== undefined ? { spillPath: s.spillPath } : {}),
357
+ })
358
+ const value = {
359
+ kind: 'foreground',
360
+ exitCode: result.exitCode === undefined ? null : result.exitCode,
361
+ signal: result.signal === undefined ? null : result.signal,
362
+ timedOut: !!result.timedOut,
363
+ aborted: false,
364
+ timeoutMs: result.timeoutMs === undefined ? null : result.timeoutMs,
365
+ stdout: stream(result.stdout),
366
+ stderr: stream(result.stderr),
367
+ ...(result.sandbox !== undefined
368
+ ? {
369
+ sandbox: {
370
+ mode: String(result.sandbox.mode),
371
+ denied: !!result.sandbox.denied,
372
+ ...(result.sandbox.enforcement !== undefined
373
+ ? { enforcement: String(result.sandbox.enforcement) }
374
+ : {}),
375
+ ...(result.sandbox.runnerFailed !== undefined
376
+ ? { runnerFailed: !!result.sandbox.runnerFailed }
377
+ : {}),
378
+ },
379
+ }
380
+ : {}),
381
+ }
382
+ return { isError: false, value, content: [{ type: 'text', text: shellRender(value) }] }
383
+ }
384
+ return next()
385
+ } catch {
386
+ // Any interception failure falls back to the default pipeline.
387
+ return next()
388
+ }
389
+ })
390
+
391
+ // ---------------------------------------------------- hydration on start
392
+ ctx.on('agent/created', (payload) => {
393
+ const agent = payload && payload.agent
394
+ if (agent && agent.session && agent.session.header) hydrate(agent.session.header.cwd)
395
+ })
396
+
397
+ // ------------------------------------------------------ /multi-folder cmd
398
+ ctx.inject(['commands'], (c) => {
399
+ const commands = c.commands
400
+ const parseArgs = (raw) => {
401
+ const out = []
402
+ let cur = ''
403
+ let inQuote = false
404
+ for (const ch of String(raw)) {
405
+ if (ch === '"') {
406
+ inQuote = !inQuote
407
+ } else if (!inQuote && (ch === ' ' || ch === '\t')) {
408
+ if (cur.length > 0) {
409
+ out.push(cur)
410
+ cur = ''
411
+ }
412
+ } else {
413
+ cur += ch
414
+ }
415
+ }
416
+ if (cur.length > 0) out.push(cur)
417
+ return out
418
+ }
419
+
420
+ const jsonLine = (obj) => JSON_MARK + ' ' + JSON.stringify(obj)
421
+ const resultText = (ws, dirs, changed) =>
422
+ dirsText(ws, dirs) + '\n' + jsonLine({ workspace: ws, dirs, changed })
423
+
424
+ return commands.register({
425
+ name: COMMAND_NAME,
426
+ description:
427
+ 'Manage secondary working directories for this project (list / add <path> / remove <path> / set <paths...>). The agent gains workspace-write-equivalent access to these directories.',
428
+ input: { hint: '[list|add <path>|remove <path>|set <paths...>]' },
429
+ async handler(invocation) {
430
+ try {
431
+ const ws =
432
+ invocation.agent && invocation.agent.session && invocation.agent.session.header
433
+ ? String(invocation.agent.session.header.cwd)
434
+ : undefined
435
+ if (typeof ws !== 'string' || ws.length === 0) {
436
+ return { kind: 'error', text: 'multi-folder: session workspace is unknown' }
437
+ }
438
+ const entry = await loadDirs(ws)
439
+ const argv = parseArgs(invocation.rawInput)
440
+ const sub = argv.length === 0 ? 'list' : argv[0].toLowerCase()
441
+ let next
442
+ let changed = false
443
+ if (sub === 'list') {
444
+ return { kind: 'success', text: resultText(ws, entry.dirs, false) }
445
+ }
446
+ if (sub === 'add') {
447
+ const path = argv.slice(1).join(' ')
448
+ if (path.length === 0) return { kind: 'error', text: 'multi-folder: add requires a path' }
449
+ if (!isAbsolute(path)) return { kind: 'error', text: 'multi-folder: add requires an absolute path' }
450
+ const canonical = await canonicalizeAbs(path)
451
+ if (canonical === null) {
452
+ return { kind: 'error', text: 'multi-folder: cannot resolve path "' + path + '"' }
453
+ }
454
+ next = sanitizeDirs([...entry.dirs, canonical], ws)
455
+ changed = next.length !== entry.dirs.length
456
+ } else if (sub === 'remove') {
457
+ const path = argv.slice(1).join(' ')
458
+ if (path.length === 0) return { kind: 'error', text: 'multi-folder: remove requires a path' }
459
+ const canonical = await canonicalizeAbs(path)
460
+ const key = wsKey(canonical === null ? path : canonical)
461
+ next = entry.dirs.filter((d) => wsKey(d) !== key)
462
+ changed = next.length !== entry.dirs.length
463
+ } else if (sub === 'set') {
464
+ const canon = []
465
+ for (const p of argv.slice(1)) {
466
+ if (!isAbsolute(p)) return { kind: 'error', text: 'multi-folder: set requires absolute paths' }
467
+ const c = await canonicalizeAbs(p)
468
+ if (c === null) {
469
+ return { kind: 'error', text: 'multi-folder: cannot resolve path "' + p + '"' }
470
+ }
471
+ canon.push(c)
472
+ }
473
+ next = sanitizeDirs(canon, ws)
474
+ changed = JSON.stringify(next) !== JSON.stringify(entry.dirs)
475
+ } else {
476
+ return {
477
+ kind: 'error',
478
+ text: 'multi-folder: unknown subcommand "' + sub + '" (use list / add / remove / set)',
479
+ }
480
+ }
481
+ entry.dirs = next
482
+ if (changed) {
483
+ await saveDirs(ws, next)
484
+ armNotice(
485
+ invocation.agent,
486
+ 'Secondary working directories changed (dsh-multi-folder):\n' + dirsText(ws, next),
487
+ )
488
+ }
489
+ return { kind: 'success', text: resultText(ws, next, changed) }
490
+ } catch (e) {
491
+ return { kind: 'error', text: 'multi-folder: ' + String(e) }
492
+ }
493
+ },
494
+ })
495
+ })
496
+ }
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "dsh-multi-folder",
3
+ "version": "0.1.0",
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.",
5
+ "keywords": [
6
+ "dsh-plugin",
7
+ "dsh",
8
+ "deepseek-harness",
9
+ "multi-folder",
10
+ "secondary-workspace",
11
+ "workspace"
12
+ ],
13
+ "license": "MIT",
14
+ "author": "Yutong Zou <yutong.zou.24@alumni.ucl.ac.uk> (https://github.com/AngelosZou)",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/AngelosZou/dsh-multi-folder.git"
18
+ },
19
+ "bugs": {
20
+ "url": "https://github.com/AngelosZou/dsh-multi-folder/issues"
21
+ },
22
+ "homepage": "https://github.com/AngelosZou/dsh-multi-folder#readme",
23
+ "type": "module",
24
+ "main": "lib/index.js",
25
+ "exports": {
26
+ ".": {
27
+ "default": "./lib/index.js"
28
+ },
29
+ "./client": {
30
+ "default": "./lib/client.js"
31
+ },
32
+ "./cordis.patch.yml": "./cordis.patch.yml",
33
+ "./package.json": "./package.json"
34
+ },
35
+ "files": [
36
+ "lib",
37
+ "cordis.patch.yml",
38
+ "SECURITY.md",
39
+ "docs"
40
+ ],
41
+ "dsh": {
42
+ "bundle": {
43
+ "patch": "./cordis.patch.yml"
44
+ },
45
+ "client": {
46
+ "platform": "web",
47
+ "inject": [
48
+ "@deepseek-ai/dsh-api-gateway",
49
+ "@deepseek-ai/dsh-api-remotes",
50
+ "@deepseek-ai/dsh-client-runtime"
51
+ ]
52
+ }
53
+ },
54
+ "engines": {
55
+ "node": ">=20"
56
+ },
57
+ "peerDependencies": {
58
+ "@deepseek-ai/cordis": "^4.0.1"
59
+ }
60
+ }