dsh-multi-folder 0.1.0 → 0.1.1
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 +10 -15
- package/README.zh.md +10 -15
- package/SECURITY.md +5 -2
- package/docs/design.md +108 -18
- package/docs/upstream-hero-slot.md +120 -0
- package/lib/client.js +274 -16
- package/lib/index.js +163 -39
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -13,33 +13,27 @@ A [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) plugin bun
|
|
|
13
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
14
|
- The directory list is **injected into the system prompt** and re-rendered per session assembly.
|
|
15
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
|
+
- Configurable **before the session starts**: the session-creation page (new-session screen) offers a 「多工作目录」 entry that reads and edits the same per-workspace configuration through a **sessionless remote API** (`multiFolder/*` endpoints) — no session id required.
|
|
16
17
|
- **No new tools.** Everything is a framework-level change (tool-pipeline interception) plus a UI-level change (a session-scoped header entry).
|
|
17
18
|
|
|
18
19
|
## Requirements
|
|
19
20
|
|
|
20
21
|
- Node.js >= 20
|
|
21
|
-
- A DSH profile composed from `@deepseek-ai/dsh-base` + `@deepseek-ai/dsh-web-app`
|
|
22
|
+
- A DSH profile composed from `@deepseek-ai/dsh-base` + `@deepseek-ai/dsh-web-app`
|
|
22
23
|
|
|
23
24
|
## Install
|
|
24
25
|
|
|
25
26
|
Link this repository into a DSH profile:
|
|
26
27
|
|
|
27
28
|
```bash
|
|
28
|
-
dsh plugin --profile web add
|
|
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
|
|
29
|
+
dsh plugin --profile web add dsh-multi-folder
|
|
36
30
|
```
|
|
37
31
|
|
|
38
32
|
Then **restart the DSH backend** (host composition loads at process start) and **refresh the browser page** (the client bundle is served `no-cache`).
|
|
39
33
|
|
|
40
34
|
## Usage
|
|
41
35
|
|
|
42
|
-
A **「多工作目录 / Multi-folder」** button appears in the session header. The panel lets you:
|
|
36
|
+
A **「多工作目录 / Multi-folder」** button appears in the session header, and a second entry appears on the **session-creation page** (fixed launcher in the bottom-right corner while the new-session screen is shown; an inline chip beside the workspace picker once the upstream `conversation.hero.workspaceExtras` slot is available). The panel lets you:
|
|
43
37
|
|
|
44
38
|
| Action | Behavior |
|
|
45
39
|
| ------ | -------- |
|
|
@@ -65,15 +59,16 @@ The agent needs nothing extra: `read` / `glob` / `grep` work everywhere, and `wr
|
|
|
65
59
|
- **Prompt injection** — one ordered `systemPrompt` section with a text provider evaluated per assembly, rendering only for sessions whose workspace has configured directories.
|
|
66
60
|
- **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
61
|
- **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
|
-
- **
|
|
62
|
+
- **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.
|
|
63
|
+
- **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.
|
|
69
64
|
|
|
70
65
|
## Project layout
|
|
71
66
|
|
|
72
67
|
| Path | Purpose |
|
|
73
68
|
| ---- | ------- |
|
|
74
69
|
| `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 |
|
|
70
|
+
| `lib/index.js` | Host plugin: config store, tool-pipeline interception, prompt injection, dual-channel notifications, `/multi-folder` command, sessionless `multiFolder/*` remote API |
|
|
71
|
+
| `lib/client.js` | Client plugin (factory bundle): session-header button + overlay panel + session-creation page entry (hero launcher / upstream hero chip) |
|
|
77
72
|
| `test/` | Runtime-free behavior tests (see Development) |
|
|
78
73
|
| `docs/` | Design and analysis documents |
|
|
79
74
|
|
|
@@ -82,7 +77,7 @@ The agent needs nothing extra: `read` / `glob` / `grep` work everywhere, and `wr
|
|
|
82
77
|
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
78
|
|
|
84
79
|
```bash
|
|
85
|
-
node test/smoke-host.mjs # host apply smoke test
|
|
80
|
+
node test/smoke-host.mjs # host apply smoke test + remote API behavior
|
|
86
81
|
node test/intercept.mjs # interception / command / notification behavior
|
|
87
82
|
node test/smoke-client.mjs # client bundle + panel flows (React shim)
|
|
88
83
|
```
|
|
@@ -92,7 +87,7 @@ Before modifying `lib/client.js`, see [docs/design.md](docs/design.md) for the b
|
|
|
92
87
|
## Documentation
|
|
93
88
|
|
|
94
89
|
- [docs/design.md](docs/design.md) — architecture and security model
|
|
95
|
-
-
|
|
90
|
+
- [docs/upstream-hero-slot.md](docs/upstream-hero-slot.md) — the upstream `conversation.hero.workspaceExtras` slot change (B1) and its plugin-side consumption
|
|
96
91
|
|
|
97
92
|
## Contributing
|
|
98
93
|
|
package/README.zh.md
CHANGED
|
@@ -13,33 +13,27 @@
|
|
|
13
13
|
- 在 **Workspace Write** 模式下,Agent 对配置的副工作目录拥有与主工作目录**同等的读取、写入、编辑与命令执行权限**——实现方式是重定向会话自身的沙箱策略根,因此每种模式语义都自然保持(`read-only` 依旧拒绝、`workspace-write` 放行、`danger-full-access` 放行);
|
|
14
14
|
- 目录列表**注入系统提示词**,每次组装按会话求值;
|
|
15
15
|
- 配置变更通过**不打断的消息队列**通知 Agent——在下一次消息边界(用户发送或工具调用结束)送达,且**仅在目录集合实际变化时**发送;
|
|
16
|
+
- **会话开始前即可配置**:会话创建页(新会话界面)提供「多工作目录」入口,通过**无会话远程 API**(`multiFolder/*` 端点)读写同一份 per-workspace 配置——无需 session id;
|
|
16
17
|
- **不新增任何工具**:改动全部位于框架级(工具流水线拦截)与 UI 级(会话级头部入口)。
|
|
17
18
|
|
|
18
19
|
## 环境要求
|
|
19
20
|
|
|
20
21
|
- Node.js >= 20
|
|
21
|
-
- 由 `@deepseek-ai/dsh-base` + `@deepseek-ai/dsh-web-app` 组成的 DSH profile
|
|
22
|
+
- 由 `@deepseek-ai/dsh-base` + `@deepseek-ai/dsh-web-app` 组成的 DSH profile
|
|
22
23
|
|
|
23
24
|
## 安装
|
|
24
25
|
|
|
25
26
|
将本仓库链接进 DSH profile:
|
|
26
27
|
|
|
27
28
|
```bash
|
|
28
|
-
dsh plugin --profile web add
|
|
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
|
|
29
|
+
dsh plugin --profile web add dsh-multi-folder
|
|
36
30
|
```
|
|
37
31
|
|
|
38
32
|
然后**重启 DSH 后端**(宿主组合在进程启动时装载)并**刷新浏览器页面**(客户端 bundle 以 `no-cache` 提供)。
|
|
39
33
|
|
|
40
34
|
## 使用
|
|
41
35
|
|
|
42
|
-
|
|
36
|
+
会话头部出现「多工作目录」按钮;**会话创建页**也有入口(新会话界面右下角的浮动按钮;当上游 DSH 声明 `conversation.hero.workspaceExtras` 插槽后,还会在工作区选择器旁显示内联 chip)。打开面板即可:
|
|
43
37
|
|
|
44
38
|
| 操作 | 行为 |
|
|
45
39
|
| ---- | ---- |
|
|
@@ -65,15 +59,16 @@ Agent 无需任何额外操作:`read` / `glob` / `grep` 随处可用;`write`
|
|
|
65
59
|
- **提示词注入**——一个有序 `systemPrompt` 段落,text provider 每次组装按会话求值,仅为配置了副目录的会话渲染。
|
|
66
60
|
- **通知**——命令处理器仅在目录集合实际变化时置位 pending notice;`agent/pre-step`(前置注入进入批次)与 `tools/post-execute`(附加为 `additionalContexts`)两个通道中先触发者消费——均使用框架原生的插件来源 `notice` 上下文。
|
|
67
61
|
- **配置与安全边界**——per-workspace 配置存储于 Agent 沙箱之外的宿主自有目录(`<DSH_HOME>/storages/multi-folder/<workspace-key>.json`)。对配置文件的任何直接 `write`/`edit` 都会收到显式拒绝——**Agent 永远无法自我授予目录,配置权仅属于用户**。详见 [SECURITY.md](SECURITY.md)。
|
|
68
|
-
-
|
|
62
|
+
- **无会话远程 API**——经 `ctx.typert.register` 注册 `multiFolder` 命名空间(手写 `src-json` 描述符),并以普通对象服务 `multiFolder` 提供;`list`/`add`/`remove`/`set` 以工作区**路径**为键,与 `/multi-folder` 命令共享同一套校验核心,因此会话尚未建立时创建页也能直接配置。
|
|
63
|
+
- **客户端**——手写维护的 factory bundle(`window.__ModuleLoader__.load`),无需构建工具链;面板经两条通道驱动宿主:会话内走 Remote BFF(`ctx.remote.commands.execute`),无会话端点走共享 `/api` RPC 通道(`ctx.connection.rpc.call`)。
|
|
69
64
|
|
|
70
65
|
## 目录结构
|
|
71
66
|
|
|
72
67
|
| 路径 | 作用 |
|
|
73
68
|
| ---- | ---- |
|
|
74
69
|
| `cordis.patch.yml` | profile patch 层,插入 `dsh-multi-folder` 行 |
|
|
75
|
-
| `lib/index.js` | 宿主插件:配置存储、工具流水线拦截、提示词注入、双通道通知、`/multi-folder`
|
|
76
|
-
| `lib/client.js` | 客户端插件(factory bundle):会话头部按钮 + 覆盖层面板 |
|
|
70
|
+
| `lib/index.js` | 宿主插件:配置存储、工具流水线拦截、提示词注入、双通道通知、`/multi-folder` 命令、无会话 `multiFolder/*` 远程 API |
|
|
71
|
+
| `lib/client.js` | 客户端插件(factory bundle):会话头部按钮 + 覆盖层面板 + 会话创建页入口(hero 浮动按钮 / 上游 hero chip) |
|
|
77
72
|
| `test/` | 免 DSH 运行时的行为测试(见开发) |
|
|
78
73
|
| `docs/` | 设计与分析文档 |
|
|
79
74
|
|
|
@@ -82,7 +77,7 @@ Agent 无需任何额外操作:`read` / `glob` / `grep` 随处可用;`write`
|
|
|
82
77
|
零构建步骤:宿主半边为纯 ESM,`lib/client.js` 为 DSH client-modules 格式的手写 factory bundle。测试直接用 Node 运行:
|
|
83
78
|
|
|
84
79
|
```bash
|
|
85
|
-
node test/smoke-host.mjs # 宿主 apply 冒烟
|
|
80
|
+
node test/smoke-host.mjs # 宿主 apply 冒烟 + 远程 API 行为
|
|
86
81
|
node test/intercept.mjs # 拦截 / 命令 / 通知行为
|
|
87
82
|
node test/smoke-client.mjs # 客户端 bundle 与面板流程(React shim)
|
|
88
83
|
```
|
|
@@ -92,7 +87,7 @@ node test/smoke-client.mjs # 客户端 bundle 与面板流程(React shim)
|
|
|
92
87
|
## 文档
|
|
93
88
|
|
|
94
89
|
- [docs/design.md](docs/design.md) — 架构与安全模型(英文)
|
|
95
|
-
-
|
|
90
|
+
- [docs/upstream-hero-slot.md](docs/upstream-hero-slot.md) — 上游 `conversation.hero.workspaceExtras` 插槽改动(B1)与插件的对接方式(英文)
|
|
96
91
|
|
|
97
92
|
## 参与贡献
|
|
98
93
|
|
package/SECURITY.md
CHANGED
|
@@ -7,8 +7,11 @@
|
|
|
7
7
|
granted the session. The design enforces four boundaries:
|
|
8
8
|
|
|
9
9
|
1. **User-only configuration.** Secondary directories can only be added or removed by
|
|
10
|
-
the user through the
|
|
11
|
-
|
|
10
|
+
the user, through the session-header panel, the session-creation page panel, or
|
|
11
|
+
the `/multi-folder` slash command. The two panels are backed by a sessionless
|
|
12
|
+
`multiFolder/*` remote API on the trusted browser→host RPC channel — endpoints
|
|
13
|
+
that exist only for the web UI and are never exposed as agent tools. The agent
|
|
14
|
+
has no tool, command, or file path through which it can change the configuration.
|
|
12
15
|
|
|
13
16
|
2. **Host-owned configuration store.** Per-workspace configuration lives in
|
|
14
17
|
`<DSH_HOME>/storages/multi-folder/<workspace-key>.json` — outside every agent
|
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 |
|
|
18
|
-
| Client | `lib/client.js` | Session-header button + overlay panel;
|
|
17
|
+
| Host | `lib/index.js` | Config store, tool-pipeline interception, prompt section, notifications, `/multi-folder` command, sessionless `multiFolder/*` remote API |
|
|
18
|
+
| Client | `lib/client.js` | Session-header button + overlay panel; session-creation page entry (hero launcher + upstream hero chip), both driving the host through the Remote BFF / shared RPC channel |
|
|
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"]`).
|
|
@@ -67,10 +67,60 @@ at apply time can yield `undefined` when the provider row activates later. There
|
|
|
67
67
|
|
|
68
68
|
- Canonical location: `<DSH_HOME>/storages/multi-folder/<workspace-key>.json`
|
|
69
69
|
(`DSH_HOME` falls back to `~/.dsh`), i.e. **outside every agent sandbox root**.
|
|
70
|
-
- Writes happen only from
|
|
70
|
+
- Writes happen only from user-initiated flows (the `/multi-folder` command
|
|
71
|
+
handler and the `multiFolder/*` remote endpoints) with an explicit
|
|
71
72
|
`workspace-write` policy rooted at the config directory.
|
|
72
73
|
- A per-process cache keyed by normalized workspace path hydrates lazily (on
|
|
73
74
|
`agent/created`, `agent/pre-step`, and `tools/execute`).
|
|
75
|
+
- One shared **core** (`coreList` / `coreAdd` / `coreRemove` / `coreSet`)
|
|
76
|
+
implements validation, canonicalization, sanitization, cache write-through,
|
|
77
|
+
and persistence. The command channel and the remote channel both call it, so
|
|
78
|
+
the security surface stays identical on both. Core errors carry bare
|
|
79
|
+
messages; each channel adds its own `multi-folder: ` prefix.
|
|
80
|
+
|
|
81
|
+
## Host: sessionless remote API
|
|
82
|
+
|
|
83
|
+
The session-creation page has no session (and no `sessionId`), so the
|
|
84
|
+
agent-scoped `commands/execute` remote cannot serve it. Instead the plugin
|
|
85
|
+
opens its own **sessionless** endpoints on the shared `/api` RPC channel:
|
|
86
|
+
|
|
87
|
+
- A **plain-object service** is registered with `ctx.provide('multiFolder', api)`.
|
|
88
|
+
The object carries the gateway-visible binding
|
|
89
|
+
`typertRemote = { service, serviceKey: 'multiFolder', namespace: 'multiFolder' }`
|
|
90
|
+
(frozen), which is exactly what the gateway's `validateBinding` expects.
|
|
91
|
+
- A **hand-written Typert contribution** is registered through
|
|
92
|
+
`ctx.inject(['typert'], (t) => t.typert.register(REMOTE_CONTRIBUTION))` —
|
|
93
|
+
the sanctioned manual path documented by `dsh-typert-loader` ("Manual
|
|
94
|
+
`ctx.typert.register()` remains available for contributions that do not use
|
|
95
|
+
a `./typert` artifact"). All four descriptors use `src-json` codecs (no zod
|
|
96
|
+
schemas needed) with `invocation: { kind: 'direct' }`:
|
|
97
|
+
|
|
98
|
+
| Endpoint | Parameters (wire) | Result |
|
|
99
|
+
| -------- | ----------------- | ------ |
|
|
100
|
+
| `multiFolder/list` | `workspace` | `{ workspace, dirs, changed: false }` |
|
|
101
|
+
| `multiFolder/add` | `workspace`, `path` | `{ workspace, dirs, changed }` |
|
|
102
|
+
| `multiFolder/remove` | `workspace`, `path` | `{ workspace, dirs, changed }` |
|
|
103
|
+
| `multiFolder/set` | `workspace`, `dirs` | `{ workspace, dirs, changed }` |
|
|
104
|
+
|
|
105
|
+
The workspace argument is a **path**, not a session id; the client derives
|
|
106
|
+
it from the workspaces store (`WorkspaceView.path`). Business errors throw
|
|
107
|
+
and arrive at the browser as `{ ok: false, error: { message } }`.
|
|
108
|
+
- Gateway mechanics verified against `dsh-api-gateway` + `dsh-typert-registry`:
|
|
109
|
+
`resolveDescriptor` finds the endpoint in `typert.local` (claimable on
|
|
110
|
+
`/api`), direct invocation resolves the receiver through
|
|
111
|
+
`ctx.get('multiFolder')` (global shared store), `validateBinding` reads the
|
|
112
|
+
frozen `typertRemote` property, and src-json parameters tolerate omitted
|
|
113
|
+
wire fields. Both registrations are owned by the plugin fiber, so unloading
|
|
114
|
+
the plugin withdraws them together.
|
|
115
|
+
- No notice is armed on the remote channel: pre-session changes have no agent
|
|
116
|
+
to notify. The session created afterwards hydrates the cache on
|
|
117
|
+
`agent/created` and the prompt section renders the directories in the very
|
|
118
|
+
first assembly.
|
|
119
|
+
- Note: `src-json` descriptors are boundary-validated only for JSON safety
|
|
120
|
+
(the gateway's `assertJsonValue`), not schema-validated. The service itself
|
|
121
|
+
must therefore treat every argument as hostile — the shared core already
|
|
122
|
+
does (type checks, absolute-path requirement, canonicalization, sanitization,
|
|
123
|
+
primary-workspace exclusion).
|
|
74
124
|
|
|
75
125
|
## Host: prompt injection and notifications
|
|
76
126
|
|
|
@@ -95,21 +145,44 @@ window.__ModuleLoader__.load({
|
|
|
95
145
|
})
|
|
96
146
|
```
|
|
97
147
|
|
|
98
|
-
- `inject: ['remote', 'remote.commands', 'slots', 'workspaces']`; the package's
|
|
148
|
+
- `inject: ['remote', 'remote.commands', 'slots', 'workspaces', 'connection', 'sessions']`; the package's
|
|
99
149
|
`dsh.client.inject` lists the packages providing them
|
|
100
150
|
(`@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)
|
|
103
|
-
`shell.overlay`
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
151
|
+
`@deepseek-ai/dsh-client-connection`, `@deepseek-ai/dsh-client-runtime`).
|
|
152
|
+
- UI registrations: `conversation.session.header.actions` (session-scoped button),
|
|
153
|
+
`shell.overlay` panel, `shell.overlay` hero launcher (root-scoped, fixed
|
|
154
|
+
position), and `conversation.hero.workspaceExtras` (upstream slot; see
|
|
155
|
+
below). One module-level store is shared by all of them.
|
|
156
|
+
- Host communication, two channels:
|
|
157
|
+
- session mode: `ctx.remote.commands.execute(sessionId, line)`. The return
|
|
158
|
+
value is the RPC envelope `{ ok, value }` where `value` is the
|
|
159
|
+
`CommandExecution`; command result text carries a `[MF:JSON] {…}` line the
|
|
160
|
+
panel parses for structured state.
|
|
161
|
+
- workspace mode (session-creation page): `ctx.connection.rpc.call('/api',
|
|
162
|
+
'multiFolder/<op>', { args })` against the sessionless remote endpoints.
|
|
163
|
+
The panel runs in either mode according to how it was opened; mutations
|
|
164
|
+
and refreshes route per mode, and both modes share the same row/error UI.
|
|
165
|
+
- Session switch: a `React.useEffect` on `sessionId` re-points the open panel
|
|
166
|
+
to the current session (reusing the per-session cache) — this also folds a
|
|
167
|
+
workspace-mode panel back into session mode once the first message creates
|
|
168
|
+
the session.
|
|
169
|
+
- Caching: per-session cache (`sessionCache`) keeps pure reads off the
|
|
170
|
+
conversation; per-workspace cache (`workspaceCache`) plays the same role for
|
|
171
|
+
the sessionless channel.
|
|
172
|
+
- Hero (session-creation page) support:
|
|
173
|
+
- The **hero launcher** (`shell.overlay` entry, `multi-folder-hero`)
|
|
174
|
+
subscribes to `sessions.list` + `workspaces.list` and observes the
|
|
175
|
+
conversation root's `data-phase="hero"` attribute (MutationObserver on
|
|
176
|
+
`document.body`). While the hero is visible it renders a fixed-position
|
|
177
|
+
「多工作目录」 button; the workspace path is derived from the current
|
|
178
|
+
(blank) session's `WorkspaceView.path`, falling back to `SessionSummary.cwd`.
|
|
179
|
+
- Clicking it opens the panel in workspace mode; without a selected
|
|
180
|
+
workspace the panel shows the "pick a workspace first" hint.
|
|
181
|
+
- The **hero chip** registers into `conversation.hero.workspaceExtras` via
|
|
182
|
+
`slots.inject`, which waits for the declaration: with an upstream DSH
|
|
183
|
+
build that declares the slot, the chip renders inline beside the workspace
|
|
184
|
+
picker; without one, the registration is a harmless no-op and the fixed
|
|
185
|
+
launcher covers the page.
|
|
113
186
|
|
|
114
187
|
## Known limitations
|
|
115
188
|
|
|
@@ -123,11 +196,28 @@ window.__ModuleLoader__.load({
|
|
|
123
196
|
secondary directories are passed through to the default pipeline.
|
|
124
197
|
- The `/multi-folder` command lifecycle rows (`command/run`, `command/done`) are
|
|
125
198
|
visible in the conversation UI by framework design; they are log-only and never
|
|
126
|
-
reach the model.
|
|
199
|
+
reach the model. Workspace-mode (session-creation page) operations avoid them
|
|
200
|
+
entirely by using the sessionless remote channel.
|
|
201
|
+
- The hero launcher relies on the conversation root's `data-phase="hero"`
|
|
202
|
+
attribute and the `sessions.list`/`workspaces.list` snapshot shapes — DOM and
|
|
203
|
+
client-runtime internals rather than documented APIs. They are guarded
|
|
204
|
+
defensively (missing services or DOM degrade to "launcher hidden"), and the
|
|
205
|
+
upstream `conversation.hero.workspaceExtras` slot (see
|
|
206
|
+
[upstream-hero-slot.md](upstream-hero-slot.md)) is the long-term surface.
|
|
207
|
+
- The `multiFolder/*` endpoints use hand-written `src-json` Typert descriptors
|
|
208
|
+
registered through `ctx.typert.register`. `src-json` gives JSON-safety
|
|
209
|
+
boundary checks, not schema validation; the shared core performs all
|
|
210
|
+
business validation server-side. DSH versions that change the Typert
|
|
211
|
+
registry contract would need this contribution revisited (the tests assert
|
|
212
|
+
the descriptor shape).
|
|
127
213
|
|
|
128
214
|
## Tests
|
|
129
215
|
|
|
130
216
|
`test/smoke-host.mjs`, `test/intercept.mjs`, and `test/smoke-client.mjs` run without
|
|
131
217
|
the DSH runtime using mock services and a React shim. They cover interception,
|
|
132
218
|
canonicalization, the config guard, both notification channels, notice
|
|
133
|
-
gating, command flows,
|
|
219
|
+
gating, command flows, the panel's session-switch/caching behavior, the
|
|
220
|
+
sessionless remote contribution shape and behavior (list/add/set/remove,
|
|
221
|
+
idempotence, sanitization, error prefixing, cross-channel cache coherence),
|
|
222
|
+
and the hero/workspace-mode client flows (launcher visibility, RPC routing,
|
|
223
|
+
workspace-mode mutations and error surfacing).
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# Upstream slot: `conversation.hero.workspaceExtras` (B1)
|
|
2
|
+
|
|
3
|
+
The session-creation page (the hero phase of `ConversationRoot`) currently
|
|
4
|
+
declares only two root-scope slots — `conversation.hero.workspace` and
|
|
5
|
+
`conversation.hero.agentPreset` — and both are `single`-kind (occupying them
|
|
6
|
+
replaces the built-in control). There is no additive seat for a plugin to
|
|
7
|
+
place configuration UI next to the workspace picker.
|
|
8
|
+
|
|
9
|
+
This document specifies the small upstream change to
|
|
10
|
+
`@deepseek-ai/dsh-client-ui-conversation` that adds one, and explains how the
|
|
11
|
+
plugin consumes it. Until this lands in a DSH release, the plugin's fixed
|
|
12
|
+
`shell.overlay` hero launcher covers the same page (B2); the slot registration
|
|
13
|
+
below is a waiting no-op (`slots.inject` fires only once the declaration
|
|
14
|
+
exists), so the plugin works with and without the upstream change.
|
|
15
|
+
|
|
16
|
+
## 1. Declare the slot
|
|
17
|
+
|
|
18
|
+
File: `packages/client/ui-conversation/src/client/contract/slots.ts`
|
|
19
|
+
|
|
20
|
+
Add to the `SlotMap` declaration merging:
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
/**
|
|
24
|
+
* Additive row beside the hero workspace picker and the agent-preset
|
|
25
|
+
* chip on the new-session screen. Root scope: no session exists yet,
|
|
26
|
+
* so entries address the pending/selected workspace by its canonical
|
|
27
|
+
* path rather than by a session id.
|
|
28
|
+
*/
|
|
29
|
+
'conversation.hero.workspaceExtras': {
|
|
30
|
+
kind: 'list';
|
|
31
|
+
scope: 'root';
|
|
32
|
+
owner: HeroWorkspaceExtrasOwnerProps;
|
|
33
|
+
};
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
And the owner share:
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
/** Owner share of the hero workspace-extras row: the canonical target path. */
|
|
40
|
+
export interface HeroWorkspaceExtrasOwnerProps {
|
|
41
|
+
/**
|
|
42
|
+
* Absolute canonical path of the workspace the hero targets (the pending
|
|
43
|
+
* pick or the blank session's workspace); undefined until one is chosen.
|
|
44
|
+
*/
|
|
45
|
+
workspacePath?: string | undefined;
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## 2. Render the slot in the hero row
|
|
50
|
+
|
|
51
|
+
File: `packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx`
|
|
52
|
+
|
|
53
|
+
The `heroWorkspaceRow` element currently renders
|
|
54
|
+
`WorkspaceChip` → `renderSlot('conversation.hero.workspace', …)` →
|
|
55
|
+
`renderSlot('conversation.hero.agentPreset', {})`. Append the new seat:
|
|
56
|
+
|
|
57
|
+
```tsx
|
|
58
|
+
renderSlot('conversation.hero.workspaceExtras', {
|
|
59
|
+
workspacePath: pendingWorkspace?.path ?? sessionWorkspace?.path,
|
|
60
|
+
}),
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
(`pendingWorkspace` and `sessionWorkspace` are the `WorkspaceView` values the
|
|
64
|
+
root already resolves; `path` is their canonical host path.)
|
|
65
|
+
|
|
66
|
+
## 3. Declare the slot on the `conversation` registration
|
|
67
|
+
|
|
68
|
+
In the same file, the `slots.register({ name: 'conversation', children: { … } })`
|
|
69
|
+
table must gain the child declaration (declaring is claiming — the render site
|
|
70
|
+
above is only authorized once it is listed):
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
'conversation.hero.workspaceExtras': {
|
|
74
|
+
kind: 'list',
|
|
75
|
+
scope: 'root',
|
|
76
|
+
},
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## 4. Plugin side (already implemented)
|
|
80
|
+
|
|
81
|
+
`lib/client.js` registers into the slot the moment it is declared:
|
|
82
|
+
|
|
83
|
+
```js
|
|
84
|
+
slots.inject('conversation.hero.workspaceExtras', function () {
|
|
85
|
+
return slots.register(
|
|
86
|
+
{ name: 'conversation.hero.workspaceExtras', id: 'multi-folder', order: 30, label: '多工作目录' },
|
|
87
|
+
function (props) { return React.createElement(HeroChip, props); },
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`HeroChip` prefers the owner-supplied `props.workspacePath` and falls back to
|
|
93
|
+
the store-derived hero workspace. It opens the same overlay panel in workspace
|
|
94
|
+
mode, which reads and writes the configuration through the sessionless
|
|
95
|
+
`multiFolder/*` endpoints (see [design.md](design.md)).
|
|
96
|
+
|
|
97
|
+
## Compiled-bundle equivalent (for local patching)
|
|
98
|
+
|
|
99
|
+
The shipped `lib/client.js` of `dsh-client-ui-conversation` is a compiled
|
|
100
|
+
bundle; the same change there is three touchpoints:
|
|
101
|
+
|
|
102
|
+
1. `SlotMap` declaration — the `declare module` block compiles into the
|
|
103
|
+
registration data; add the `'conversation.hero.workspaceExtras': { kind:
|
|
104
|
+
'list', scope: 'root' }` child entry to the `children` table of the
|
|
105
|
+
`conversation` registration (around the existing
|
|
106
|
+
`"conversation.hero.agentPreset"` entry).
|
|
107
|
+
2. The render site — inside `heroWorkspaceRow`, after
|
|
108
|
+
`renderSlot("conversation.hero.agentPreset", {})`, add
|
|
109
|
+
`renderSlot("conversation.hero.workspaceExtras", { workspacePath: (pendingWorkspace ?? sessionWorkspace)?.path })`.
|
|
110
|
+
3. Rebuild the web bundle and refresh the page (bundled shell changes require
|
|
111
|
+
a rebuild; the plugin's own bundle is served no-cache).
|
|
112
|
+
|
|
113
|
+
## Verification
|
|
114
|
+
|
|
115
|
+
- Unit: the plugin's `test/smoke-client.mjs` registers and drives `HeroChip`
|
|
116
|
+
with a mock owner share (`workspacePath`), asserting the panel opens in
|
|
117
|
+
workspace mode and reuses the per-workspace cache.
|
|
118
|
+
- Manual: with the patched build, the 「多工作目录」 chip renders in the hero
|
|
119
|
+
workspace row between the preset chip and the composer; clicking it lists
|
|
120
|
+
the workspace's secondary directories before any message is sent.
|
package/lib/client.js
CHANGED
|
@@ -9,6 +9,17 @@
|
|
|
9
9
|
* human-readable result carrying a `[MF:JSON]` line the panel parses for
|
|
10
10
|
* structured state.
|
|
11
11
|
*
|
|
12
|
+
* Session-creation page UI (no session exists yet):
|
|
13
|
+
* - a hero-phase launcher in `shell.overlay` (fixed-position entry) whose
|
|
14
|
+
* visibility follows the conversation root's `data-phase="hero"` attribute;
|
|
15
|
+
* - an inline chip registered into the upstream `conversation.hero.workspaceExtras`
|
|
16
|
+
* slot when that slot exists (the `slots.inject` wait is a no-op until the
|
|
17
|
+
* DSH core declares it).
|
|
18
|
+
* Both open the same panel in WORKSPACE mode and drive the sessionless
|
|
19
|
+
* `multiFolder/*` endpoints over the shared RPC channel
|
|
20
|
+
* (`ctx.connection.rpc.call('/api', endpoint, { args })`), keyed by workspace
|
|
21
|
+
* path instead of sessionId.
|
|
22
|
+
*
|
|
12
23
|
* Layout shape: an entry in the session header action row plus a frame-wide
|
|
13
24
|
* overlay panel. Both read one tiny module-scoped store; opening the panel
|
|
14
25
|
* refreshes the list from the Host.
|
|
@@ -22,7 +33,7 @@ window.__ModuleLoader__.load({
|
|
|
22
33
|
|
|
23
34
|
// ------------------------------------------------------------- store
|
|
24
35
|
var listeners = new Set();
|
|
25
|
-
var state = { open: false, sessionId: null, dirs: [], workspace: null, busy: false, error: null };
|
|
36
|
+
var state = { open: false, mode: null, sessionId: null, dirs: [], workspace: null, busy: false, error: null, hero: false, heroWorkspace: null };
|
|
26
37
|
function patch(next) {
|
|
27
38
|
state = Object.assign({}, state, next);
|
|
28
39
|
listeners.forEach(function (fn) { fn(); });
|
|
@@ -36,6 +47,13 @@ window.__ModuleLoader__.load({
|
|
|
36
47
|
/** sessionId -> { dirs, workspace }: avoids re-running the list command
|
|
37
48
|
* (and its conversation row) on every panel open. */
|
|
38
49
|
var sessionCache = {};
|
|
50
|
+
/** workspacePathKey -> { dirs, workspace }: the workspace-mode twin of
|
|
51
|
+
* sessionCache, keyed by the sessionless remote's workspace argument. */
|
|
52
|
+
var workspaceCache = {};
|
|
53
|
+
|
|
54
|
+
function workspacePathKey(path) {
|
|
55
|
+
return String(path).replace(/\\/g, '/').toLowerCase().replace(/\/+$/, '');
|
|
56
|
+
}
|
|
39
57
|
|
|
40
58
|
function parseJsonLine(text) {
|
|
41
59
|
if (typeof text !== 'string') return null;
|
|
@@ -46,12 +64,85 @@ window.__ModuleLoader__.load({
|
|
|
46
64
|
|
|
47
65
|
// ------------------------------------------------------------- plugin
|
|
48
66
|
var name = 'dsh-multi-folder';
|
|
49
|
-
var inject = ['remote', 'remote.commands', 'slots', 'workspaces'];
|
|
67
|
+
var inject = ['remote', 'remote.commands', 'slots', 'workspaces', 'connection', 'sessions'];
|
|
50
68
|
|
|
51
69
|
function apply(ctx) {
|
|
52
70
|
var slots = ctx.slots;
|
|
53
71
|
var remote = ctx.remote;
|
|
54
72
|
var workspaces = ctx.workspaces;
|
|
73
|
+
var connection = ctx.connection;
|
|
74
|
+
var sessions = ctx.sessions;
|
|
75
|
+
|
|
76
|
+
// -------------------------------------------------- sessionless RPC
|
|
77
|
+
/** Call one `multiFolder/*` endpoint over the shared /api channel.
|
|
78
|
+
* The Host gateway answers with the same `{ ok, value }` envelope as
|
|
79
|
+
* the command remote; business errors surface as thrown Errors. */
|
|
80
|
+
function remoteCall(endpoint, args) {
|
|
81
|
+
if (!connection || !connection.rpc || typeof connection.rpc.call !== 'function') {
|
|
82
|
+
return Promise.reject(new Error('multi-folder: the shared RPC channel (connection service) is unavailable'));
|
|
83
|
+
}
|
|
84
|
+
return connection.rpc.call('/api', endpoint, { args: args }).then(function (envelope) {
|
|
85
|
+
if (!envelope || envelope.ok !== true) {
|
|
86
|
+
var message = envelope && envelope.error !== undefined
|
|
87
|
+
? String(envelope.error.message !== undefined ? envelope.error.message : envelope.error)
|
|
88
|
+
: 'remote call failed';
|
|
89
|
+
throw new Error('multi-folder: ' + String(message).replace(/^multi-folder:\s*/, ''));
|
|
90
|
+
}
|
|
91
|
+
return envelope.value;
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function refreshWorkspace(workspacePath) {
|
|
96
|
+
if (!workspacePath) return;
|
|
97
|
+
patch({ busy: true, error: null });
|
|
98
|
+
remoteCall('multiFolder/list', { workspace: workspacePath }).then(function (value) {
|
|
99
|
+
workspaceCache[workspacePathKey(workspacePath)] = value || { workspace: workspacePath, dirs: [] };
|
|
100
|
+
var current = getSnapshot();
|
|
101
|
+
if (!current.sessionId && current.mode === 'workspace' && current.workspace && workspacePathKey(current.workspace) === workspacePathKey(workspacePath)) {
|
|
102
|
+
patch({ busy: false, workspace: value.workspace, dirs: value.dirs || [] });
|
|
103
|
+
} else {
|
|
104
|
+
patch({ busy: false });
|
|
105
|
+
}
|
|
106
|
+
}).catch(function (e) {
|
|
107
|
+
patch({ busy: false, error: String(e && e.message ? e.message : e) });
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function mutateWorkspace(workspacePath, endpoint, args) {
|
|
112
|
+
if (!workspacePath) return Promise.resolve();
|
|
113
|
+
patch({ busy: true, error: null });
|
|
114
|
+
var payload = Object.assign({ workspace: workspacePath }, args);
|
|
115
|
+
return remoteCall(endpoint, payload).then(function (value) {
|
|
116
|
+
workspaceCache[workspacePathKey(workspacePath)] = value || { workspace: workspacePath, dirs: [] };
|
|
117
|
+
var current = getSnapshot();
|
|
118
|
+
if (!current.sessionId && current.mode === 'workspace' && current.workspace && workspacePathKey(current.workspace) === workspacePathKey(workspacePath)) {
|
|
119
|
+
patch({ busy: false, workspace: value.workspace, dirs: value.dirs || [] });
|
|
120
|
+
} else {
|
|
121
|
+
patch({ busy: false });
|
|
122
|
+
}
|
|
123
|
+
}).catch(function (e) {
|
|
124
|
+
patch({ busy: false, error: String(e && e.message ? e.message : e) });
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Open the panel in WORKSPACE mode (session-creation page): a null
|
|
129
|
+
* workspace shows the "pick a workspace first" hint instead. */
|
|
130
|
+
function openForWorkspace(workspacePath) {
|
|
131
|
+
if (!workspacePath) {
|
|
132
|
+
patch({ open: true, mode: 'workspace', sessionId: null, workspace: null, dirs: [], error: null });
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
var cached = workspaceCache[workspacePathKey(workspacePath)];
|
|
136
|
+
patch({
|
|
137
|
+
open: true,
|
|
138
|
+
mode: 'workspace',
|
|
139
|
+
sessionId: null,
|
|
140
|
+
workspace: workspacePath,
|
|
141
|
+
dirs: cached ? cached.dirs : [],
|
|
142
|
+
error: null,
|
|
143
|
+
});
|
|
144
|
+
if (!cached) refreshWorkspace(workspacePath);
|
|
145
|
+
}
|
|
55
146
|
|
|
56
147
|
function runCommand(sessionId, line) {
|
|
57
148
|
return remote.commands.execute(sessionId, line).then(function (envelope) {
|
|
@@ -107,10 +198,15 @@ window.__ModuleLoader__.load({
|
|
|
107
198
|
});
|
|
108
199
|
}
|
|
109
200
|
|
|
110
|
-
function addDirectory(
|
|
201
|
+
function addDirectory() {
|
|
111
202
|
workspaces.pickDirectory().then(function (path) {
|
|
112
203
|
if (path === null || path === undefined) return;
|
|
113
|
-
|
|
204
|
+
var snapshot = getSnapshot();
|
|
205
|
+
if (snapshot.sessionId) {
|
|
206
|
+
mutate(snapshot.sessionId, '/multi-folder add "' + path.replace(/"/g, '\\"') + '"');
|
|
207
|
+
} else if (snapshot.workspace) {
|
|
208
|
+
mutateWorkspace(snapshot.workspace, 'multiFolder/add', { path: path });
|
|
209
|
+
}
|
|
114
210
|
}).catch(function (e) {
|
|
115
211
|
patch({ error: String(e && e.message ? e.message : e) });
|
|
116
212
|
});
|
|
@@ -123,6 +219,7 @@ window.__ModuleLoader__.load({
|
|
|
123
219
|
var cached = sessionCache[sessionId];
|
|
124
220
|
patch({
|
|
125
221
|
open: true,
|
|
222
|
+
mode: 'session',
|
|
126
223
|
sessionId: sessionId,
|
|
127
224
|
dirs: cached ? cached.dirs : [],
|
|
128
225
|
workspace: cached ? cached.workspace : null,
|
|
@@ -184,7 +281,9 @@ window.__ModuleLoader__.load({
|
|
|
184
281
|
// Overlay panel ------------------------------------------------------
|
|
185
282
|
function Panel() {
|
|
186
283
|
var store = useStore();
|
|
187
|
-
if (!store.open || !store.
|
|
284
|
+
if (!store.open || !store.mode) return null;
|
|
285
|
+
var sessionMode = store.mode === 'session';
|
|
286
|
+
var usable = sessionMode || !!store.workspace;
|
|
188
287
|
var rows = (store.dirs || []).map(function (dir, index) {
|
|
189
288
|
return React.createElement(
|
|
190
289
|
'div',
|
|
@@ -213,7 +312,14 @@ window.__ModuleLoader__.load({
|
|
|
213
312
|
{
|
|
214
313
|
type: 'button',
|
|
215
314
|
title: '移除此副工作目录',
|
|
216
|
-
|
|
315
|
+
disabled: !usable,
|
|
316
|
+
onClick: function () {
|
|
317
|
+
if (sessionMode) {
|
|
318
|
+
mutate(store.sessionId, '/multi-folder remove "' + dir.replace(/"/g, '\\"') + '"');
|
|
319
|
+
} else if (store.workspace) {
|
|
320
|
+
mutateWorkspace(store.workspace, 'multiFolder/remove', { path: dir });
|
|
321
|
+
}
|
|
322
|
+
},
|
|
217
323
|
style: { padding: '2px 8px', borderRadius: 6, border: '1px solid transparent', background: 'transparent', color: 'var(--color-danger, #c62828)', cursor: 'pointer' },
|
|
218
324
|
},
|
|
219
325
|
'移除',
|
|
@@ -260,8 +366,16 @@ window.__ModuleLoader__.load({
|
|
|
260
366
|
{ style: { marginBottom: 8, color: 'var(--color-text-muted, #6b6b6b)', fontSize: 12, wordBreak: 'break-all' } },
|
|
261
367
|
'项目:' + store.workspace,
|
|
262
368
|
)
|
|
263
|
-
:
|
|
264
|
-
|
|
369
|
+
: (!sessionMode
|
|
370
|
+
? React.createElement(
|
|
371
|
+
'div',
|
|
372
|
+
{ style: { marginBottom: 8, color: 'var(--color-text-muted, #6b6b6b)' } },
|
|
373
|
+
'尚未选择工作区。请先在上方选择项目,再配置多工作目录。',
|
|
374
|
+
)
|
|
375
|
+
: null),
|
|
376
|
+
usable
|
|
377
|
+
? (rows.length > 0 ? rows : React.createElement('div', { style: { marginBottom: 8, color: 'var(--color-text-muted, #6b6b6b)' } }, '尚未配置副工作目录。'))
|
|
378
|
+
: React.createElement('div', { style: { marginBottom: 8, color: 'var(--color-text-muted, #6b6b6b)' } }, '选择工作区后可在此添加副工作目录。'),
|
|
265
379
|
store.error
|
|
266
380
|
? React.createElement('div', { style: { marginBottom: 8, color: 'var(--color-danger, #c62828)', whiteSpace: 'pre-wrap' } }, String(store.error))
|
|
267
381
|
: null,
|
|
@@ -272,8 +386,8 @@ window.__ModuleLoader__.load({
|
|
|
272
386
|
'button',
|
|
273
387
|
{
|
|
274
388
|
type: 'button',
|
|
275
|
-
disabled: !!store.busy,
|
|
276
|
-
onClick: function () { addDirectory(
|
|
389
|
+
disabled: !!store.busy || !usable,
|
|
390
|
+
onClick: function () { addDirectory(); },
|
|
277
391
|
style: {
|
|
278
392
|
flex: 1,
|
|
279
393
|
padding: '6px 10px',
|
|
@@ -281,8 +395,8 @@ window.__ModuleLoader__.load({
|
|
|
281
395
|
border: '1px solid var(--color-border, rgba(127,127,127,0.35))',
|
|
282
396
|
background: 'var(--color-bg-accent, rgba(80,120,255,0.14))',
|
|
283
397
|
color: 'var(--color-text, inherit)',
|
|
284
|
-
cursor: store.busy ? 'default' : 'pointer',
|
|
285
|
-
opacity: store.busy ? 0.6 : 1,
|
|
398
|
+
cursor: store.busy || !usable ? 'default' : 'pointer',
|
|
399
|
+
opacity: store.busy || !usable ? 0.6 : 1,
|
|
286
400
|
},
|
|
287
401
|
},
|
|
288
402
|
store.busy ? '处理中…' : '+ 添加目录',
|
|
@@ -291,16 +405,22 @@ window.__ModuleLoader__.load({
|
|
|
291
405
|
'button',
|
|
292
406
|
{
|
|
293
407
|
type: 'button',
|
|
294
|
-
disabled: !!store.busy,
|
|
295
|
-
onClick: function () {
|
|
408
|
+
disabled: !!store.busy || !usable,
|
|
409
|
+
onClick: function () {
|
|
410
|
+
if (sessionMode) {
|
|
411
|
+
refresh(store.sessionId);
|
|
412
|
+
} else if (store.workspace) {
|
|
413
|
+
refreshWorkspace(store.workspace);
|
|
414
|
+
}
|
|
415
|
+
},
|
|
296
416
|
style: {
|
|
297
417
|
padding: '6px 10px',
|
|
298
418
|
borderRadius: 8,
|
|
299
419
|
border: '1px solid var(--color-border, rgba(127,127,127,0.35))',
|
|
300
420
|
background: 'transparent',
|
|
301
421
|
color: 'var(--color-text, inherit)',
|
|
302
|
-
cursor: store.busy ? 'default' : 'pointer',
|
|
303
|
-
opacity: store.busy ? 0.6 : 1,
|
|
422
|
+
cursor: store.busy || !usable ? 'default' : 'pointer',
|
|
423
|
+
opacity: store.busy || !usable ? 0.6 : 1,
|
|
304
424
|
},
|
|
305
425
|
},
|
|
306
426
|
'刷新',
|
|
@@ -321,6 +441,130 @@ window.__ModuleLoader__.load({
|
|
|
321
441
|
);
|
|
322
442
|
}
|
|
323
443
|
|
|
444
|
+
// Hero (session-creation page) support --------------------------------
|
|
445
|
+
/** Workspace path of the current session (blank-session hero), or null
|
|
446
|
+
* while no session/workspace is selected at all. */
|
|
447
|
+
function heroWorkspacePath() {
|
|
448
|
+
var sessionList = sessions && sessions.list ? sessions.list.getSnapshot() : null;
|
|
449
|
+
var id = sessionList && sessionList.current;
|
|
450
|
+
if (!id) return null;
|
|
451
|
+
var workspaceList = workspaces && workspaces.list ? workspaces.list.getSnapshot() : null;
|
|
452
|
+
var items = workspaceList && workspaceList.items ? workspaceList.items : [];
|
|
453
|
+
for (var i = 0; i < items.length; i++) {
|
|
454
|
+
var workspace = items[i];
|
|
455
|
+
if (workspace && workspace.path && Array.isArray(workspace.sessionIds) && workspace.sessionIds.indexOf(id) >= 0) {
|
|
456
|
+
return workspace.path;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
var row = sessionList && sessionList.byId ? sessionList.byId[id] : undefined;
|
|
460
|
+
return row && row.cwd ? row.cwd : null;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Re-read the conversation root's `data-phase` attribute (authoritative
|
|
464
|
+
* hero signal) and the derivable workspace, then patch the store. */
|
|
465
|
+
function syncHero() {
|
|
466
|
+
var phaseEl = null;
|
|
467
|
+
try {
|
|
468
|
+
if (typeof document !== 'undefined') phaseEl = document.querySelector('[data-phase]');
|
|
469
|
+
} catch (_) { /* no DOM (tests) */ }
|
|
470
|
+
var hero = !!phaseEl && phaseEl.getAttribute && phaseEl.getAttribute('data-phase') === 'hero';
|
|
471
|
+
var workspacePath = hero ? heroWorkspacePath() : null;
|
|
472
|
+
var current = getSnapshot();
|
|
473
|
+
if (current.hero !== hero || current.heroWorkspace !== workspacePath) {
|
|
474
|
+
patch({ hero: hero, heroWorkspace: workspacePath });
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** Fixed-position hero launcher (B2): visible only while the
|
|
479
|
+
* conversation root reports the hero phase. */
|
|
480
|
+
function HeroLauncher() {
|
|
481
|
+
var store = useStore();
|
|
482
|
+
React.useEffect(
|
|
483
|
+
function () {
|
|
484
|
+
var disposers = [];
|
|
485
|
+
if (sessions && sessions.list && typeof sessions.list.subscribe === 'function') {
|
|
486
|
+
disposers.push(sessions.list.subscribe(syncHero));
|
|
487
|
+
}
|
|
488
|
+
if (workspaces && workspaces.list && typeof workspaces.list.subscribe === 'function') {
|
|
489
|
+
disposers.push(workspaces.list.subscribe(syncHero));
|
|
490
|
+
}
|
|
491
|
+
syncHero();
|
|
492
|
+
var observer = null;
|
|
493
|
+
if (typeof document !== 'undefined' && document.body && typeof MutationObserver !== 'undefined') {
|
|
494
|
+
observer = new MutationObserver(syncHero);
|
|
495
|
+
observer.observe(document.body, {
|
|
496
|
+
subtree: true,
|
|
497
|
+
childList: true,
|
|
498
|
+
attributes: true,
|
|
499
|
+
attributeFilter: ['data-phase'],
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
return function () {
|
|
503
|
+
disposers.forEach(function (dispose) { dispose(); });
|
|
504
|
+
if (observer) observer.disconnect();
|
|
505
|
+
};
|
|
506
|
+
},
|
|
507
|
+
[],
|
|
508
|
+
);
|
|
509
|
+
if (!store.hero) return null;
|
|
510
|
+
return React.createElement(
|
|
511
|
+
'button',
|
|
512
|
+
{
|
|
513
|
+
type: 'button',
|
|
514
|
+
title: store.heroWorkspace ? '配置此项目的副工作目录(多工作目录)' : '请先选择工作区,再配置多工作目录',
|
|
515
|
+
onClick: function () { openForWorkspace(store.heroWorkspace); },
|
|
516
|
+
style: {
|
|
517
|
+
position: 'fixed',
|
|
518
|
+
bottom: 24,
|
|
519
|
+
right: 24,
|
|
520
|
+
zIndex: 300,
|
|
521
|
+
display: 'inline-flex',
|
|
522
|
+
alignItems: 'center',
|
|
523
|
+
gap: 6,
|
|
524
|
+
padding: '6px 12px',
|
|
525
|
+
borderRadius: 999,
|
|
526
|
+
border: '1px solid var(--color-border, rgba(127,127,127,0.35))',
|
|
527
|
+
background: 'var(--color-bg-elevated, #ffffff)',
|
|
528
|
+
color: 'var(--color-text, inherit)',
|
|
529
|
+
fontSize: 13,
|
|
530
|
+
cursor: 'pointer',
|
|
531
|
+
boxShadow: '0 4px 16px rgba(0,0,0,0.14)',
|
|
532
|
+
opacity: store.heroWorkspace ? 1 : 0.7,
|
|
533
|
+
},
|
|
534
|
+
},
|
|
535
|
+
'多工作目录',
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** Inline chip for the upstream `conversation.hero.workspaceExtras`
|
|
540
|
+
* slot (B1): rendered beside the workspace picker once the DSH core
|
|
541
|
+
* declares the slot; a no-op registration until then. */
|
|
542
|
+
function HeroChip(props) {
|
|
543
|
+
var store = useStore();
|
|
544
|
+
var workspacePath = props && props.workspacePath ? props.workspacePath : store.heroWorkspace;
|
|
545
|
+
return React.createElement(
|
|
546
|
+
'button',
|
|
547
|
+
{
|
|
548
|
+
type: 'button',
|
|
549
|
+
title: '配置此项目的副工作目录(多工作目录)',
|
|
550
|
+
onClick: function () { openForWorkspace(workspacePath); },
|
|
551
|
+
style: {
|
|
552
|
+
display: 'inline-flex',
|
|
553
|
+
alignItems: 'center',
|
|
554
|
+
gap: 6,
|
|
555
|
+
padding: '4px 10px',
|
|
556
|
+
borderRadius: 999,
|
|
557
|
+
border: '1px solid var(--color-border, rgba(127,127,127,0.35))',
|
|
558
|
+
background: 'transparent',
|
|
559
|
+
color: 'var(--color-text, inherit)',
|
|
560
|
+
fontSize: 13,
|
|
561
|
+
cursor: 'pointer',
|
|
562
|
+
},
|
|
563
|
+
},
|
|
564
|
+
'多工作目录',
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
|
|
324
568
|
// Registrations ------------------------------------------------------
|
|
325
569
|
slots.inject('conversation.session.header.actions', function () {
|
|
326
570
|
return slots.register(
|
|
@@ -334,6 +578,20 @@ window.__ModuleLoader__.load({
|
|
|
334
578
|
function () { return React.createElement(Panel); },
|
|
335
579
|
);
|
|
336
580
|
});
|
|
581
|
+
slots.inject('shell.overlay', function () {
|
|
582
|
+
return slots.register(
|
|
583
|
+
{ name: 'shell.overlay', id: 'multi-folder-hero', order: 200, label: '多工作目录(新会话)' },
|
|
584
|
+
function () { return React.createElement(HeroLauncher); },
|
|
585
|
+
);
|
|
586
|
+
});
|
|
587
|
+
// Upstream slot (B1): the callback fires only once a DSH build declares
|
|
588
|
+
// `conversation.hero.workspaceExtras`; until then this contributes nothing.
|
|
589
|
+
slots.inject('conversation.hero.workspaceExtras', function () {
|
|
590
|
+
return slots.register(
|
|
591
|
+
{ name: 'conversation.hero.workspaceExtras', id: 'multi-folder', order: 30, label: '多工作目录' },
|
|
592
|
+
function (props) { return React.createElement(HeroChip, props); },
|
|
593
|
+
);
|
|
594
|
+
});
|
|
337
595
|
}
|
|
338
596
|
|
|
339
597
|
exports.name = name;
|
package/lib/index.js
CHANGED
|
@@ -27,6 +27,14 @@
|
|
|
27
27
|
* plugin-sourced `notice` context channel.
|
|
28
28
|
* 5. `/multi-folder` command (list/add/remove/set): the human-command
|
|
29
29
|
* registry entry the browser UI drives through the Remote BFF.
|
|
30
|
+
* 6. Sessionless remote API: a `multiFolder` namespace registered through
|
|
31
|
+
* the Typert registry with hand-written `src-json` descriptors and a
|
|
32
|
+
* plain-object service (`ctx.provide('multiFolder', …)`). Methods are
|
|
33
|
+
* keyed by workspace PATH (not sessionId), so the session-creation page
|
|
34
|
+
* — where no session exists yet — can read and edit the configuration
|
|
35
|
+
* directly. The `/multi-folder` command and the remote methods share
|
|
36
|
+
* one core so validation, canonicalization, and the config guard are
|
|
37
|
+
* identical on both channels.
|
|
30
38
|
*/
|
|
31
39
|
|
|
32
40
|
import { join } from 'node:path'
|
|
@@ -48,7 +56,7 @@ const INTERCEPT_TOOLS = new Set(['write', 'edit', 'pwsh', 'bash'])
|
|
|
48
56
|
const JSON_MARK = '[MF:JSON]'
|
|
49
57
|
const CONFIG_GUARD_TEXT =
|
|
50
58
|
'This file is managed by the dsh-multi-folder plugin. Secondary working directories may only be ' +
|
|
51
|
-
'configured by the user through the UI
|
|
59
|
+
'configured by the user through the UI (session header or session-creation page) or the /multi-folder command; direct edits are rejected.'
|
|
52
60
|
|
|
53
61
|
export function apply(ctx) {
|
|
54
62
|
const { fs, sandboxPolicy, systemPrompt } = ctx
|
|
@@ -150,6 +158,139 @@ export function apply(ctx) {
|
|
|
150
158
|
return 'Secondary working directories for ' + ws + ':\n' + dirs.map((d) => '- ' + d).join('\n')
|
|
151
159
|
}
|
|
152
160
|
|
|
161
|
+
// ----------------------------------------------------- shared config core
|
|
162
|
+
// One validated, canonicalizing write-through core shared by the
|
|
163
|
+
// `/multi-folder` command and the sessionless `multiFolder/*` remote
|
|
164
|
+
// endpoints. Errors thrown here carry bare messages; each channel adds
|
|
165
|
+
// its own `multi-folder: ` prefix.
|
|
166
|
+
|
|
167
|
+
const requireWorkspace = (ws) => {
|
|
168
|
+
if (typeof ws !== 'string' || ws.length === 0) throw new Error('workspace is required')
|
|
169
|
+
return ws
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const coreList = async (ws) => {
|
|
173
|
+
ws = requireWorkspace(ws)
|
|
174
|
+
const entry = await loadDirs(ws)
|
|
175
|
+
return { workspace: ws, dirs: [...entry.dirs], changed: false }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const coreAdd = async (ws, path) => {
|
|
179
|
+
ws = requireWorkspace(ws)
|
|
180
|
+
if (typeof path !== 'string' || path.length === 0) throw new Error('add requires a path')
|
|
181
|
+
if (!isAbsolute(path)) throw new Error('add requires an absolute path')
|
|
182
|
+
const canonical = await canonicalizeAbs(path)
|
|
183
|
+
if (canonical === null) throw new Error('cannot resolve path "' + path + '"')
|
|
184
|
+
const entry = await loadDirs(ws)
|
|
185
|
+
const next = sanitizeDirs([...entry.dirs, canonical], ws)
|
|
186
|
+
const changed = next.length !== entry.dirs.length
|
|
187
|
+
entry.dirs = next
|
|
188
|
+
if (changed) await saveDirs(ws, next)
|
|
189
|
+
return { workspace: ws, dirs: [...next], changed }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const coreRemove = async (ws, path) => {
|
|
193
|
+
ws = requireWorkspace(ws)
|
|
194
|
+
if (typeof path !== 'string' || path.length === 0) throw new Error('remove requires a path')
|
|
195
|
+
const canonical = await canonicalizeAbs(path)
|
|
196
|
+
const key = wsKey(canonical === null ? path : canonical)
|
|
197
|
+
const entry = await loadDirs(ws)
|
|
198
|
+
const next = entry.dirs.filter((d) => wsKey(d) !== key)
|
|
199
|
+
const changed = next.length !== entry.dirs.length
|
|
200
|
+
entry.dirs = next
|
|
201
|
+
if (changed) await saveDirs(ws, next)
|
|
202
|
+
return { workspace: ws, dirs: [...next], changed }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const coreSet = async (ws, paths) => {
|
|
206
|
+
ws = requireWorkspace(ws)
|
|
207
|
+
if (!Array.isArray(paths)) throw new Error('set requires an array of absolute paths')
|
|
208
|
+
const canon = []
|
|
209
|
+
for (const p of paths) {
|
|
210
|
+
if (!isAbsolute(p)) throw new Error('set requires absolute paths')
|
|
211
|
+
const c = await canonicalizeAbs(p)
|
|
212
|
+
if (c === null) throw new Error('cannot resolve path "' + p + '"')
|
|
213
|
+
canon.push(c)
|
|
214
|
+
}
|
|
215
|
+
const entry = await loadDirs(ws)
|
|
216
|
+
const next = sanitizeDirs(canon, ws)
|
|
217
|
+
const changed = JSON.stringify(next) !== JSON.stringify(entry.dirs)
|
|
218
|
+
entry.dirs = next
|
|
219
|
+
if (changed) await saveDirs(ws, next)
|
|
220
|
+
return { workspace: ws, dirs: [...next], changed }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ----------------------------------------------- sessionless remote API
|
|
224
|
+
// `multiFolder/*` endpoints over the Typert gateway. Hand-written
|
|
225
|
+
// `src-json` descriptors registered through ctx.typert.register (the
|
|
226
|
+
// sanctioned manual path documented by dsh-typert-loader) plus a
|
|
227
|
+
// plain-object service carrying the gateway's typertRemote binding.
|
|
228
|
+
// No session is involved: parameters are the workspace path and paths.
|
|
229
|
+
|
|
230
|
+
const remoteErrorMessage = (e) =>
|
|
231
|
+
'multi-folder: ' + String(e && e.message ? e.message : e).replace(/^multi-folder:\s*/, '')
|
|
232
|
+
|
|
233
|
+
const multiFolderApi = {
|
|
234
|
+
async list(workspace) {
|
|
235
|
+
try {
|
|
236
|
+
return await coreList(workspace)
|
|
237
|
+
} catch (e) {
|
|
238
|
+
throw new Error(remoteErrorMessage(e))
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
async add(workspace, path) {
|
|
242
|
+
try {
|
|
243
|
+
return await coreAdd(workspace, path)
|
|
244
|
+
} catch (e) {
|
|
245
|
+
throw new Error(remoteErrorMessage(e))
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
async remove(workspace, path) {
|
|
249
|
+
try {
|
|
250
|
+
return await coreRemove(workspace, path)
|
|
251
|
+
} catch (e) {
|
|
252
|
+
throw new Error(remoteErrorMessage(e))
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
async set(workspace, dirs) {
|
|
256
|
+
try {
|
|
257
|
+
return await coreSet(workspace, dirs)
|
|
258
|
+
} catch (e) {
|
|
259
|
+
throw new Error(remoteErrorMessage(e))
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
}
|
|
263
|
+
Object.defineProperty(multiFolderApi, 'typertRemote', {
|
|
264
|
+
value: Object.freeze({
|
|
265
|
+
service: multiFolderApi,
|
|
266
|
+
serviceKey: 'multiFolder',
|
|
267
|
+
namespace: 'multiFolder',
|
|
268
|
+
}),
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
const remoteParam = (name) => ({ name, wire: name, source: 'json', codec: { mode: 'src-json' } })
|
|
272
|
+
const remoteInvocation = (method, params) => ({
|
|
273
|
+
id: 'dsh-multi-folder#multiFolder/' + method,
|
|
274
|
+
service: 'multiFolder',
|
|
275
|
+
namespace: 'multiFolder',
|
|
276
|
+
method,
|
|
277
|
+
invocation: { kind: 'direct' },
|
|
278
|
+
parameters: params.map(remoteParam),
|
|
279
|
+
result: { mode: 'src-json' },
|
|
280
|
+
})
|
|
281
|
+
const REMOTE_CONTRIBUTION = {
|
|
282
|
+
package: 'dsh-multi-folder',
|
|
283
|
+
face: 'host',
|
|
284
|
+
schemas: [],
|
|
285
|
+
model: { services: [], events: [], objects: [] },
|
|
286
|
+
invocations: [
|
|
287
|
+
remoteInvocation('list', ['workspace']),
|
|
288
|
+
remoteInvocation('add', ['workspace', 'path']),
|
|
289
|
+
remoteInvocation('remove', ['workspace', 'path']),
|
|
290
|
+
remoteInvocation('set', ['workspace', 'dirs']),
|
|
291
|
+
],
|
|
292
|
+
}
|
|
293
|
+
|
|
153
294
|
// -------------------------------------------------------- notice channel
|
|
154
295
|
const noticeMessage = (text) => ({
|
|
155
296
|
id: 'mf-note-' + (++noteSeq),
|
|
@@ -435,62 +576,45 @@ export function apply(ctx) {
|
|
|
435
576
|
if (typeof ws !== 'string' || ws.length === 0) {
|
|
436
577
|
return { kind: 'error', text: 'multi-folder: session workspace is unknown' }
|
|
437
578
|
}
|
|
438
|
-
const entry = await loadDirs(ws)
|
|
439
579
|
const argv = parseArgs(invocation.rawInput)
|
|
440
580
|
const sub = argv.length === 0 ? 'list' : argv[0].toLowerCase()
|
|
441
|
-
let
|
|
442
|
-
let changed = false
|
|
581
|
+
let outcome
|
|
443
582
|
if (sub === 'list') {
|
|
444
|
-
|
|
445
|
-
}
|
|
446
|
-
|
|
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
|
|
583
|
+
outcome = await coreList(ws)
|
|
584
|
+
} else if (sub === 'add') {
|
|
585
|
+
outcome = await coreAdd(ws, argv.slice(1).join(' '))
|
|
456
586
|
} else if (sub === 'remove') {
|
|
457
|
-
|
|
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
|
|
587
|
+
outcome = await coreRemove(ws, argv.slice(1).join(' '))
|
|
463
588
|
} else if (sub === 'set') {
|
|
464
|
-
|
|
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)
|
|
589
|
+
outcome = await coreSet(ws, argv.slice(1))
|
|
475
590
|
} else {
|
|
476
591
|
return {
|
|
477
592
|
kind: 'error',
|
|
478
593
|
text: 'multi-folder: unknown subcommand "' + sub + '" (use list / add / remove / set)',
|
|
479
594
|
}
|
|
480
595
|
}
|
|
481
|
-
|
|
482
|
-
if (changed) {
|
|
483
|
-
await saveDirs(ws, next)
|
|
596
|
+
if (outcome.changed) {
|
|
484
597
|
armNotice(
|
|
485
598
|
invocation.agent,
|
|
486
|
-
'Secondary working directories changed (dsh-multi-folder):\n' + dirsText(ws,
|
|
599
|
+
'Secondary working directories changed (dsh-multi-folder):\n' + dirsText(ws, outcome.dirs),
|
|
487
600
|
)
|
|
488
601
|
}
|
|
489
|
-
return { kind: 'success', text: resultText(
|
|
602
|
+
return { kind: 'success', text: resultText(outcome.workspace, outcome.dirs, outcome.changed) }
|
|
490
603
|
} catch (e) {
|
|
491
|
-
return {
|
|
604
|
+
return {
|
|
605
|
+
kind: 'error',
|
|
606
|
+
text: 'multi-folder: ' + String(e && e.message ? e.message : e).replace(/^multi-folder:\s*/, ''),
|
|
607
|
+
}
|
|
492
608
|
}
|
|
493
609
|
},
|
|
494
610
|
})
|
|
495
611
|
})
|
|
612
|
+
|
|
613
|
+
// ------------------------------------------ sessionless remote API mounts
|
|
614
|
+
// The plain-object service must be reachable through ctx.get('multiFolder')
|
|
615
|
+
// with a visible typertRemote binding, and the typert registry entry makes
|
|
616
|
+
// the endpoints claimable on the shared /api channel. Both are owned by
|
|
617
|
+
// this plugin fiber, so unloading the plugin withdraws them together.
|
|
618
|
+
ctx.provide('multiFolder', multiFolderApi)
|
|
619
|
+
ctx.inject(['typert'], (t) => t.typert.register(REMOTE_CONTRIBUTION))
|
|
496
620
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-multi-folder",
|
|
3
|
-
"version": "0.1.
|
|
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.",
|
|
3
|
+
"version": "0.1.1",
|
|
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",
|
|
7
7
|
"dsh",
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
"inject": [
|
|
48
48
|
"@deepseek-ai/dsh-api-gateway",
|
|
49
49
|
"@deepseek-ai/dsh-api-remotes",
|
|
50
|
+
"@deepseek-ai/dsh-client-connection",
|
|
50
51
|
"@deepseek-ai/dsh-client-runtime"
|
|
51
52
|
]
|
|
52
53
|
}
|