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 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` (or an equivalent composition that provides `fs`, `sandboxPolicy`, `systemPrompt`, `commands`, `shell`, `shellEnv`, and the standard web client modules).
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 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
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
- - **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`).
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
- - 中文说明:[README.zh.md](README.zh.md)
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(或提供 `fs`、`sandboxPolicy`、`systemPrompt`、`commands`、`shell`、`shellEnv` 及标准 Web 客户端模块的等价组合)。
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 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
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
- - **客户端**——手写维护的 factory bundle(`window.__ModuleLoader__.load`),无需构建工具链;面板经 Remote BFF(`ctx.remote.commands.execute`)驱动宿主。
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
- - English README: [README.md](README.md)
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 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.
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; drives the host through the Remote BFF |
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 the command handler (user-initiated) with an explicit
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) 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).
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, and the panel's session-switch/caching behavior.
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(sessionId) {
201
+ function addDirectory() {
111
202
  workspaces.pickDirectory().then(function (path) {
112
203
  if (path === null || path === undefined) return;
113
- mutate(sessionId, '/multi-folder add "' + path.replace(/"/g, '\\"') + '"');
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.sessionId) return null;
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
- onClick: function () { mutate(store.sessionId, '/multi-folder remove "' + dir.replace(/"/g, '\\"') + '"'); },
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
- : null,
264
- rows.length > 0 ? rows : React.createElement('div', { style: { marginBottom: 8, color: 'var(--color-text-muted, #6b6b6b)' } }, '尚未配置副工作目录。'),
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(store.sessionId); },
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 () { refresh(store.sessionId); },
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 panel or the /multi-folder command; direct edits are rejected.'
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 next
442
- let changed = false
581
+ let outcome
443
582
  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
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
- 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
587
+ outcome = await coreRemove(ws, argv.slice(1).join(' '))
463
588
  } 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)
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
- entry.dirs = next
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, next),
599
+ 'Secondary working directories changed (dsh-multi-folder):\n' + dirsText(ws, outcome.dirs),
487
600
  )
488
601
  }
489
- return { kind: 'success', text: resultText(ws, next, changed) }
602
+ return { kind: 'success', text: resultText(outcome.workspace, outcome.dirs, outcome.changed) }
490
603
  } catch (e) {
491
- return { kind: 'error', text: 'multi-folder: ' + String(e) }
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.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.",
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
  }