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 +11 -15
- package/README.zh.md +11 -15
- package/SECURITY.md +5 -2
- package/docs/design.md +121 -17
- package/docs/upstream-hero-slot.md +127 -0
- package/lib/client.js +364 -33
- package/lib/index.js +163 -39
- package/package.json +4 -2
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`
|
|
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
|
|
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
|
|
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
|
-
- **
|
|
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
|
-
-
|
|
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
|
|
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
|
|
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
|
-
-
|
|
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
|
-
-
|
|
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
|
|
11
|
-
|
|
10
|
+
the user, through the session-header panel, the session-creation page panel, or
|
|
11
|
+
the `/multi-folder` slash command. The two panels are backed by a sessionless
|
|
12
|
+
`multiFolder/*` remote API on the trusted browser→host RPC channel — endpoints
|
|
13
|
+
that exist only for the web UI and are never exposed as agent tools. The agent
|
|
14
|
+
has no tool, command, or file path through which it can change the configuration.
|
|
12
15
|
|
|
13
16
|
2. **Host-owned configuration store.** Per-workspace configuration lives in
|
|
14
17
|
`<DSH_HOME>/storages/multi-folder/<workspace-key>.json` — outside every agent
|
package/docs/design.md
CHANGED
|
@@ -14,8 +14,8 @@ agent informed. No new tools are added.
|
|
|
14
14
|
|
|
15
15
|
| Half | File | Role |
|
|
16
16
|
| ---- | ---- | ---- |
|
|
17
|
-
| Host | `lib/index.js` | Config store, tool-pipeline interception, prompt section, notifications, `/multi-folder` command |
|
|
18
|
-
| Client | `lib/client.js` | Session-header button + overlay panel;
|
|
17
|
+
| Host | `lib/index.js` | Config store, tool-pipeline interception, prompt section, notifications, `/multi-folder` command, sessionless `multiFolder/*` remote API |
|
|
18
|
+
| Client | `lib/client.js` | Session-header button + overlay panel; session-creation page entry (hero launcher + upstream hero chip), both driving the host through the Remote BFF / shared RPC channel |
|
|
19
19
|
|
|
20
20
|
The package declares both faces: `dsh.bundle.patch` (the host row inserted by
|
|
21
21
|
`cordis.patch.yml`) and `dsh.client` (the web bundle at `exports["./client"]`).
|
|
@@ -67,10 +67,60 @@ at apply time can yield `undefined` when the provider row activates later. There
|
|
|
67
67
|
|
|
68
68
|
- Canonical location: `<DSH_HOME>/storages/multi-folder/<workspace-key>.json`
|
|
69
69
|
(`DSH_HOME` falls back to `~/.dsh`), i.e. **outside every agent sandbox root**.
|
|
70
|
-
- Writes happen only from
|
|
70
|
+
- Writes happen only from user-initiated flows (the `/multi-folder` command
|
|
71
|
+
handler and the `multiFolder/*` remote endpoints) with an explicit
|
|
71
72
|
`workspace-write` policy rooted at the config directory.
|
|
72
73
|
- A per-process cache keyed by normalized workspace path hydrates lazily (on
|
|
73
74
|
`agent/created`, `agent/pre-step`, and `tools/execute`).
|
|
75
|
+
- One shared **core** (`coreList` / `coreAdd` / `coreRemove` / `coreSet`)
|
|
76
|
+
implements validation, canonicalization, sanitization, cache write-through,
|
|
77
|
+
and persistence. The command channel and the remote channel both call it, so
|
|
78
|
+
the security surface stays identical on both. Core errors carry bare
|
|
79
|
+
messages; each channel adds its own `multi-folder: ` prefix.
|
|
80
|
+
|
|
81
|
+
## Host: sessionless remote API
|
|
82
|
+
|
|
83
|
+
The session-creation page has no session (and no `sessionId`), so the
|
|
84
|
+
agent-scoped `commands/execute` remote cannot serve it. Instead the plugin
|
|
85
|
+
opens its own **sessionless** endpoints on the shared `/api` RPC channel:
|
|
86
|
+
|
|
87
|
+
- A **plain-object service** is registered with `ctx.provide('multiFolder', api)`.
|
|
88
|
+
The object carries the gateway-visible binding
|
|
89
|
+
`typertRemote = { service, serviceKey: 'multiFolder', namespace: 'multiFolder' }`
|
|
90
|
+
(frozen), which is exactly what the gateway's `validateBinding` expects.
|
|
91
|
+
- A **hand-written Typert contribution** is registered through
|
|
92
|
+
`ctx.inject(['typert'], (t) => t.typert.register(REMOTE_CONTRIBUTION))` —
|
|
93
|
+
the sanctioned manual path documented by `dsh-typert-loader` ("Manual
|
|
94
|
+
`ctx.typert.register()` remains available for contributions that do not use
|
|
95
|
+
a `./typert` artifact"). All four descriptors use `src-json` codecs (no zod
|
|
96
|
+
schemas needed) with `invocation: { kind: 'direct' }`:
|
|
97
|
+
|
|
98
|
+
| Endpoint | Parameters (wire) | Result |
|
|
99
|
+
| -------- | ----------------- | ------ |
|
|
100
|
+
| `multiFolder/list` | `workspace` | `{ workspace, dirs, changed: false }` |
|
|
101
|
+
| `multiFolder/add` | `workspace`, `path` | `{ workspace, dirs, changed }` |
|
|
102
|
+
| `multiFolder/remove` | `workspace`, `path` | `{ workspace, dirs, changed }` |
|
|
103
|
+
| `multiFolder/set` | `workspace`, `dirs` | `{ workspace, dirs, changed }` |
|
|
104
|
+
|
|
105
|
+
The workspace argument is a **path**, not a session id; the client derives
|
|
106
|
+
it from the workspaces store (`WorkspaceView.path`). Business errors throw
|
|
107
|
+
and arrive at the browser as `{ ok: false, error: { message } }`.
|
|
108
|
+
- Gateway mechanics verified against `dsh-api-gateway` + `dsh-typert-registry`:
|
|
109
|
+
`resolveDescriptor` finds the endpoint in `typert.local` (claimable on
|
|
110
|
+
`/api`), direct invocation resolves the receiver through
|
|
111
|
+
`ctx.get('multiFolder')` (global shared store), `validateBinding` reads the
|
|
112
|
+
frozen `typertRemote` property, and src-json parameters tolerate omitted
|
|
113
|
+
wire fields. Both registrations are owned by the plugin fiber, so unloading
|
|
114
|
+
the plugin withdraws them together.
|
|
115
|
+
- No notice is armed on the remote channel: pre-session changes have no agent
|
|
116
|
+
to notify. The session created afterwards hydrates the cache on
|
|
117
|
+
`agent/created` and the prompt section renders the directories in the very
|
|
118
|
+
first assembly.
|
|
119
|
+
- Note: `src-json` descriptors are boundary-validated only for JSON safety
|
|
120
|
+
(the gateway's `assertJsonValue`), not schema-validated. The service itself
|
|
121
|
+
must therefore treat every argument as hostile — the shared core already
|
|
122
|
+
does (type checks, absolute-path requirement, canonicalization, sanitization,
|
|
123
|
+
primary-workspace exclusion).
|
|
74
124
|
|
|
75
125
|
## Host: prompt injection and notifications
|
|
76
126
|
|
|
@@ -95,21 +145,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)
|
|
103
|
-
`shell.overlay`
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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,
|
|
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 "
|
|
5
|
-
* (`conversation.session.header.actions`, scope:
|
|
6
|
-
* `shell.overlay` listing the project's
|
|
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(
|
|
270
|
+
function addDirectory() {
|
|
111
271
|
workspaces.pickDirectory().then(function (path) {
|
|
112
272
|
if (path === null || path === undefined) return;
|
|
113
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
'
|
|
438
|
+
t('panel.project', { path: store.workspace }),
|
|
262
439
|
)
|
|
263
|
-
:
|
|
264
|
-
|
|
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(
|
|
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 () {
|
|
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
|
-
'
|
|
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
|
|
59
|
+
'configured by the user through the UI (session header or session-creation page) or the /multi-folder command; direct edits are rejected.'
|
|
52
60
|
|
|
53
61
|
export function apply(ctx) {
|
|
54
62
|
const { fs, sandboxPolicy, systemPrompt } = ctx
|
|
@@ -150,6 +158,139 @@ export function apply(ctx) {
|
|
|
150
158
|
return 'Secondary working directories for ' + ws + ':\n' + dirs.map((d) => '- ' + d).join('\n')
|
|
151
159
|
}
|
|
152
160
|
|
|
161
|
+
// ----------------------------------------------------- shared config core
|
|
162
|
+
// One validated, canonicalizing write-through core shared by the
|
|
163
|
+
// `/multi-folder` command and the sessionless `multiFolder/*` remote
|
|
164
|
+
// endpoints. Errors thrown here carry bare messages; each channel adds
|
|
165
|
+
// its own `multi-folder: ` prefix.
|
|
166
|
+
|
|
167
|
+
const requireWorkspace = (ws) => {
|
|
168
|
+
if (typeof ws !== 'string' || ws.length === 0) throw new Error('workspace is required')
|
|
169
|
+
return ws
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const coreList = async (ws) => {
|
|
173
|
+
ws = requireWorkspace(ws)
|
|
174
|
+
const entry = await loadDirs(ws)
|
|
175
|
+
return { workspace: ws, dirs: [...entry.dirs], changed: false }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const coreAdd = async (ws, path) => {
|
|
179
|
+
ws = requireWorkspace(ws)
|
|
180
|
+
if (typeof path !== 'string' || path.length === 0) throw new Error('add requires a path')
|
|
181
|
+
if (!isAbsolute(path)) throw new Error('add requires an absolute path')
|
|
182
|
+
const canonical = await canonicalizeAbs(path)
|
|
183
|
+
if (canonical === null) throw new Error('cannot resolve path "' + path + '"')
|
|
184
|
+
const entry = await loadDirs(ws)
|
|
185
|
+
const next = sanitizeDirs([...entry.dirs, canonical], ws)
|
|
186
|
+
const changed = next.length !== entry.dirs.length
|
|
187
|
+
entry.dirs = next
|
|
188
|
+
if (changed) await saveDirs(ws, next)
|
|
189
|
+
return { workspace: ws, dirs: [...next], changed }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const coreRemove = async (ws, path) => {
|
|
193
|
+
ws = requireWorkspace(ws)
|
|
194
|
+
if (typeof path !== 'string' || path.length === 0) throw new Error('remove requires a path')
|
|
195
|
+
const canonical = await canonicalizeAbs(path)
|
|
196
|
+
const key = wsKey(canonical === null ? path : canonical)
|
|
197
|
+
const entry = await loadDirs(ws)
|
|
198
|
+
const next = entry.dirs.filter((d) => wsKey(d) !== key)
|
|
199
|
+
const changed = next.length !== entry.dirs.length
|
|
200
|
+
entry.dirs = next
|
|
201
|
+
if (changed) await saveDirs(ws, next)
|
|
202
|
+
return { workspace: ws, dirs: [...next], changed }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const coreSet = async (ws, paths) => {
|
|
206
|
+
ws = requireWorkspace(ws)
|
|
207
|
+
if (!Array.isArray(paths)) throw new Error('set requires an array of absolute paths')
|
|
208
|
+
const canon = []
|
|
209
|
+
for (const p of paths) {
|
|
210
|
+
if (!isAbsolute(p)) throw new Error('set requires absolute paths')
|
|
211
|
+
const c = await canonicalizeAbs(p)
|
|
212
|
+
if (c === null) throw new Error('cannot resolve path "' + p + '"')
|
|
213
|
+
canon.push(c)
|
|
214
|
+
}
|
|
215
|
+
const entry = await loadDirs(ws)
|
|
216
|
+
const next = sanitizeDirs(canon, ws)
|
|
217
|
+
const changed = JSON.stringify(next) !== JSON.stringify(entry.dirs)
|
|
218
|
+
entry.dirs = next
|
|
219
|
+
if (changed) await saveDirs(ws, next)
|
|
220
|
+
return { workspace: ws, dirs: [...next], changed }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ----------------------------------------------- sessionless remote API
|
|
224
|
+
// `multiFolder/*` endpoints over the Typert gateway. Hand-written
|
|
225
|
+
// `src-json` descriptors registered through ctx.typert.register (the
|
|
226
|
+
// sanctioned manual path documented by dsh-typert-loader) plus a
|
|
227
|
+
// plain-object service carrying the gateway's typertRemote binding.
|
|
228
|
+
// No session is involved: parameters are the workspace path and paths.
|
|
229
|
+
|
|
230
|
+
const remoteErrorMessage = (e) =>
|
|
231
|
+
'multi-folder: ' + String(e && e.message ? e.message : e).replace(/^multi-folder:\s*/, '')
|
|
232
|
+
|
|
233
|
+
const multiFolderApi = {
|
|
234
|
+
async list(workspace) {
|
|
235
|
+
try {
|
|
236
|
+
return await coreList(workspace)
|
|
237
|
+
} catch (e) {
|
|
238
|
+
throw new Error(remoteErrorMessage(e))
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
async add(workspace, path) {
|
|
242
|
+
try {
|
|
243
|
+
return await coreAdd(workspace, path)
|
|
244
|
+
} catch (e) {
|
|
245
|
+
throw new Error(remoteErrorMessage(e))
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
async remove(workspace, path) {
|
|
249
|
+
try {
|
|
250
|
+
return await coreRemove(workspace, path)
|
|
251
|
+
} catch (e) {
|
|
252
|
+
throw new Error(remoteErrorMessage(e))
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
async set(workspace, dirs) {
|
|
256
|
+
try {
|
|
257
|
+
return await coreSet(workspace, dirs)
|
|
258
|
+
} catch (e) {
|
|
259
|
+
throw new Error(remoteErrorMessage(e))
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
}
|
|
263
|
+
Object.defineProperty(multiFolderApi, 'typertRemote', {
|
|
264
|
+
value: Object.freeze({
|
|
265
|
+
service: multiFolderApi,
|
|
266
|
+
serviceKey: 'multiFolder',
|
|
267
|
+
namespace: 'multiFolder',
|
|
268
|
+
}),
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
const remoteParam = (name) => ({ name, wire: name, source: 'json', codec: { mode: 'src-json' } })
|
|
272
|
+
const remoteInvocation = (method, params) => ({
|
|
273
|
+
id: 'dsh-multi-folder#multiFolder/' + method,
|
|
274
|
+
service: 'multiFolder',
|
|
275
|
+
namespace: 'multiFolder',
|
|
276
|
+
method,
|
|
277
|
+
invocation: { kind: 'direct' },
|
|
278
|
+
parameters: params.map(remoteParam),
|
|
279
|
+
result: { mode: 'src-json' },
|
|
280
|
+
})
|
|
281
|
+
const REMOTE_CONTRIBUTION = {
|
|
282
|
+
package: 'dsh-multi-folder',
|
|
283
|
+
face: 'host',
|
|
284
|
+
schemas: [],
|
|
285
|
+
model: { services: [], events: [], objects: [] },
|
|
286
|
+
invocations: [
|
|
287
|
+
remoteInvocation('list', ['workspace']),
|
|
288
|
+
remoteInvocation('add', ['workspace', 'path']),
|
|
289
|
+
remoteInvocation('remove', ['workspace', 'path']),
|
|
290
|
+
remoteInvocation('set', ['workspace', 'dirs']),
|
|
291
|
+
],
|
|
292
|
+
}
|
|
293
|
+
|
|
153
294
|
// -------------------------------------------------------- notice channel
|
|
154
295
|
const noticeMessage = (text) => ({
|
|
155
296
|
id: 'mf-note-' + (++noteSeq),
|
|
@@ -435,62 +576,45 @@ export function apply(ctx) {
|
|
|
435
576
|
if (typeof ws !== 'string' || ws.length === 0) {
|
|
436
577
|
return { kind: 'error', text: 'multi-folder: session workspace is unknown' }
|
|
437
578
|
}
|
|
438
|
-
const entry = await loadDirs(ws)
|
|
439
579
|
const argv = parseArgs(invocation.rawInput)
|
|
440
580
|
const sub = argv.length === 0 ? 'list' : argv[0].toLowerCase()
|
|
441
|
-
let
|
|
442
|
-
let changed = false
|
|
581
|
+
let outcome
|
|
443
582
|
if (sub === 'list') {
|
|
444
|
-
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
const path = argv.slice(1).join(' ')
|
|
448
|
-
if (path.length === 0) return { kind: 'error', text: 'multi-folder: add requires a path' }
|
|
449
|
-
if (!isAbsolute(path)) return { kind: 'error', text: 'multi-folder: add requires an absolute path' }
|
|
450
|
-
const canonical = await canonicalizeAbs(path)
|
|
451
|
-
if (canonical === null) {
|
|
452
|
-
return { kind: 'error', text: 'multi-folder: cannot resolve path "' + path + '"' }
|
|
453
|
-
}
|
|
454
|
-
next = sanitizeDirs([...entry.dirs, canonical], ws)
|
|
455
|
-
changed = next.length !== entry.dirs.length
|
|
583
|
+
outcome = await coreList(ws)
|
|
584
|
+
} else if (sub === 'add') {
|
|
585
|
+
outcome = await coreAdd(ws, argv.slice(1).join(' '))
|
|
456
586
|
} else if (sub === 'remove') {
|
|
457
|
-
|
|
458
|
-
if (path.length === 0) return { kind: 'error', text: 'multi-folder: remove requires a path' }
|
|
459
|
-
const canonical = await canonicalizeAbs(path)
|
|
460
|
-
const key = wsKey(canonical === null ? path : canonical)
|
|
461
|
-
next = entry.dirs.filter((d) => wsKey(d) !== key)
|
|
462
|
-
changed = next.length !== entry.dirs.length
|
|
587
|
+
outcome = await coreRemove(ws, argv.slice(1).join(' '))
|
|
463
588
|
} else if (sub === 'set') {
|
|
464
|
-
|
|
465
|
-
for (const p of argv.slice(1)) {
|
|
466
|
-
if (!isAbsolute(p)) return { kind: 'error', text: 'multi-folder: set requires absolute paths' }
|
|
467
|
-
const c = await canonicalizeAbs(p)
|
|
468
|
-
if (c === null) {
|
|
469
|
-
return { kind: 'error', text: 'multi-folder: cannot resolve path "' + p + '"' }
|
|
470
|
-
}
|
|
471
|
-
canon.push(c)
|
|
472
|
-
}
|
|
473
|
-
next = sanitizeDirs(canon, ws)
|
|
474
|
-
changed = JSON.stringify(next) !== JSON.stringify(entry.dirs)
|
|
589
|
+
outcome = await coreSet(ws, argv.slice(1))
|
|
475
590
|
} else {
|
|
476
591
|
return {
|
|
477
592
|
kind: 'error',
|
|
478
593
|
text: 'multi-folder: unknown subcommand "' + sub + '" (use list / add / remove / set)',
|
|
479
594
|
}
|
|
480
595
|
}
|
|
481
|
-
|
|
482
|
-
if (changed) {
|
|
483
|
-
await saveDirs(ws, next)
|
|
596
|
+
if (outcome.changed) {
|
|
484
597
|
armNotice(
|
|
485
598
|
invocation.agent,
|
|
486
|
-
'Secondary working directories changed (dsh-multi-folder):\n' + dirsText(ws,
|
|
599
|
+
'Secondary working directories changed (dsh-multi-folder):\n' + dirsText(ws, outcome.dirs),
|
|
487
600
|
)
|
|
488
601
|
}
|
|
489
|
-
return { kind: 'success', text: resultText(
|
|
602
|
+
return { kind: 'success', text: resultText(outcome.workspace, outcome.dirs, outcome.changed) }
|
|
490
603
|
} catch (e) {
|
|
491
|
-
return {
|
|
604
|
+
return {
|
|
605
|
+
kind: 'error',
|
|
606
|
+
text: 'multi-folder: ' + String(e && e.message ? e.message : e).replace(/^multi-folder:\s*/, ''),
|
|
607
|
+
}
|
|
492
608
|
}
|
|
493
609
|
},
|
|
494
610
|
})
|
|
495
611
|
})
|
|
612
|
+
|
|
613
|
+
// ------------------------------------------ sessionless remote API mounts
|
|
614
|
+
// The plain-object service must be reachable through ctx.get('multiFolder')
|
|
615
|
+
// with a visible typertRemote binding, and the typert registry entry makes
|
|
616
|
+
// the endpoints claimable on the shared /api channel. Both are owned by
|
|
617
|
+
// this plugin fiber, so unloading the plugin withdraws them together.
|
|
618
|
+
ctx.provide('multiFolder', multiFolderApi)
|
|
619
|
+
ctx.inject(['typert'], (t) => t.typert.register(REMOTE_CONTRIBUTION))
|
|
496
620
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-multi-folder",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "DeepSeek Harness plugin: secondary working directories for a project. The agent keeps the primary workspace as cwd, gains equal write/exec permissions on configured secondary directories under workspace-write mode, and is notified of configuration changes at the next message boundary.",
|
|
3
|
+
"version": "0.1.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
|
}
|