dsh-multi-folder 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,33 +13,28 @@ 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 Multi-folder entry (「多工作目录」 in the Chinese UI) that reads and edits the same per-workspace configuration through a **sessionless remote API** (`multiFolder/*` endpoints) — no session id required.
17
+ - **Localized UI.** The button, panel, and creation-page entries follow the DSH locale (the browser language or the Language setting in Settings): "Multi-folder" in English, 「多工作目录」 in Chinese.
16
18
  - **No new tools.** Everything is a framework-level change (tool-pipeline interception) plus a UI-level change (a session-scoped header entry).
17
19
 
18
20
  ## Requirements
19
21
 
20
22
  - 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).
23
+ - A DSH profile composed from `@deepseek-ai/dsh-base` + `@deepseek-ai/dsh-web-app`
22
24
 
23
25
  ## Install
24
26
 
25
27
  Link this repository into a DSH profile:
26
28
 
27
29
  ```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
30
+ dsh plugin --profile web add dsh-multi-folder
36
31
  ```
37
32
 
38
33
  Then **restart the DSH backend** (host composition loads at process start) and **refresh the browser page** (the client bundle is served `no-cache`).
39
34
 
40
35
  ## Usage
41
36
 
42
- A **「多工作目录 / Multi-folder」** button appears in the session header. The panel lets you:
37
+ A Multi-folder button (「多工作目录」 in the Chinese UI) 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
38
 
44
39
  | Action | Behavior |
45
40
  | ------ | -------- |
@@ -65,15 +60,16 @@ The agent needs nothing extra: `read` / `glob` / `grep` work everywhere, and `wr
65
60
  - **Prompt injection** — one ordered `systemPrompt` section with a text provider evaluated per assembly, rendering only for sessions whose workspace has configured directories.
66
61
  - **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
62
  - **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`).
63
+ - **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.
64
+ - **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
65
 
70
66
  ## Project layout
71
67
 
72
68
  | Path | Purpose |
73
69
  | ---- | ------- |
74
70
  | `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 |
71
+ | `lib/index.js` | Host plugin: config store, tool-pipeline interception, prompt injection, dual-channel notifications, `/multi-folder` command, sessionless `multiFolder/*` remote API |
72
+ | `lib/client.js` | Client plugin (factory bundle): session-header button + overlay panel + session-creation page entry (hero launcher / upstream hero chip) |
77
73
  | `test/` | Runtime-free behavior tests (see Development) |
78
74
  | `docs/` | Design and analysis documents |
79
75
 
@@ -82,7 +78,7 @@ The agent needs nothing extra: `read` / `glob` / `grep` work everywhere, and `wr
82
78
  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
79
 
84
80
  ```bash
85
- node test/smoke-host.mjs # host apply smoke test
81
+ node test/smoke-host.mjs # host apply smoke test + remote API behavior
86
82
  node test/intercept.mjs # interception / command / notification behavior
87
83
  node test/smoke-client.mjs # client bundle + panel flows (React shim)
88
84
  ```
@@ -92,7 +88,7 @@ Before modifying `lib/client.js`, see [docs/design.md](docs/design.md) for the b
92
88
  ## Documentation
93
89
 
94
90
  - [docs/design.md](docs/design.md) — architecture and security model
95
- - 中文说明:[README.zh.md](README.zh.md)
91
+ - [docs/upstream-hero-slot.md](docs/upstream-hero-slot.md) — the upstream `conversation.hero.workspaceExtras` slot change (B1) and its plugin-side consumption
96
92
 
97
93
  ## Contributing
98
94
 
package/README.zh.md CHANGED
@@ -13,33 +13,28 @@
13
13
  - 在 **Workspace Write** 模式下,Agent 对配置的副工作目录拥有与主工作目录**同等的读取、写入、编辑与命令执行权限**——实现方式是重定向会话自身的沙箱策略根,因此每种模式语义都自然保持(`read-only` 依旧拒绝、`workspace-write` 放行、`danger-full-access` 放行);
14
14
  - 目录列表**注入系统提示词**,每次组装按会话求值;
15
15
  - 配置变更通过**不打断的消息队列**通知 Agent——在下一次消息边界(用户发送或工具调用结束)送达,且**仅在目录集合实际变化时**发送;
16
+ - **会话开始前即可配置**:会话创建页(新会话界面)提供「多工作目录」入口(英文界面显示 "Multi-folder"),通过**无会话远程 API**(`multiFolder/*` 端点)读写同一份 per-workspace 配置——无需 session id;
17
+ - **界面本地化**:按钮、面板与创建页入口跟随 DSH 的语言设置(浏览器语言或设置中的 Language 选项):英文界面显示 "Multi-folder",中文界面显示「多工作目录」。
16
18
  - **不新增任何工具**:改动全部位于框架级(工具流水线拦截)与 UI 级(会话级头部入口)。
17
19
 
18
20
  ## 环境要求
19
21
 
20
22
  - Node.js >= 20
21
- - 由 `@deepseek-ai/dsh-base` + `@deepseek-ai/dsh-web-app` 组成的 DSH profile(或提供 `fs`、`sandboxPolicy`、`systemPrompt`、`commands`、`shell`、`shellEnv` 及标准 Web 客户端模块的等价组合)。
23
+ - 由 `@deepseek-ai/dsh-base` + `@deepseek-ai/dsh-web-app` 组成的 DSH profile
22
24
 
23
25
  ## 安装
24
26
 
25
27
  将本仓库链接进 DSH profile:
26
28
 
27
29
  ```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
30
+ dsh plugin --profile web add dsh-multi-folder
36
31
  ```
37
32
 
38
33
  然后**重启 DSH 后端**(宿主组合在进程启动时装载)并**刷新浏览器页面**(客户端 bundle 以 `no-cache` 提供)。
39
34
 
40
35
  ## 使用
41
36
 
42
- 会话头部出现「多工作目录」按钮,打开面板即可:
37
+ 会话头部出现「多工作目录」按钮(英文界面显示 "Multi-folder");**会话创建页**也有入口(新会话界面右下角的浮动按钮;当上游 DSH 声明 `conversation.hero.workspaceExtras` 插槽后,还会在工作区选择器旁显示内联 chip)。打开面板即可:
43
38
 
44
39
  | 操作 | 行为 |
45
40
  | ---- | ---- |
@@ -65,15 +60,16 @@ Agent 无需任何额外操作:`read` / `glob` / `grep` 随处可用;`write`
65
60
  - **提示词注入**——一个有序 `systemPrompt` 段落,text provider 每次组装按会话求值,仅为配置了副目录的会话渲染。
66
61
  - **通知**——命令处理器仅在目录集合实际变化时置位 pending notice;`agent/pre-step`(前置注入进入批次)与 `tools/post-execute`(附加为 `additionalContexts`)两个通道中先触发者消费——均使用框架原生的插件来源 `notice` 上下文。
67
62
  - **配置与安全边界**——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`)驱动宿主。
63
+ - **无会话远程 API**——经 `ctx.typert.register` 注册 `multiFolder` 命名空间(手写 `src-json` 描述符),并以普通对象服务 `multiFolder` 提供;`list`/`add`/`remove`/`set` 以工作区**路径**为键,与 `/multi-folder` 命令共享同一套校验核心,因此会话尚未建立时创建页也能直接配置。
64
+ - **客户端**——手写维护的 factory bundle(`window.__ModuleLoader__.load`),无需构建工具链;面板经两条通道驱动宿主:会话内走 Remote BFF(`ctx.remote.commands.execute`),无会话端点走共享 `/api` RPC 通道(`ctx.connection.rpc.call`)。
69
65
 
70
66
  ## 目录结构
71
67
 
72
68
  | 路径 | 作用 |
73
69
  | ---- | ---- |
74
70
  | `cordis.patch.yml` | profile patch 层,插入 `dsh-multi-folder` 行 |
75
- | `lib/index.js` | 宿主插件:配置存储、工具流水线拦截、提示词注入、双通道通知、`/multi-folder` 命令 |
76
- | `lib/client.js` | 客户端插件(factory bundle):会话头部按钮 + 覆盖层面板 |
71
+ | `lib/index.js` | 宿主插件:配置存储、工具流水线拦截、提示词注入、双通道通知、`/multi-folder` 命令、无会话 `multiFolder/*` 远程 API |
72
+ | `lib/client.js` | 客户端插件(factory bundle):会话头部按钮 + 覆盖层面板 + 会话创建页入口(hero 浮动按钮 / 上游 hero chip) |
77
73
  | `test/` | 免 DSH 运行时的行为测试(见开发) |
78
74
  | `docs/` | 设计与分析文档 |
79
75
 
@@ -82,7 +78,7 @@ Agent 无需任何额外操作:`read` / `glob` / `grep` 随处可用;`write`
82
78
  零构建步骤:宿主半边为纯 ESM,`lib/client.js` 为 DSH client-modules 格式的手写 factory bundle。测试直接用 Node 运行:
83
79
 
84
80
  ```bash
85
- node test/smoke-host.mjs # 宿主 apply 冒烟
81
+ node test/smoke-host.mjs # 宿主 apply 冒烟 + 远程 API 行为
86
82
  node test/intercept.mjs # 拦截 / 命令 / 通知行为
87
83
  node test/smoke-client.mjs # 客户端 bundle 与面板流程(React shim)
88
84
  ```
@@ -92,7 +88,7 @@ node test/smoke-client.mjs # 客户端 bundle 与面板流程(React shim)
92
88
  ## 文档
93
89
 
94
90
  - [docs/design.md](docs/design.md) — 架构与安全模型(英文)
95
- - English README: [README.md](README.md)
91
+ - [docs/upstream-hero-slot.md](docs/upstream-hero-slot.md) — 上游 `conversation.hero.workspaceExtras` 插槽改动(B1)与插件的对接方式(英文)
96
92
 
97
93
  ## 参与贡献
98
94
 
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,58 @@ 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', 'locale']`; 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`,
151
+ `@deepseek-ai/dsh-client-connection`, `@deepseek-ai/dsh-client-locale`,
101
152
  `@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).
153
+ - UI registrations: `conversation.session.header.actions` (session-scoped button),
154
+ `shell.overlay` panel, `shell.overlay` hero launcher (root-scoped, fixed
155
+ position), and `conversation.hero.workspaceExtras` (upstream slot; see
156
+ below). One module-level store is shared by all of them.
157
+ - **Localization (zh / en).** All client copy goes through
158
+ `@deepseek-ai/dsh-client-locale` (always composed by the standard web
159
+ profile). The bundle registers a `multi-folder` dictionary namespace with
160
+ `ctx.effect(() => locale.register(NS, { zh, en }))` the locale service
161
+ enforces bilingual balance, and the effect ties the dictionaries to the
162
+ plugin fiber. Every slot registration declares `locale: 'multi-folder'`,
163
+ so the renderer synthesizes the `t` seat on component props and
164
+ re-renders mounted outlets on locale switch; list-entry `label`s are
165
+ thunks (`() => t('label')`) that `resolveSlotLabel` re-evaluates per read,
166
+ so registration-time text follows the active locale without
167
+ re-registering. The active locale is the browser language or the user's
168
+ Language preference in Settings; the English UI reads "Multi-folder", the
169
+ Chinese UI keeps 「多工作目录」.
170
+ - Host communication, two channels:
171
+ - session mode: `ctx.remote.commands.execute(sessionId, line)`. The return
172
+ value is the RPC envelope `{ ok, value }` where `value` is the
173
+ `CommandExecution`; command result text carries a `[MF:JSON] {…}` line the
174
+ panel parses for structured state.
175
+ - workspace mode (session-creation page): `ctx.connection.rpc.call('/api',
176
+ 'multiFolder/<op>', { args })` against the sessionless remote endpoints.
177
+ The panel runs in either mode according to how it was opened; mutations
178
+ and refreshes route per mode, and both modes share the same row/error UI.
179
+ - Session switch: a `React.useEffect` on `sessionId` re-points the open panel
180
+ to the current session (reusing the per-session cache) — this also folds a
181
+ workspace-mode panel back into session mode once the first message creates
182
+ the session.
183
+ - Caching: per-session cache (`sessionCache`) keeps pure reads off the
184
+ conversation; per-workspace cache (`workspaceCache`) plays the same role for
185
+ the sessionless channel.
186
+ - Hero (session-creation page) support:
187
+ - The **hero launcher** (`shell.overlay` entry, `multi-folder-hero`)
188
+ subscribes to `sessions.list` + `workspaces.list` and observes the
189
+ conversation root's `data-phase="hero"` attribute (MutationObserver on
190
+ `document.body`). While the hero is visible it renders a fixed-position
191
+ 「多工作目录」 button; the workspace path is derived from the current
192
+ (blank) session's `WorkspaceView.path`, falling back to `SessionSummary.cwd`.
193
+ - Clicking it opens the panel in workspace mode; without a selected
194
+ workspace the panel shows the "pick a workspace first" hint.
195
+ - The **hero chip** registers into `conversation.hero.workspaceExtras` via
196
+ `slots.inject`, which waits for the declaration: with an upstream DSH
197
+ build that declares the slot, the chip renders inline beside the workspace
198
+ picker; without one, the registration is a harmless no-op and the fixed
199
+ launcher covers the page.
113
200
 
114
201
  ## Known limitations
115
202
 
@@ -123,11 +210,28 @@ window.__ModuleLoader__.load({
123
210
  secondary directories are passed through to the default pipeline.
124
211
  - The `/multi-folder` command lifecycle rows (`command/run`, `command/done`) are
125
212
  visible in the conversation UI by framework design; they are log-only and never
126
- reach the model.
213
+ reach the model. Workspace-mode (session-creation page) operations avoid them
214
+ entirely by using the sessionless remote channel.
215
+ - The hero launcher relies on the conversation root's `data-phase="hero"`
216
+ attribute and the `sessions.list`/`workspaces.list` snapshot shapes — DOM and
217
+ client-runtime internals rather than documented APIs. They are guarded
218
+ defensively (missing services or DOM degrade to "launcher hidden"), and the
219
+ upstream `conversation.hero.workspaceExtras` slot (see
220
+ [upstream-hero-slot.md](upstream-hero-slot.md)) is the long-term surface.
221
+ - The `multiFolder/*` endpoints use hand-written `src-json` Typert descriptors
222
+ registered through `ctx.typert.register`. `src-json` gives JSON-safety
223
+ boundary checks, not schema validation; the shared core performs all
224
+ business validation server-side. DSH versions that change the Typert
225
+ registry contract would need this contribution revisited (the tests assert
226
+ the descriptor shape).
127
227
 
128
228
  ## Tests
129
229
 
130
230
  `test/smoke-host.mjs`, `test/intercept.mjs`, and `test/smoke-client.mjs` run without
131
231
  the DSH runtime using mock services and a React shim. They cover interception,
132
232
  canonicalization, the config guard, both notification channels, notice
133
- gating, command flows, and the panel's session-switch/caching behavior.
233
+ gating, command flows, the panel's session-switch/caching behavior, the
234
+ sessionless remote contribution shape and behavior (list/add/set/remove,
235
+ idempotence, sanitization, error prefixing, cross-channel cache coherence),
236
+ and the hero/workspace-mode client flows (launcher visibility, RPC routing,
237
+ workspace-mode mutations and error surfacing).
@@ -0,0 +1,127 @@
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. The entry
82
+ declares the plugin's `multi-folder` locale namespace, so the chip renders
83
+ through the framework `t` seat and its `label` is a thunk that follows the
84
+ active locale (English: "Multi-folder", Chinese: 「多工作目录」):
85
+
86
+ ```js
87
+ slots.inject('conversation.hero.workspaceExtras', function () {
88
+ return slots.register(
89
+ { name: 'conversation.hero.workspaceExtras', id: 'multi-folder', order: 30, label: () => t('label'), locale: NS },
90
+ function (props) { return React.createElement(HeroChip, props); },
91
+ );
92
+ });
93
+ ```
94
+
95
+ (`NS` is the plugin's `multi-folder` namespace and `t` its bound translator —
96
+ see the localization section of [design.md](design.md).)
97
+
98
+ `HeroChip` prefers the owner-supplied `props.workspacePath` and falls back to
99
+ the store-derived hero workspace. It opens the same overlay panel in workspace
100
+ mode, which reads and writes the configuration through the sessionless
101
+ `multiFolder/*` endpoints (see [design.md](design.md)).
102
+
103
+ ## Compiled-bundle equivalent (for local patching)
104
+
105
+ The shipped `lib/client.js` of `dsh-client-ui-conversation` is a compiled
106
+ bundle; the same change there is three touchpoints:
107
+
108
+ 1. `SlotMap` declaration — the `declare module` block compiles into the
109
+ registration data; add the `'conversation.hero.workspaceExtras': { kind:
110
+ 'list', scope: 'root' }` child entry to the `children` table of the
111
+ `conversation` registration (around the existing
112
+ `"conversation.hero.agentPreset"` entry).
113
+ 2. The render site — inside `heroWorkspaceRow`, after
114
+ `renderSlot("conversation.hero.agentPreset", {})`, add
115
+ `renderSlot("conversation.hero.workspaceExtras", { workspacePath: (pendingWorkspace ?? sessionWorkspace)?.path })`.
116
+ 3. Rebuild the web bundle and refresh the page (bundled shell changes require
117
+ a rebuild; the plugin's own bundle is served no-cache).
118
+
119
+ ## Verification
120
+
121
+ - Unit: the plugin's `test/smoke-client.mjs` registers and drives `HeroChip`
122
+ with a mock owner share (`workspacePath`), asserting the panel opens in
123
+ workspace mode and reuses the per-workspace cache.
124
+ - Manual: with the patched build, the Multi-folder chip (「多工作目录」 in the
125
+ Chinese UI) renders in the hero workspace row between the preset chip and
126
+ the composer; clicking it lists the workspace's secondary directories
127
+ before any message is sent.
package/lib/client.js CHANGED
@@ -1,14 +1,27 @@
1
1
  /**
2
2
  * dsh-multi-folder — client half (hand-written factory bundle, no build step).
3
3
  *
4
- * Session-scoped UI: a "多工作目录" button in the conversation session header
5
- * (`conversation.session.header.actions`, scope: session) that opens a panel in
6
- * `shell.overlay` listing the project's secondary working directories.
4
+ * Session-scoped UI: a localized "Multi-folder" (「多工作目录」) button in the
5
+ * conversation session header (`conversation.session.header.actions`, scope:
6
+ * session) that opens a panel in `shell.overlay` listing the project's
7
+ * secondary working directories. All copy goes through the framework locale
8
+ * service (`@deepseek-ai/dsh-client-locale`) — see the "i18n" section below.
7
9
  * Mutations go through the Host `/multi-folder` command via the Remote BFF
8
10
  * (`ctx.remote.commands.execute(sessionId, line)`); the Host answers with a
9
11
  * human-readable result carrying a `[MF:JSON]` line the panel parses for
10
12
  * structured state.
11
13
  *
14
+ * Session-creation page UI (no session exists yet):
15
+ * - a hero-phase launcher in `shell.overlay` (fixed-position entry) whose
16
+ * visibility follows the conversation root's `data-phase="hero"` attribute;
17
+ * - an inline chip registered into the upstream `conversation.hero.workspaceExtras`
18
+ * slot when that slot exists (the `slots.inject` wait is a no-op until the
19
+ * DSH core declares it).
20
+ * Both open the same panel in WORKSPACE mode and drive the sessionless
21
+ * `multiFolder/*` endpoints over the shared RPC channel
22
+ * (`ctx.connection.rpc.call('/api', endpoint, { args })`), keyed by workspace
23
+ * path instead of sessionId.
24
+ *
12
25
  * Layout shape: an entry in the session header action row plus a frame-wide
13
26
  * overlay panel. Both read one tiny module-scoped store; opening the panel
14
27
  * refreshes the list from the Host.
@@ -22,7 +35,7 @@ window.__ModuleLoader__.load({
22
35
 
23
36
  // ------------------------------------------------------------- store
24
37
  var listeners = new Set();
25
- var state = { open: false, sessionId: null, dirs: [], workspace: null, busy: false, error: null };
38
+ var state = { open: false, mode: null, sessionId: null, dirs: [], workspace: null, busy: false, error: null, hero: false, heroWorkspace: null };
26
39
  function patch(next) {
27
40
  state = Object.assign({}, state, next);
28
41
  listeners.forEach(function (fn) { fn(); });
@@ -36,6 +49,13 @@ window.__ModuleLoader__.load({
36
49
  /** sessionId -> { dirs, workspace }: avoids re-running the list command
37
50
  * (and its conversation row) on every panel open. */
38
51
  var sessionCache = {};
52
+ /** workspacePathKey -> { dirs, workspace }: the workspace-mode twin of
53
+ * sessionCache, keyed by the sessionless remote's workspace argument. */
54
+ var workspaceCache = {};
55
+
56
+ function workspacePathKey(path) {
57
+ return String(path).replace(/\\/g, '/').toLowerCase().replace(/\/+$/, '');
58
+ }
39
59
 
40
60
  function parseJsonLine(text) {
41
61
  if (typeof text !== 'string') return null;
@@ -44,14 +64,154 @@ window.__ModuleLoader__.load({
44
64
  try { return JSON.parse(m[1]); } catch (_) { return null; }
45
65
  }
46
66
 
67
+ // ------------------------------------------------------------- i18n
68
+ /** Locale namespace owned by this plugin. Dictionaries are registered
69
+ * with the `locale` service (`@deepseek-ai/dsh-client-locale`, always
70
+ * composed by the standard web profile), which enforces bilingual
71
+ * balance: both shipped locales (zh, en) must be registered together. */
72
+ var NS = 'multi-folder';
73
+ /** Simplified Chinese dictionary — the key-set source of truth. */
74
+ var zhDict = {
75
+ 'label': '多工作目录',
76
+ 'label.open': '多工作目录 ▾',
77
+ 'label.heroLauncher': '多工作目录(新会话)',
78
+ 'title.header': '多工作目录(副工作目录)',
79
+ 'title.remove': '移除此副工作目录',
80
+ 'title.close': '关闭',
81
+ 'title.heroLauncher.hasWorkspace': '配置此项目的副工作目录(多工作目录)',
82
+ 'title.heroLauncher.noWorkspace': '请先选择工作区,再配置多工作目录',
83
+ 'title.heroChip': '配置此项目的副工作目录(多工作目录)',
84
+ 'panel.title': '多工作目录(副工作目录)',
85
+ 'panel.project': '项目:{path}',
86
+ 'panel.noWorkspaceHint': '尚未选择工作区。请先在上方选择项目,再配置多工作目录。',
87
+ 'panel.empty': '尚未配置副工作目录。',
88
+ 'panel.pickWorkspaceHint': '选择工作区后可在此添加副工作目录。',
89
+ 'panel.add': '+ 添加目录',
90
+ 'panel.adding': '处理中…',
91
+ 'panel.remove': '移除',
92
+ 'panel.refresh': '刷新',
93
+ 'panel.footnote': 'Agent 的主工作目录不变;在 Workspace Write 模式下,Agent 对上述目录拥有与主工作目录同等的读写与命令执行权限。配置变更会在下一条消息或工具调用结束时通知 Agent。',
94
+ };
95
+ /** English dictionary — checked complete against the zh key set. */
96
+ var enDict = {
97
+ 'label': 'Multi-folder',
98
+ 'label.open': 'Multi-folder ▾',
99
+ 'label.heroLauncher': 'Multi-folder (new session)',
100
+ 'title.header': 'Multi-folder (secondary working directories)',
101
+ 'title.remove': 'Remove this secondary working directory',
102
+ 'title.close': 'Close',
103
+ 'title.heroLauncher.hasWorkspace': 'Configure secondary working directories for this project (Multi-folder)',
104
+ 'title.heroLauncher.noWorkspace': 'Pick a workspace first, then configure Multi-folder',
105
+ 'title.heroChip': 'Configure secondary working directories for this project (Multi-folder)',
106
+ 'panel.title': 'Multi-folder (secondary working directories)',
107
+ 'panel.project': 'Workspace: {path}',
108
+ 'panel.noWorkspaceHint': 'No workspace selected yet. Pick a project above first, then configure secondary directories.',
109
+ 'panel.empty': 'No secondary working directories configured yet.',
110
+ 'panel.pickWorkspaceHint': 'Pick a workspace first — secondary directories can be added here afterwards.',
111
+ 'panel.add': '+ Add directory',
112
+ 'panel.adding': 'Working…',
113
+ 'panel.remove': 'Remove',
114
+ 'panel.refresh': 'Refresh',
115
+ 'panel.footnote': "The agent's primary working directory stays unchanged; under Workspace Write mode the agent has the same read/write and command-execution rights on the listed directories as on the primary workspace. Configuration changes are announced at the next message or tool-call boundary.",
116
+ };
117
+
47
118
  // ------------------------------------------------------------- plugin
48
119
  var name = 'dsh-multi-folder';
49
- var inject = ['remote', 'remote.commands', 'slots', 'workspaces'];
120
+ var inject = ['remote', 'remote.commands', 'slots', 'workspaces', 'connection', 'sessions', 'locale'];
50
121
 
51
122
  function apply(ctx) {
52
123
  var slots = ctx.slots;
53
124
  var remote = ctx.remote;
54
125
  var workspaces = ctx.workspaces;
126
+ var connection = ctx.connection;
127
+ var sessions = ctx.sessions;
128
+ var locale = ctx.locale;
129
+
130
+ /** Register this plugin's dictionaries for every shipped locale
131
+ * (bilingual balance is enforced by the locale service). The
132
+ * registration is an effect on this plugin's fiber, so unloading the
133
+ * plugin withdraws the dictionaries. */
134
+ ctx.effect(function () {
135
+ return locale.register(NS, { zh: zhDict, en: enDict });
136
+ }, 'dsh-multi-folder: client dictionaries');
137
+
138
+ /** Bound translator for slot labels: label thunks re-evaluate per read
139
+ * (`resolveSlotLabel`), so registration-time text follows the active
140
+ * locale without re-registering. Components themselves render through
141
+ * the `t` seat the renderer synthesizes from the declared
142
+ * `locale:` namespace (which also re-renders them on locale switch). */
143
+ var t = locale.bind(NS);
144
+
145
+ // -------------------------------------------------- sessionless RPC
146
+ /** Call one `multiFolder/*` endpoint over the shared /api channel.
147
+ * The Host gateway answers with the same `{ ok, value }` envelope as
148
+ * the command remote; business errors surface as thrown Errors. */
149
+ function remoteCall(endpoint, args) {
150
+ if (!connection || !connection.rpc || typeof connection.rpc.call !== 'function') {
151
+ return Promise.reject(new Error('multi-folder: the shared RPC channel (connection service) is unavailable'));
152
+ }
153
+ return connection.rpc.call('/api', endpoint, { args: args }).then(function (envelope) {
154
+ if (!envelope || envelope.ok !== true) {
155
+ var message = envelope && envelope.error !== undefined
156
+ ? String(envelope.error.message !== undefined ? envelope.error.message : envelope.error)
157
+ : 'remote call failed';
158
+ throw new Error('multi-folder: ' + String(message).replace(/^multi-folder:\s*/, ''));
159
+ }
160
+ return envelope.value;
161
+ });
162
+ }
163
+
164
+ function refreshWorkspace(workspacePath) {
165
+ if (!workspacePath) return;
166
+ patch({ busy: true, error: null });
167
+ remoteCall('multiFolder/list', { workspace: workspacePath }).then(function (value) {
168
+ workspaceCache[workspacePathKey(workspacePath)] = value || { workspace: workspacePath, dirs: [] };
169
+ var current = getSnapshot();
170
+ if (!current.sessionId && current.mode === 'workspace' && current.workspace && workspacePathKey(current.workspace) === workspacePathKey(workspacePath)) {
171
+ patch({ busy: false, workspace: value.workspace, dirs: value.dirs || [] });
172
+ } else {
173
+ patch({ busy: false });
174
+ }
175
+ }).catch(function (e) {
176
+ patch({ busy: false, error: String(e && e.message ? e.message : e) });
177
+ });
178
+ }
179
+
180
+ function mutateWorkspace(workspacePath, endpoint, args) {
181
+ if (!workspacePath) return Promise.resolve();
182
+ patch({ busy: true, error: null });
183
+ var payload = Object.assign({ workspace: workspacePath }, args);
184
+ return remoteCall(endpoint, payload).then(function (value) {
185
+ workspaceCache[workspacePathKey(workspacePath)] = value || { workspace: workspacePath, dirs: [] };
186
+ var current = getSnapshot();
187
+ if (!current.sessionId && current.mode === 'workspace' && current.workspace && workspacePathKey(current.workspace) === workspacePathKey(workspacePath)) {
188
+ patch({ busy: false, workspace: value.workspace, dirs: value.dirs || [] });
189
+ } else {
190
+ patch({ busy: false });
191
+ }
192
+ }).catch(function (e) {
193
+ patch({ busy: false, error: String(e && e.message ? e.message : e) });
194
+ });
195
+ }
196
+
197
+ /** Open the panel in WORKSPACE mode (session-creation page): a null
198
+ * workspace shows the "pick a workspace first" hint instead. */
199
+ function openForWorkspace(workspacePath) {
200
+ if (!workspacePath) {
201
+ patch({ open: true, mode: 'workspace', sessionId: null, workspace: null, dirs: [], error: null });
202
+ return;
203
+ }
204
+ var cached = workspaceCache[workspacePathKey(workspacePath)];
205
+ patch({
206
+ open: true,
207
+ mode: 'workspace',
208
+ sessionId: null,
209
+ workspace: workspacePath,
210
+ dirs: cached ? cached.dirs : [],
211
+ error: null,
212
+ });
213
+ if (!cached) refreshWorkspace(workspacePath);
214
+ }
55
215
 
56
216
  function runCommand(sessionId, line) {
57
217
  return remote.commands.execute(sessionId, line).then(function (envelope) {
@@ -107,10 +267,15 @@ window.__ModuleLoader__.load({
107
267
  });
108
268
  }
109
269
 
110
- function addDirectory(sessionId) {
270
+ function addDirectory() {
111
271
  workspaces.pickDirectory().then(function (path) {
112
272
  if (path === null || path === undefined) return;
113
- mutate(sessionId, '/multi-folder add "' + path.replace(/"/g, '\\"') + '"');
273
+ var snapshot = getSnapshot();
274
+ if (snapshot.sessionId) {
275
+ mutate(snapshot.sessionId, '/multi-folder add "' + path.replace(/"/g, '\\"') + '"');
276
+ } else if (snapshot.workspace) {
277
+ mutateWorkspace(snapshot.workspace, 'multiFolder/add', { path: path });
278
+ }
114
279
  }).catch(function (e) {
115
280
  patch({ error: String(e && e.message ? e.message : e) });
116
281
  });
@@ -123,6 +288,7 @@ window.__ModuleLoader__.load({
123
288
  var cached = sessionCache[sessionId];
124
289
  patch({
125
290
  open: true,
291
+ mode: 'session',
126
292
  sessionId: sessionId,
127
293
  dirs: cached ? cached.dirs : [],
128
294
  workspace: cached ? cached.workspace : null,
@@ -135,6 +301,7 @@ window.__ModuleLoader__.load({
135
301
  function HeaderButton(props) {
136
302
  var store = useStore();
137
303
  var sessionId = props.sessionId;
304
+ var t = props.t;
138
305
  var open = store.open && store.sessionId === sessionId;
139
306
  // Session switch: keep the panel in sync with the session this
140
307
  // header belongs to. On a changed sessionId (or first mount) with
@@ -153,7 +320,7 @@ window.__ModuleLoader__.load({
153
320
  'button',
154
321
  {
155
322
  type: 'button',
156
- title: '多工作目录(副工作目录)',
323
+ title: t('title.header'),
157
324
  onClick: function () {
158
325
  var current = getSnapshot();
159
326
  var isOpen = current.open && current.sessionId === sessionId;
@@ -176,15 +343,18 @@ window.__ModuleLoader__.load({
176
343
  cursor: 'pointer',
177
344
  },
178
345
  },
179
- open ? '多工作目录 ▾' : '多工作目录',
346
+ open ? t('label.open') : t('label'),
180
347
  );
181
348
  return btn;
182
349
  }
183
350
 
184
351
  // Overlay panel ------------------------------------------------------
185
- function Panel() {
352
+ function Panel(props) {
186
353
  var store = useStore();
187
- if (!store.open || !store.sessionId) return null;
354
+ var t = props.t;
355
+ if (!store.open || !store.mode) return null;
356
+ var sessionMode = store.mode === 'session';
357
+ var usable = sessionMode || !!store.workspace;
188
358
  var rows = (store.dirs || []).map(function (dir, index) {
189
359
  return React.createElement(
190
360
  'div',
@@ -212,11 +382,18 @@ window.__ModuleLoader__.load({
212
382
  'button',
213
383
  {
214
384
  type: 'button',
215
- title: '移除此副工作目录',
216
- onClick: function () { mutate(store.sessionId, '/multi-folder remove "' + dir.replace(/"/g, '\\"') + '"'); },
385
+ title: t('title.remove'),
386
+ disabled: !usable,
387
+ onClick: function () {
388
+ if (sessionMode) {
389
+ mutate(store.sessionId, '/multi-folder remove "' + dir.replace(/"/g, '\\"') + '"');
390
+ } else if (store.workspace) {
391
+ mutateWorkspace(store.workspace, 'multiFolder/remove', { path: dir });
392
+ }
393
+ },
217
394
  style: { padding: '2px 8px', borderRadius: 6, border: '1px solid transparent', background: 'transparent', color: 'var(--color-danger, #c62828)', cursor: 'pointer' },
218
395
  },
219
- '移除',
396
+ t('panel.remove'),
220
397
  ),
221
398
  );
222
399
  });
@@ -242,12 +419,12 @@ window.__ModuleLoader__.load({
242
419
  React.createElement(
243
420
  'div',
244
421
  { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 } },
245
- React.createElement('div', { style: { fontWeight: 600, fontSize: 14 } }, '多工作目录(副工作目录)'),
422
+ React.createElement('div', { style: { fontWeight: 600, fontSize: 14 } }, t('panel.title')),
246
423
  React.createElement(
247
424
  'button',
248
425
  {
249
426
  type: 'button',
250
- title: '关闭',
427
+ title: t('title.close'),
251
428
  onClick: function () { patch({ open: false }); },
252
429
  style: { padding: '2px 8px', borderRadius: 6, border: '1px solid transparent', background: 'transparent', cursor: 'pointer' },
253
430
  },
@@ -258,10 +435,18 @@ window.__ModuleLoader__.load({
258
435
  ? React.createElement(
259
436
  'div',
260
437
  { style: { marginBottom: 8, color: 'var(--color-text-muted, #6b6b6b)', fontSize: 12, wordBreak: 'break-all' } },
261
- '项目:' + store.workspace,
438
+ t('panel.project', { path: store.workspace }),
262
439
  )
263
- : null,
264
- rows.length > 0 ? rows : React.createElement('div', { style: { marginBottom: 8, color: 'var(--color-text-muted, #6b6b6b)' } }, '尚未配置副工作目录。'),
440
+ : (!sessionMode
441
+ ? React.createElement(
442
+ 'div',
443
+ { style: { marginBottom: 8, color: 'var(--color-text-muted, #6b6b6b)' } },
444
+ t('panel.noWorkspaceHint'),
445
+ )
446
+ : null),
447
+ usable
448
+ ? (rows.length > 0 ? rows : React.createElement('div', { style: { marginBottom: 8, color: 'var(--color-text-muted, #6b6b6b)' } }, t('panel.empty')))
449
+ : React.createElement('div', { style: { marginBottom: 8, color: 'var(--color-text-muted, #6b6b6b)' } }, t('panel.pickWorkspaceHint')),
265
450
  store.error
266
451
  ? React.createElement('div', { style: { marginBottom: 8, color: 'var(--color-danger, #c62828)', whiteSpace: 'pre-wrap' } }, String(store.error))
267
452
  : null,
@@ -272,8 +457,8 @@ window.__ModuleLoader__.load({
272
457
  'button',
273
458
  {
274
459
  type: 'button',
275
- disabled: !!store.busy,
276
- onClick: function () { addDirectory(store.sessionId); },
460
+ disabled: !!store.busy || !usable,
461
+ onClick: function () { addDirectory(); },
277
462
  style: {
278
463
  flex: 1,
279
464
  padding: '6px 10px',
@@ -281,29 +466,35 @@ window.__ModuleLoader__.load({
281
466
  border: '1px solid var(--color-border, rgba(127,127,127,0.35))',
282
467
  background: 'var(--color-bg-accent, rgba(80,120,255,0.14))',
283
468
  color: 'var(--color-text, inherit)',
284
- cursor: store.busy ? 'default' : 'pointer',
285
- opacity: store.busy ? 0.6 : 1,
469
+ cursor: store.busy || !usable ? 'default' : 'pointer',
470
+ opacity: store.busy || !usable ? 0.6 : 1,
286
471
  },
287
472
  },
288
- store.busy ? '处理中…' : '+ 添加目录',
473
+ store.busy ? t('panel.adding') : t('panel.add'),
289
474
  ),
290
475
  React.createElement(
291
476
  'button',
292
477
  {
293
478
  type: 'button',
294
- disabled: !!store.busy,
295
- onClick: function () { refresh(store.sessionId); },
479
+ disabled: !!store.busy || !usable,
480
+ onClick: function () {
481
+ if (sessionMode) {
482
+ refresh(store.sessionId);
483
+ } else if (store.workspace) {
484
+ refreshWorkspace(store.workspace);
485
+ }
486
+ },
296
487
  style: {
297
488
  padding: '6px 10px',
298
489
  borderRadius: 8,
299
490
  border: '1px solid var(--color-border, rgba(127,127,127,0.35))',
300
491
  background: 'transparent',
301
492
  color: 'var(--color-text, inherit)',
302
- cursor: store.busy ? 'default' : 'pointer',
303
- opacity: store.busy ? 0.6 : 1,
493
+ cursor: store.busy || !usable ? 'default' : 'pointer',
494
+ opacity: store.busy || !usable ? 0.6 : 1,
304
495
  },
305
496
  },
306
- '刷新',
497
+ t('panel.refresh'),
307
498
  ),
308
499
  ),
309
500
  React.createElement(
@@ -316,22 +507,162 @@ window.__ModuleLoader__.load({
316
507
  color: 'var(--color-text-muted, #6b6b6b)',
317
508
  },
318
509
  },
319
- 'Agent 的主工作目录不变;在 Workspace Write 模式下,Agent 对上述目录拥有与主工作目录同等的读写与命令执行权限。配置变更会在下一条消息或工具调用结束时通知 Agent。',
510
+ t('panel.footnote'),
320
511
  ),
321
512
  );
322
513
  }
323
514
 
515
+ // Hero (session-creation page) support --------------------------------
516
+ /** Workspace path of the current session (blank-session hero), or null
517
+ * while no session/workspace is selected at all. */
518
+ function heroWorkspacePath() {
519
+ var sessionList = sessions && sessions.list ? sessions.list.getSnapshot() : null;
520
+ var id = sessionList && sessionList.current;
521
+ if (!id) return null;
522
+ var workspaceList = workspaces && workspaces.list ? workspaces.list.getSnapshot() : null;
523
+ var items = workspaceList && workspaceList.items ? workspaceList.items : [];
524
+ for (var i = 0; i < items.length; i++) {
525
+ var workspace = items[i];
526
+ if (workspace && workspace.path && Array.isArray(workspace.sessionIds) && workspace.sessionIds.indexOf(id) >= 0) {
527
+ return workspace.path;
528
+ }
529
+ }
530
+ var row = sessionList && sessionList.byId ? sessionList.byId[id] : undefined;
531
+ return row && row.cwd ? row.cwd : null;
532
+ }
533
+
534
+ /** Re-read the conversation root's `data-phase` attribute (authoritative
535
+ * hero signal) and the derivable workspace, then patch the store. */
536
+ function syncHero() {
537
+ var phaseEl = null;
538
+ try {
539
+ if (typeof document !== 'undefined') phaseEl = document.querySelector('[data-phase]');
540
+ } catch (_) { /* no DOM (tests) */ }
541
+ var hero = !!phaseEl && phaseEl.getAttribute && phaseEl.getAttribute('data-phase') === 'hero';
542
+ var workspacePath = hero ? heroWorkspacePath() : null;
543
+ var current = getSnapshot();
544
+ if (current.hero !== hero || current.heroWorkspace !== workspacePath) {
545
+ patch({ hero: hero, heroWorkspace: workspacePath });
546
+ }
547
+ }
548
+
549
+ /** Fixed-position hero launcher (B2): visible only while the
550
+ * conversation root reports the hero phase. */
551
+ function HeroLauncher(props) {
552
+ var store = useStore();
553
+ var t = props.t;
554
+ React.useEffect(
555
+ function () {
556
+ var disposers = [];
557
+ if (sessions && sessions.list && typeof sessions.list.subscribe === 'function') {
558
+ disposers.push(sessions.list.subscribe(syncHero));
559
+ }
560
+ if (workspaces && workspaces.list && typeof workspaces.list.subscribe === 'function') {
561
+ disposers.push(workspaces.list.subscribe(syncHero));
562
+ }
563
+ syncHero();
564
+ var observer = null;
565
+ if (typeof document !== 'undefined' && document.body && typeof MutationObserver !== 'undefined') {
566
+ observer = new MutationObserver(syncHero);
567
+ observer.observe(document.body, {
568
+ subtree: true,
569
+ childList: true,
570
+ attributes: true,
571
+ attributeFilter: ['data-phase'],
572
+ });
573
+ }
574
+ return function () {
575
+ disposers.forEach(function (dispose) { dispose(); });
576
+ if (observer) observer.disconnect();
577
+ };
578
+ },
579
+ [],
580
+ );
581
+ if (!store.hero) return null;
582
+ return React.createElement(
583
+ 'button',
584
+ {
585
+ type: 'button',
586
+ title: store.heroWorkspace ? t('title.heroLauncher.hasWorkspace') : t('title.heroLauncher.noWorkspace'),
587
+ onClick: function () { openForWorkspace(store.heroWorkspace); },
588
+ style: {
589
+ position: 'fixed',
590
+ bottom: 24,
591
+ right: 24,
592
+ zIndex: 300,
593
+ display: 'inline-flex',
594
+ alignItems: 'center',
595
+ gap: 6,
596
+ padding: '6px 12px',
597
+ borderRadius: 999,
598
+ border: '1px solid var(--color-border, rgba(127,127,127,0.35))',
599
+ background: 'var(--color-bg-elevated, #ffffff)',
600
+ color: 'var(--color-text, inherit)',
601
+ fontSize: 13,
602
+ cursor: 'pointer',
603
+ boxShadow: '0 4px 16px rgba(0,0,0,0.14)',
604
+ opacity: store.heroWorkspace ? 1 : 0.7,
605
+ },
606
+ },
607
+ t('label'),
608
+ );
609
+ }
610
+
611
+ /** Inline chip for the upstream `conversation.hero.workspaceExtras`
612
+ * slot (B1): rendered beside the workspace picker once the DSH core
613
+ * declares the slot; a no-op registration until then. */
614
+ function HeroChip(props) {
615
+ var store = useStore();
616
+ var t = props.t;
617
+ var workspacePath = props && props.workspacePath ? props.workspacePath : store.heroWorkspace;
618
+ return React.createElement(
619
+ 'button',
620
+ {
621
+ type: 'button',
622
+ title: t('title.heroChip'),
623
+ onClick: function () { openForWorkspace(workspacePath); },
624
+ style: {
625
+ display: 'inline-flex',
626
+ alignItems: 'center',
627
+ gap: 6,
628
+ padding: '4px 10px',
629
+ borderRadius: 999,
630
+ border: '1px solid var(--color-border, rgba(127,127,127,0.35))',
631
+ background: 'transparent',
632
+ color: 'var(--color-text, inherit)',
633
+ fontSize: 13,
634
+ cursor: 'pointer',
635
+ },
636
+ },
637
+ t('label'),
638
+ );
639
+ }
640
+
324
641
  // Registrations ------------------------------------------------------
325
642
  slots.inject('conversation.session.header.actions', function () {
326
643
  return slots.register(
327
- { name: 'conversation.session.header.actions', id: 'multi-folder', order: 30, label: '多工作目录' },
644
+ { name: 'conversation.session.header.actions', id: 'multi-folder', order: 30, label: function () { return t('label'); }, locale: NS },
328
645
  function (props) { return React.createElement(HeaderButton, props); },
329
646
  );
330
647
  });
331
648
  slots.inject('shell.overlay', function () {
332
649
  return slots.register(
333
- { name: 'shell.overlay', id: 'multi-folder', order: 100, label: '多工作目录' },
334
- function () { return React.createElement(Panel); },
650
+ { name: 'shell.overlay', id: 'multi-folder', order: 100, label: function () { return t('label'); }, locale: NS },
651
+ function (props) { return React.createElement(Panel, props); },
652
+ );
653
+ });
654
+ slots.inject('shell.overlay', function () {
655
+ return slots.register(
656
+ { name: 'shell.overlay', id: 'multi-folder-hero', order: 200, label: function () { return t('label.heroLauncher'); }, locale: NS },
657
+ function (props) { return React.createElement(HeroLauncher, props); },
658
+ );
659
+ });
660
+ // Upstream slot (B1): the callback fires only once a DSH build declares
661
+ // `conversation.hero.workspaceExtras`; until then this contributes nothing.
662
+ slots.inject('conversation.hero.workspaceExtras', function () {
663
+ return slots.register(
664
+ { name: 'conversation.hero.workspaceExtras', id: 'multi-folder', order: 30, label: function () { return t('label'); }, locale: NS },
665
+ function (props) { return React.createElement(HeroChip, props); },
335
666
  );
336
667
  });
337
668
  }
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.2",
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,8 @@
47
47
  "inject": [
48
48
  "@deepseek-ai/dsh-api-gateway",
49
49
  "@deepseek-ai/dsh-api-remotes",
50
+ "@deepseek-ai/dsh-client-connection",
51
+ "@deepseek-ai/dsh-client-locale",
50
52
  "@deepseek-ai/dsh-client-runtime"
51
53
  ]
52
54
  }