dsh-multi-folder 0.1.3 → 0.1.5
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 +13 -2
- package/README.zh.md +13 -2
- package/docs/design.md +91 -23
- package/docs/upstream-hero-slot.md +25 -12
- package/lib/client.js +473 -109
- package/lib/index.js +113 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -36,7 +36,7 @@ Then **restart the DSH backend** (host composition loads at process start) and *
|
|
|
36
36
|
|
|
37
37
|
## Usage
|
|
38
38
|
|
|
39
|
-
A Multi-folder button appears in the session header, and a second entry appears on the **session-creation page
|
|
39
|
+
A Multi-folder button appears in the session header, and a second entry appears on the **session-creation page**: a chip row directly above the composer card (the same band the git-branch chip uses), aligned with the workspace/preset chips of the new-session screen. Clicking it opens the panel as a popover anchored to the chip. Exactly one session-creation entry is ever shown — the plugin registers three candidate seats and elects the best available one (upstream `conversation.hero.workspaceExtras` chip > `conversation.input.dock` row > fixed bottom-right launcher for shells declaring neither). The panel lets you:
|
|
40
40
|
|
|
41
41
|
| Action | Behavior |
|
|
42
42
|
| ------ | -------- |
|
|
@@ -56,6 +56,17 @@ Equivalent slash command for the user:
|
|
|
56
56
|
|
|
57
57
|
The agent needs nothing extra: `read` / `glob` / `grep` work everywhere, and `write` / `edit` / `pwsh` / `bash` are intercepted and re-rooted automatically when the target path (or `workdir`) falls inside a configured secondary directory.
|
|
58
58
|
|
|
59
|
+
## Permission model
|
|
60
|
+
|
|
61
|
+
Each confined command runs under **exactly ONE writable root** — the workspace root the call is re-rooted to (the Windows ACL runner grants a single workspace write SID per process tree). Consequences:
|
|
62
|
+
|
|
63
|
+
- A command whose cwd stays the **primary workspace cannot create files inside a secondary directory**. `git -C <secondary> commit`, `cd <secondary>` inside a script, `git clone <url> <secondary>`, or absolute-path writes all fail with an OS-level `Permission denied` (e.g. `fatal: Unable to create '.../.git/index.lock': Permission denied`).
|
|
64
|
+
- Symmetrically, a command re-rooted to a secondary directory cannot write to the **primary workspace** (or another secondary directory) in the same invocation.
|
|
65
|
+
- **Rule for file-creating commands: set `workdir` to the directory the command writes into.** For git, run the command from inside the repository (pass `workdir` pointing at it) instead of using `git -C` from the primary workspace.
|
|
66
|
+
- Reads are unrestricted and need no `workdir`.
|
|
67
|
+
|
|
68
|
+
When a shell run ends in such a denial and references a configured secondary directory, the plugin attaches a short diagnostic hint to the tool result explaining the workdir fix.
|
|
69
|
+
|
|
59
70
|
## How it works
|
|
60
71
|
|
|
61
72
|
- **Interception** — a listener on the `tools/execute` around-dispatch waterfall short-circuits `write` / `edit` / `pwsh` / `bash` calls whose resolved path (or `workdir`) lands inside a configured secondary directory, and executes them with the session's standing sandbox policy **re-rooted to that directory** (`{ ...standingPolicy, workspaceRoot: secondaryDir }`). The mode itself is untouched, which is what gives every sandbox mode its identical primary-workspace semantics for free. Paths are canonicalized through `fs.resolve` + `processPath` before matching, so `..`, symlinks, and case differences behave correctly.
|
|
@@ -71,7 +82,7 @@ The agent needs nothing extra: `read` / `glob` / `grep` work everywhere, and `wr
|
|
|
71
82
|
| ---- | ------- |
|
|
72
83
|
| `cordis.patch.yml` | Profile patch layer inserting the `dsh-multi-folder` row |
|
|
73
84
|
| `lib/index.js` | Host plugin: config store, tool-pipeline interception, prompt injection, dual-channel notifications, `/multi-folder` command, sessionless `multiFolder/*` remote API |
|
|
74
|
-
| `lib/client.js` | Client plugin (factory bundle): session-header button + overlay panel + session-creation page entry (
|
|
85
|
+
| `lib/client.js` | Client plugin (factory bundle): session-header button + overlay panel + session-creation page entry (input-dock chip / upstream hero chip / fixed fallback launcher) |
|
|
75
86
|
| `test/` | Runtime-free behavior tests (see Development) |
|
|
76
87
|
| `docs/` | Design and analysis documents |
|
|
77
88
|
|
package/README.zh.md
CHANGED
|
@@ -36,7 +36,7 @@ dsh plugin --profile web add dsh-multi-folder
|
|
|
36
36
|
|
|
37
37
|
## 使用
|
|
38
38
|
|
|
39
|
-
会话头部出现「多工作目录」按钮(英文界面显示 "Multi-folder"
|
|
39
|
+
会话头部出现「多工作目录」按钮(英文界面显示 "Multi-folder");**会话创建页**的入口位于**输入框上方**——与 git 分支胶囊同一条 dock 带,左边缘对齐新会话界面的工作区/预设胶囊,点击后面板以锚定在该胶囊上的浮层展开。会话创建页始终只显示**一个**入口:插件注册三个候选座位并选用当前可用的最佳者(上游 `conversation.hero.workspaceExtras` chip > `conversation.input.dock` 行 > 两者皆未声明时的右下角浮动按钮)。打开面板即可:
|
|
40
40
|
|
|
41
41
|
| 操作 | 行为 |
|
|
42
42
|
| ---- | ---- |
|
|
@@ -56,6 +56,17 @@ dsh plugin --profile web add dsh-multi-folder
|
|
|
56
56
|
|
|
57
57
|
Agent 无需任何额外操作:`read` / `glob` / `grep` 随处可用;`write` / `edit` / `pwsh` / `bash` 在路径(或 `workdir`)落入副目录时自动拦截并以该目录为沙箱根执行。
|
|
58
58
|
|
|
59
|
+
## 权限模型
|
|
60
|
+
|
|
61
|
+
每条受沙箱约束的命令只拥有**唯一一个可写根**——即本次调用被换根到的那个目录(Windows ACL runner 为每个进程树只授予一个工作区写 SID)。由此:
|
|
62
|
+
|
|
63
|
+
- cwd 停留在**主工作区**的命令**不能在副目录创建文件**。`git -C <副目录> commit`、脚本内 `cd <副目录>`、`git clone <url> <副目录>`、按绝对路径写文件等都会以操作系统级 `Permission denied` 失败(例如 `fatal: Unable to create '.../.git/index.lock': Permission denied`)。
|
|
64
|
+
- 对称地,被换根到副目录的命令在同一次调用中也**不能写主工作区**(或另一个副目录)。
|
|
65
|
+
- **创建文件的命令必须把 `workdir` 设为它要写入的目录。** 对 git 而言,请进入仓库目录执行(`workdir` 指向该仓库),而不是从主工作区用 `git -C`。
|
|
66
|
+
- 读操作不受限制,无需 `workdir`。
|
|
67
|
+
|
|
68
|
+
当 shell 命令以这类拒绝失败且命令引用了已配置的副目录时,插件会在工具结果后附带一条简短的诊断提示,说明 workdir 的修正方式。
|
|
69
|
+
|
|
59
70
|
## 工作原理
|
|
60
71
|
|
|
61
72
|
- **拦截**——监听 `tools/execute` 环绕分派瀑布,对解析路径(或 `workdir`)落在副目录内的 `write` / `edit` / `pwsh` / `bash` 调用短路,并以**换根后的会话站立策略**(`{ ...standingPolicy, workspaceRoot: secondaryDir }`)执行。模式本身不变,因此各种沙箱模式与主工作区的语义天然一致。匹配前先经 `fs.resolve` + `processPath` 规范化,`..`、符号链接与大小写差异均正确处理。
|
|
@@ -71,7 +82,7 @@ Agent 无需任何额外操作:`read` / `glob` / `grep` 随处可用;`write`
|
|
|
71
82
|
| ---- | ---- |
|
|
72
83
|
| `cordis.patch.yml` | profile patch 层,插入 `dsh-multi-folder` 行 |
|
|
73
84
|
| `lib/index.js` | 宿主插件:配置存储、工具流水线拦截、提示词注入、双通道通知、`/multi-folder` 命令、无会话 `multiFolder/*` 远程 API |
|
|
74
|
-
| `lib/client.js` | 客户端插件(factory bundle):会话头部按钮 + 覆盖层面板 +
|
|
85
|
+
| `lib/client.js` | 客户端插件(factory bundle):会话头部按钮 + 覆盖层面板 + 会话创建页入口(输入框上方的 dock 胶囊 / 上游 hero chip / 右下角兜底浮动按钮) |
|
|
75
86
|
| `test/` | 免 DSH 运行时的行为测试(见开发) |
|
|
76
87
|
| `docs/` | 设计与分析文档 |
|
|
77
88
|
|
package/docs/design.md
CHANGED
|
@@ -15,7 +15,7 @@ agent informed. No new tools are added.
|
|
|
15
15
|
| Half | File | Role |
|
|
16
16
|
| ---- | ---- | ---- |
|
|
17
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
|
|
18
|
+
| Client | `lib/client.js` | Session-header button + overlay panel; session-creation page entry (input-dock chip, upstream hero chip, or fixed fallback launcher — one at a time), all 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"]`).
|
|
@@ -157,9 +157,12 @@ window.__ModuleLoader__.load({
|
|
|
157
157
|
`@deepseek-ai/dsh-client-connection`, `@deepseek-ai/dsh-client-locale`,
|
|
158
158
|
`@deepseek-ai/dsh-client-runtime`).
|
|
159
159
|
- UI registrations: `conversation.session.header.actions` (session-scoped button),
|
|
160
|
-
`shell.overlay` panel, `
|
|
161
|
-
|
|
162
|
-
|
|
160
|
+
`shell.overlay` panel, `conversation.input.dock` chip row (session-scoped
|
|
161
|
+
list entry above the composer card — the session-creation page's shipped
|
|
162
|
+
seat), `shell.overlay` hero launcher (root-scoped fixed-position fallback),
|
|
163
|
+
and `conversation.hero.workspaceExtras` (upstream slot; see below). One
|
|
164
|
+
module-level store is shared by all of them, and only ONE session-creation
|
|
165
|
+
entry ever renders (see "hero seat election").
|
|
163
166
|
- **Localization (zh / en).** All client copy goes through
|
|
164
167
|
`@deepseek-ai/dsh-client-locale` (always composed by the standard web
|
|
165
168
|
profile). The bundle registers a `multi-folder` dictionary namespace with
|
|
@@ -189,23 +192,68 @@ window.__ModuleLoader__.load({
|
|
|
189
192
|
- Caching: per-session cache (`sessionCache`) keeps pure reads off the
|
|
190
193
|
conversation; per-workspace cache (`workspaceCache`) plays the same role for
|
|
191
194
|
the sessionless channel.
|
|
192
|
-
- Hero (session-creation page) support
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
195
|
+
- Hero (session-creation page) support — **three candidate seats, one visible
|
|
196
|
+
entry**:
|
|
197
|
+
- The **dock chip** (`conversation.input.dock`, id `multi-folder`,
|
|
198
|
+
order 120) is the shipped seat: a `list` slot the rc.6 shell declares and
|
|
199
|
+
renders directly ABOVE the composer card, in the same band as the
|
|
200
|
+
git-branch chip. The entry receives the dock owner share (`{ session,
|
|
201
|
+
input }`) plus the standard `useSessions` / `useWorkspaces` selector hooks,
|
|
202
|
+
so the hero phase (`composerPhase === 'blank' && (openState === 'open' ||
|
|
203
|
+
blank)`) and the target workspace come from framework props instead of DOM
|
|
204
|
+
probing. The row stays **in flow** — `display:flex` with the official hero
|
|
205
|
+
row's 20px indent, no absolute positioning — so the framework's list-slot
|
|
206
|
+
arrangement keeps it clear of every other plugin's dock row. It renders
|
|
207
|
+
only on the session-creation page; an active session keeps its entry in the
|
|
208
|
+
session header, so the two never appear together.
|
|
201
209
|
- The **hero chip** registers into `conversation.hero.workspaceExtras` via
|
|
202
210
|
`slots.inject`, which waits for the declaration: with an upstream DSH
|
|
203
211
|
build that declares the slot, the chip renders inline beside the workspace
|
|
204
|
-
picker; without one, the registration
|
|
205
|
-
|
|
212
|
+
picker; without one, the registration contributes nothing.
|
|
213
|
+
- The **hero launcher** (`shell.overlay` entry, `multi-folder-hero`) is the
|
|
214
|
+
last-resort fallback for shells that declare neither slot. Only then does
|
|
215
|
+
it subscribe to `sessions.list` + `workspaces.list` and observe the
|
|
216
|
+
conversation root's `data-phase="hero"` attribute (MutationObserver on
|
|
217
|
+
`document.body`) to render a fixed-position button; the
|
|
218
|
+
workspace path is derived from the current (blank) session's
|
|
219
|
+
`WorkspaceView.path`, falling back to `SessionSummary.cwd`.
|
|
220
|
+
- **Hero seat election.** Each seat claims a token while its slot declaration
|
|
221
|
+
is live (`slots.inject` fires only for declared slots and disposes on
|
|
222
|
+
collapse); the components render only while holding the best live claim
|
|
223
|
+
(`extras` > `dock` > fallback). The framework arranges *different plugins*
|
|
224
|
+
on a shared `list` slot but has no opinion about one plugin holding several
|
|
225
|
+
alternative seats, so this election is the plugin's own duty.
|
|
226
|
+
- Clicking any of them opens the panel in workspace mode; without a selected
|
|
227
|
+
workspace the panel shows the "pick a workspace first" hint.
|
|
228
|
+
- Panel placement: one `panelBody(store, t)` function returns the panel's
|
|
229
|
+
children, spread by whichever wrapper owns the panel — the fixed
|
|
230
|
+
`shell.overlay` panel (session-header path) or an `AnchoredPanel` popover
|
|
231
|
+
rendered by the chip itself (opening upward from the dock row, downward from
|
|
232
|
+
the hero row). `store.anchor` names the owner, and the overlay wrapper stands
|
|
233
|
+
down whenever a chip owns it, so the panel never renders twice.
|
|
234
|
+
- Styling: all surfaces use the official `--dsw-alias-*` design tokens
|
|
235
|
+
(`dsh-client-ui-theme`) with inert fallbacks, so themes and applied skins
|
|
236
|
+
restyle this plugin's chip and panel along with the shell's own controls.
|
|
206
237
|
|
|
207
238
|
## Known limitations
|
|
208
239
|
|
|
240
|
+
- Each confined command runs under exactly ONE writable root: the Windows ACL
|
|
241
|
+
runner grants a single workspace write SID per process tree (`--write-sid`
|
|
242
|
+
must match `--workspace`), and re-rooting replaces the root. A command whose
|
|
243
|
+
cwd stays the primary workspace therefore cannot create files inside a
|
|
244
|
+
secondary directory — `git -C <secondary> commit`, `cd <secondary>` inside a
|
|
245
|
+
script, `git clone <url> <secondary>`, and absolute-path writes fail with an
|
|
246
|
+
OS-level `Permission denied` (`fatal: Unable to create '.../.git/index.lock':
|
|
247
|
+
Permission denied`) that carries no sandbox marker. Symmetrically, a command
|
|
248
|
+
re-rooted to a secondary directory cannot write the primary workspace in the
|
|
249
|
+
same invocation. The injected prompt states the workdir rule, and a
|
|
250
|
+
`tools/post-execute` heuristic attaches a workdir-fix hint when a failed
|
|
251
|
+
`pwsh`/`bash` run both mentions a configured secondary directory and ends in
|
|
252
|
+
a denial (`permission denied` / `access … denied` / `is denied` / `eacces`;
|
|
253
|
+
the plugin's own `[sandbox: …]` marker lines are excluded from the scan).
|
|
254
|
+
Lifting this to real multi-root confinement needs an upstream change
|
|
255
|
+
(`SandboxExecutionPolicy` carrying extra write roots and the ACL runner
|
|
256
|
+
accepting several workspace write SIDs).
|
|
209
257
|
- Intercepted secondary-directory writes bypass the fs observation policy: they emit
|
|
210
258
|
no `fs/observed` event and do not participate in the `fs/write-intent` intent guard.
|
|
211
259
|
This is deliberate — secondary directories sit outside the primary workspace's
|
|
@@ -227,12 +275,26 @@ window.__ModuleLoader__.load({
|
|
|
227
275
|
visible in the conversation UI by framework design; they are log-only and never
|
|
228
276
|
reach the model. Workspace-mode (session-creation page) operations avoid them
|
|
229
277
|
entirely by using the sessionless remote channel.
|
|
230
|
-
- The
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
278
|
+
- The session-creation entry depends on shell internals to different degrees per
|
|
279
|
+
seat. The **dock chip** reads only declared contract surfaces (the
|
|
280
|
+
`conversation.input.dock` declaration, its owner share, and the standard
|
|
281
|
+
selector hooks), but its 20px indent is tuned to the shipped hero row's
|
|
282
|
+
padding — a restyled shell would misalign it, never break it. The **fallback
|
|
283
|
+
launcher** relies on the conversation root's `data-phase="hero"` attribute and
|
|
284
|
+
the `sessions.list`/`workspaces.list` snapshot shapes — DOM and client-runtime
|
|
285
|
+
internals rather than documented APIs; they are guarded defensively (missing
|
|
286
|
+
services or DOM degrade to "launcher hidden") and it only mounts when no
|
|
287
|
+
declared seat exists. The upstream `conversation.hero.workspaceExtras` slot
|
|
288
|
+
(see [upstream-hero-slot.md](upstream-hero-slot.md)) remains the long-term
|
|
289
|
+
surface.
|
|
290
|
+
- Sharing the `conversation.input.dock` band is safe by construction (unique
|
|
291
|
+
`id`, explicit `order`, in-flow layout) but `order` is a shared number space,
|
|
292
|
+
not an enforced allocation: another plugin may pick the same `order` and the
|
|
293
|
+
tie is then broken by registration sequence. The rows still stack without
|
|
294
|
+
overlapping — only their vertical sequence is unspecified. Absolute-positioned
|
|
295
|
+
neighbours (the git-branch chip lifts itself into the hero row) are outside
|
|
296
|
+
the framework's arrangement entirely; this plugin deliberately does not do the
|
|
297
|
+
same.
|
|
236
298
|
- The `multiFolder/*` endpoints use hand-written `src-json` Typert descriptors
|
|
237
299
|
registered through `ctx.typert.register`. `src-json` gives JSON-safety
|
|
238
300
|
boundary checks, not schema validation; the shared core performs all
|
|
@@ -248,5 +310,11 @@ canonicalization, the config guard, both notification channels, notice
|
|
|
248
310
|
gating, command flows, the panel's session-switch/caching behavior, the
|
|
249
311
|
sessionless remote contribution shape and behavior (list/add/set/remove,
|
|
250
312
|
idempotence, sanitization, error prefixing, cross-channel cache coherence),
|
|
251
|
-
and the hero/workspace-mode client flows
|
|
252
|
-
|
|
313
|
+
and the hero/workspace-mode client flows. The client test's `slots.inject` mock
|
|
314
|
+
is declaration-aware like the real service (a wait fires only while its slot is
|
|
315
|
+
declared, and a collapse disposes the registration), so it covers all three
|
|
316
|
+
session-creation seats: the dock chip on an rc.6-style shell (registration
|
|
317
|
+
shape, in-flow row, hero-only visibility, RPC routing, anchored popover), the
|
|
318
|
+
upstream hero chip taking over the moment its slot is declared, and the fixed
|
|
319
|
+
launcher returning once both declarations collapse — asserting at each step that
|
|
320
|
+
the other two surfaces stand down.
|
|
@@ -8,10 +8,14 @@ place configuration UI next to the workspace picker.
|
|
|
8
8
|
|
|
9
9
|
This document specifies the small upstream change to
|
|
10
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
|
|
12
|
-
`
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
plugin consumes it. Until this lands in a DSH release, the plugin's
|
|
12
|
+
`conversation.input.dock` chip row covers the same page (an in-flow chip
|
|
13
|
+
directly above the composer card, in the band the git-branch chip uses); the
|
|
14
|
+
slot registration below is a waiting no-op (`slots.inject` fires only once the
|
|
15
|
+
declaration exists), so the plugin works with and without the upstream change.
|
|
16
|
+
When the declaration does arrive, the hero chip claims the better seat and the
|
|
17
|
+
dock row stands down automatically — see the "hero seat election" section of
|
|
18
|
+
[design.md](design.md).
|
|
15
19
|
|
|
16
20
|
## 1. Declare the slot
|
|
17
21
|
|
|
@@ -85,19 +89,24 @@ active locale (English: "Multi-folder", Chinese: 「多工作目录」):
|
|
|
85
89
|
|
|
86
90
|
```js
|
|
87
91
|
slots.inject('conversation.hero.workspaceExtras', function () {
|
|
88
|
-
|
|
92
|
+
var release = claimHeroSeat('extras');
|
|
93
|
+
var dispose = slots.register(
|
|
89
94
|
{ name: 'conversation.hero.workspaceExtras', id: 'multi-folder', order: 30, label: () => t('label'), locale: NS },
|
|
90
95
|
function (props) { return React.createElement(HeroChip, props); },
|
|
91
96
|
);
|
|
97
|
+
return function () { dispose(); release(); };
|
|
92
98
|
});
|
|
93
99
|
```
|
|
94
100
|
|
|
95
101
|
(`NS` is the plugin's `multi-folder` namespace and `t` its bound translator —
|
|
96
|
-
see the localization section of [design.md](design.md).)
|
|
102
|
+
see the localization section of [design.md](design.md). `claimHeroSeat('extras')`
|
|
103
|
+
is the seat election: while this declaration lives, the `conversation.input.dock`
|
|
104
|
+
row and the fixed fallback launcher render nothing.)
|
|
97
105
|
|
|
98
106
|
`HeroChip` prefers the owner-supplied `props.workspacePath` and falls back to
|
|
99
|
-
the store-derived hero workspace. It opens the
|
|
100
|
-
|
|
107
|
+
the store-derived hero workspace. It opens the shared panel body as its own
|
|
108
|
+
popover anchored under the chip (matching the official workspace picker on the
|
|
109
|
+
same row), reading and writing the configuration through the sessionless
|
|
101
110
|
`multiFolder/*` endpoints (see [design.md](design.md)).
|
|
102
111
|
|
|
103
112
|
## Compiled-bundle equivalent (for local patching)
|
|
@@ -118,10 +127,14 @@ bundle; the same change there is three touchpoints:
|
|
|
118
127
|
|
|
119
128
|
## Verification
|
|
120
129
|
|
|
121
|
-
- Unit: the plugin's `test/smoke-client.mjs`
|
|
122
|
-
|
|
123
|
-
|
|
130
|
+
- Unit: the plugin's `test/smoke-client.mjs` declares the slot mid-run through
|
|
131
|
+
a declaration-aware `slots.inject` mock, then drives `HeroChip` with a mock
|
|
132
|
+
owner share (`workspacePath`), asserting the chip registers on declaration,
|
|
133
|
+
opens its popover in workspace mode from the per-workspace cache, and that
|
|
134
|
+
the `conversation.input.dock` row plus the fixed launcher both stand down
|
|
135
|
+
while the slot lives (and that the dock row returns once it collapses).
|
|
124
136
|
- Manual: with the patched build, the Multi-folder chip (「多工作目录」 in the
|
|
125
137
|
Chinese UI) renders in the hero workspace row between the preset chip and
|
|
126
138
|
the composer; clicking it lists the workspace's secondary directories
|
|
127
|
-
before any message is sent
|
|
139
|
+
before any message is sent, and the chip row above the composer card
|
|
140
|
+
disappears (one entry, not two).
|
package/lib/client.js
CHANGED
|
@@ -11,20 +11,42 @@
|
|
|
11
11
|
* human-readable result carrying a `[MF:JSON]` line the panel parses for
|
|
12
12
|
* structured state.
|
|
13
13
|
*
|
|
14
|
-
* Session-creation page UI (no
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
14
|
+
* Session-creation page UI (no message sent yet). Three candidate seats are
|
|
15
|
+
* registered, best first, and EXACTLY ONE renders — see "hero seat election":
|
|
16
|
+
* - `conversation.hero.workspaceExtras` (upstream additive hero row; the
|
|
17
|
+
* `slots.inject` wait is a no-op until a DSH core declares it);
|
|
18
|
+
* - `conversation.input.dock` (shipped since 0.1.9, declared by rc.6): an
|
|
19
|
+
* in-flow chip row directly ABOVE the composer card, left-aligned with the
|
|
20
|
+
* official hero chip row — the same band the git-branch chip uses;
|
|
21
|
+
* - `shell.overlay` fixed-position launcher: last-resort fallback for shells
|
|
22
|
+
* that declare neither slot.
|
|
23
|
+
* All three open the same panel in WORKSPACE mode and drive the sessionless
|
|
21
24
|
* `multiFolder/*` endpoints over the shared RPC channel
|
|
22
25
|
* (`ctx.connection.rpc.call('/api', endpoint, { args })`), keyed by workspace
|
|
23
26
|
* path instead of sessionId.
|
|
24
27
|
*
|
|
25
|
-
* Layout shape: an entry in the session header action row
|
|
26
|
-
*
|
|
28
|
+
* Layout shape: an entry in the session header action row, one chip row above
|
|
29
|
+
* the composer card on the session-creation page, and a frame-wide overlay
|
|
30
|
+
* panel. Every surface reads one tiny module-scoped store; opening the panel
|
|
27
31
|
* refreshes the list from the Host.
|
|
32
|
+
*
|
|
33
|
+
* ## Hero seat election (multi-plugin coexistence)
|
|
34
|
+
*
|
|
35
|
+
* `conversation.input.dock` is a `list` slot, so the framework already
|
|
36
|
+
* arranges co-registered entries for us: entries are sorted by
|
|
37
|
+
* `(priority, order)`, a duplicate `(id, priority)` pair is rejected at
|
|
38
|
+
* registration with an error naming the sitting occupant, and the outlet
|
|
39
|
+
* renders `display:contents` so each entry becomes its own row of the
|
|
40
|
+
* composer stack. This plugin therefore stays a good citizen by construction:
|
|
41
|
+
* one unique `id`, one explicit `order`, and NO absolute positioning that
|
|
42
|
+
* would escape the framework's arrangement and overlap a neighbour.
|
|
43
|
+
*
|
|
44
|
+
* What the framework does NOT do is stop ONE plugin from occupying several
|
|
45
|
+
* alternative seats at once (each is a legitimate, differently-declared
|
|
46
|
+
* slot). That is this plugin's own duty: each seat claims a token when its
|
|
47
|
+
* declaration arrives (`slots.inject` fires only for declared slots), and the
|
|
48
|
+
* components render only while they hold the best live claim, so the
|
|
49
|
+
* session-creation page never shows two Multi-folder entries.
|
|
28
50
|
*/
|
|
29
51
|
window.__ModuleLoader__.load({
|
|
30
52
|
id: 'dsh-multi-folder',
|
|
@@ -35,7 +57,24 @@ window.__ModuleLoader__.load({
|
|
|
35
57
|
|
|
36
58
|
// ------------------------------------------------------------- store
|
|
37
59
|
var listeners = new Set();
|
|
38
|
-
var state = {
|
|
60
|
+
var state = {
|
|
61
|
+
open: false,
|
|
62
|
+
mode: null,
|
|
63
|
+
sessionId: null,
|
|
64
|
+
dirs: [],
|
|
65
|
+
workspace: null,
|
|
66
|
+
busy: false,
|
|
67
|
+
error: null,
|
|
68
|
+
/** Fallback launcher only: hero visibility derived from the DOM. */
|
|
69
|
+
hero: false,
|
|
70
|
+
heroWorkspace: null,
|
|
71
|
+
/** Live hero-seat claims, in claim order (see "hero seat election"). */
|
|
72
|
+
heroClaims: [],
|
|
73
|
+
/** Which surface owns the open panel: 'overlay' | 'dock' | 'extras'. */
|
|
74
|
+
anchor: 'overlay',
|
|
75
|
+
/** Bumped on every workspaceCache write so chips re-read their count. */
|
|
76
|
+
cacheRev: 0,
|
|
77
|
+
};
|
|
39
78
|
function patch(next) {
|
|
40
79
|
state = Object.assign({}, state, next);
|
|
41
80
|
listeners.forEach(function (fn) { fn(); });
|
|
@@ -64,6 +103,58 @@ window.__ModuleLoader__.load({
|
|
|
64
103
|
try { return JSON.parse(m[1]); } catch (_) { return null; }
|
|
65
104
|
}
|
|
66
105
|
|
|
106
|
+
/** Stand-in for an absent standard-kit selector hook: runs the selector
|
|
107
|
+
* against no snapshot, so callers keep one unconditional call site. */
|
|
108
|
+
function selectNothing(selector) { return selector(undefined); }
|
|
109
|
+
|
|
110
|
+
// -------------------------------------------------- hero seat election
|
|
111
|
+
/** Candidate seats for the session-creation page, best first. */
|
|
112
|
+
var HERO_SEATS = ['extras', 'dock'];
|
|
113
|
+
/** Order of this plugin's `conversation.input.dock` row. The shipped band
|
|
114
|
+
* already carries other plugins' entries (the aionui drop inlay at 90/91
|
|
115
|
+
* and the git-branch chip at 100); 120 keeps this row closest to the
|
|
116
|
+
* composer card without contesting theirs. */
|
|
117
|
+
var DOCK_ORDER = 120;
|
|
118
|
+
|
|
119
|
+
/** The best seat currently claimed, or null when only the fallback is left. */
|
|
120
|
+
function bestHeroSeat(claims) {
|
|
121
|
+
for (var i = 0; i < HERO_SEATS.length; i++) {
|
|
122
|
+
if (claims.indexOf(HERO_SEATS[i]) >= 0) return HERO_SEATS[i];
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Claim a seat for as long as its slot declaration lives. */
|
|
128
|
+
function claimHeroSeat(kind) {
|
|
129
|
+
patch({ heroClaims: getSnapshot().heroClaims.concat([kind]) });
|
|
130
|
+
return function () {
|
|
131
|
+
var claims = getSnapshot().heroClaims.slice();
|
|
132
|
+
var at = claims.indexOf(kind);
|
|
133
|
+
if (at >= 0) claims.splice(at, 1);
|
|
134
|
+
patch({ heroClaims: claims });
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ------------------------------------------------------------- theme
|
|
139
|
+
/** Official `--dsw-alias-*` design tokens (dsh-client-ui-theme) with inert
|
|
140
|
+
* fallbacks, so the surfaces follow the active theme and any applied skin
|
|
141
|
+
* instead of guessing a palette. */
|
|
142
|
+
var TOKEN = {
|
|
143
|
+
border: 'var(--dsw-alias-border-l2, rgba(127,127,127,0.35))',
|
|
144
|
+
borderSoft: 'var(--dsw-alias-border-l1, rgba(127,127,127,0.20))',
|
|
145
|
+
surface: 'var(--dsw-alias-bg-overlay, #ffffff)',
|
|
146
|
+
subtle: 'var(--dsw-alias-bg-layer-2, rgba(127,127,127,0.08))',
|
|
147
|
+
fill: 'var(--dsw-alias-button-tool-bar-fill, rgba(127,127,127,0.10))',
|
|
148
|
+
hover: 'var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,0.14))',
|
|
149
|
+
ink: 'var(--dsw-alias-label-primary, #1a1a1a)',
|
|
150
|
+
inkSoft: 'var(--dsw-alias-label-secondary, #4a4a4a)',
|
|
151
|
+
inkMuted: 'var(--dsw-alias-label-tertiary, #6b6b6b)',
|
|
152
|
+
inkFaint: 'var(--dsw-alias-label-caption, #8a8a8a)',
|
|
153
|
+
danger: 'var(--dsw-alias-state-error-primary, #c62828)',
|
|
154
|
+
accent: 'var(--dsw-alias-brand-primary, #4c6ef5)',
|
|
155
|
+
shadow: '0 8px 24px var(--dsw-alias-bg-mask-2, rgba(0,0,0,0.18))',
|
|
156
|
+
};
|
|
157
|
+
|
|
67
158
|
// ------------------------------------------------------------- i18n
|
|
68
159
|
/** Locale namespace owned by this plugin. Dictionaries are registered
|
|
69
160
|
* with the `locale` service (`@deepseek-ai/dsh-client-locale`, always
|
|
@@ -74,7 +165,9 @@ window.__ModuleLoader__.load({
|
|
|
74
165
|
var zhDict = {
|
|
75
166
|
'label': '多工作目录',
|
|
76
167
|
'label.open': '多工作目录 ▾',
|
|
168
|
+
'label.withCount': '多工作目录 · {count}',
|
|
77
169
|
'label.heroLauncher': '多工作目录(新会话)',
|
|
170
|
+
'label.heroDock': '多工作目录(输入框上方)',
|
|
78
171
|
'title.header': '多工作目录(副工作目录)',
|
|
79
172
|
'title.remove': '移除此副工作目录',
|
|
80
173
|
'title.close': '关闭',
|
|
@@ -96,7 +189,9 @@ window.__ModuleLoader__.load({
|
|
|
96
189
|
var enDict = {
|
|
97
190
|
'label': 'Multi-folder',
|
|
98
191
|
'label.open': 'Multi-folder ▾',
|
|
192
|
+
'label.withCount': 'Multi-folder · {count}',
|
|
99
193
|
'label.heroLauncher': 'Multi-folder (new session)',
|
|
194
|
+
'label.heroDock': 'Multi-folder (above the composer)',
|
|
100
195
|
'title.header': 'Multi-folder (secondary working directories)',
|
|
101
196
|
'title.remove': 'Remove this secondary working directory',
|
|
102
197
|
'title.close': 'Close',
|
|
@@ -161,17 +256,42 @@ window.__ModuleLoader__.load({
|
|
|
161
256
|
});
|
|
162
257
|
}
|
|
163
258
|
|
|
259
|
+
/** Write one workspace's list into the cache and return it normalized. */
|
|
260
|
+
function storeWorkspace(workspacePath, value) {
|
|
261
|
+
var normalized = value && Array.isArray(value.dirs)
|
|
262
|
+
? value
|
|
263
|
+
: { workspace: workspacePath, dirs: [] };
|
|
264
|
+
workspaceCache[workspacePathKey(workspacePath)] = normalized;
|
|
265
|
+
return normalized;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Whether the open panel is the one showing `workspacePath`. */
|
|
269
|
+
function panelTargets(workspacePath) {
|
|
270
|
+
var current = getSnapshot();
|
|
271
|
+
return !current.sessionId
|
|
272
|
+
&& current.mode === 'workspace'
|
|
273
|
+
&& !!current.workspace
|
|
274
|
+
&& workspacePathKey(current.workspace) === workspacePathKey(workspacePath);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Patch after a workspace-mode read/write: always publish the new cache
|
|
278
|
+
* revision (chips show the configured count), and refresh the panel
|
|
279
|
+
* body only when it is the panel for this workspace. */
|
|
280
|
+
function publishWorkspace(workspacePath, value) {
|
|
281
|
+
var normalized = storeWorkspace(workspacePath, value);
|
|
282
|
+
var next = { busy: false, cacheRev: getSnapshot().cacheRev + 1 };
|
|
283
|
+
if (panelTargets(workspacePath)) {
|
|
284
|
+
next.workspace = normalized.workspace;
|
|
285
|
+
next.dirs = normalized.dirs || [];
|
|
286
|
+
}
|
|
287
|
+
patch(next);
|
|
288
|
+
}
|
|
289
|
+
|
|
164
290
|
function refreshWorkspace(workspacePath) {
|
|
165
291
|
if (!workspacePath) return;
|
|
166
292
|
patch({ busy: true, error: null });
|
|
167
293
|
remoteCall('multiFolder/list', { workspace: workspacePath }).then(function (value) {
|
|
168
|
-
|
|
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
|
-
}
|
|
294
|
+
publishWorkspace(workspacePath, value);
|
|
175
295
|
}).catch(function (e) {
|
|
176
296
|
patch({ busy: false, error: String(e && e.message ? e.message : e) });
|
|
177
297
|
});
|
|
@@ -182,23 +302,27 @@ window.__ModuleLoader__.load({
|
|
|
182
302
|
patch({ busy: true, error: null });
|
|
183
303
|
var payload = Object.assign({ workspace: workspacePath }, args);
|
|
184
304
|
return remoteCall(endpoint, payload).then(function (value) {
|
|
185
|
-
|
|
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
|
-
}
|
|
305
|
+
publishWorkspace(workspacePath, value);
|
|
192
306
|
}).catch(function (e) {
|
|
193
307
|
patch({ busy: false, error: String(e && e.message ? e.message : e) });
|
|
194
308
|
});
|
|
195
309
|
}
|
|
196
310
|
|
|
311
|
+
/** Configured directory count for a workspace, or null while unread. */
|
|
312
|
+
function dirCountOf(workspacePath) {
|
|
313
|
+
if (!workspacePath) return null;
|
|
314
|
+
var cached = workspaceCache[workspacePathKey(workspacePath)];
|
|
315
|
+
return cached && Array.isArray(cached.dirs) ? cached.dirs.length : null;
|
|
316
|
+
}
|
|
317
|
+
|
|
197
318
|
/** Open the panel in WORKSPACE mode (session-creation page): a null
|
|
198
|
-
* workspace shows the "pick a workspace first" hint instead.
|
|
199
|
-
|
|
319
|
+
* workspace shows the "pick a workspace first" hint instead.
|
|
320
|
+
* `anchor` names the surface that owns the panel — an inline chip
|
|
321
|
+
* renders it as its own popover, everything else uses the overlay. */
|
|
322
|
+
function openForWorkspace(workspacePath, anchor) {
|
|
323
|
+
var seat = anchor || 'overlay';
|
|
200
324
|
if (!workspacePath) {
|
|
201
|
-
patch({ open: true, mode: 'workspace', sessionId: null, workspace: null, dirs: [], error: null });
|
|
325
|
+
patch({ open: true, mode: 'workspace', sessionId: null, workspace: null, dirs: [], error: null, anchor: seat });
|
|
202
326
|
return;
|
|
203
327
|
}
|
|
204
328
|
var cached = workspaceCache[workspacePathKey(workspacePath)];
|
|
@@ -209,6 +333,7 @@ window.__ModuleLoader__.load({
|
|
|
209
333
|
workspace: workspacePath,
|
|
210
334
|
dirs: cached ? cached.dirs : [],
|
|
211
335
|
error: null,
|
|
336
|
+
anchor: seat,
|
|
212
337
|
});
|
|
213
338
|
if (!cached) refreshWorkspace(workspacePath);
|
|
214
339
|
}
|
|
@@ -293,6 +418,7 @@ window.__ModuleLoader__.load({
|
|
|
293
418
|
dirs: cached ? cached.dirs : [],
|
|
294
419
|
workspace: cached ? cached.workspace : null,
|
|
295
420
|
error: null,
|
|
421
|
+
anchor: 'overlay',
|
|
296
422
|
});
|
|
297
423
|
if (cached === undefined) refresh(sessionId);
|
|
298
424
|
}
|
|
@@ -336,9 +462,9 @@ window.__ModuleLoader__.load({
|
|
|
336
462
|
gap: 6,
|
|
337
463
|
padding: '4px 10px',
|
|
338
464
|
borderRadius: 8,
|
|
339
|
-
border: '1px solid
|
|
340
|
-
background: open ?
|
|
341
|
-
color:
|
|
465
|
+
border: '1px solid ' + TOKEN.border,
|
|
466
|
+
background: open ? TOKEN.hover : 'transparent',
|
|
467
|
+
color: TOKEN.ink,
|
|
342
468
|
fontSize: 13,
|
|
343
469
|
cursor: 'pointer',
|
|
344
470
|
},
|
|
@@ -348,11 +474,11 @@ window.__ModuleLoader__.load({
|
|
|
348
474
|
return btn;
|
|
349
475
|
}
|
|
350
476
|
|
|
351
|
-
//
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
477
|
+
// Panel body ---------------------------------------------------------
|
|
478
|
+
/** The panel's children, shared verbatim by the overlay panel and the
|
|
479
|
+
* chip popover. Returned as an ARRAY so each wrapper can spread it as
|
|
480
|
+
* its own direct children (one DOM shape, two placements). */
|
|
481
|
+
function panelBody(store, t) {
|
|
356
482
|
var sessionMode = store.mode === 'session';
|
|
357
483
|
var usable = sessionMode || !!store.workspace;
|
|
358
484
|
var rows = (store.dirs || []).map(function (dir, index) {
|
|
@@ -366,7 +492,7 @@ window.__ModuleLoader__.load({
|
|
|
366
492
|
gap: 8,
|
|
367
493
|
padding: '6px 8px',
|
|
368
494
|
borderRadius: 6,
|
|
369
|
-
background:
|
|
495
|
+
background: TOKEN.subtle,
|
|
370
496
|
marginBottom: 6,
|
|
371
497
|
},
|
|
372
498
|
},
|
|
@@ -391,42 +517,24 @@ window.__ModuleLoader__.load({
|
|
|
391
517
|
mutateWorkspace(store.workspace, 'multiFolder/remove', { path: dir });
|
|
392
518
|
}
|
|
393
519
|
},
|
|
394
|
-
style: { padding: '2px 8px', borderRadius: 6, border: '1px solid transparent', background: 'transparent', color:
|
|
520
|
+
style: { padding: '2px 8px', borderRadius: 6, border: '1px solid transparent', background: 'transparent', color: TOKEN.danger, cursor: 'pointer' },
|
|
395
521
|
},
|
|
396
522
|
t('panel.remove'),
|
|
397
523
|
),
|
|
398
524
|
);
|
|
399
525
|
});
|
|
400
|
-
return
|
|
401
|
-
'div',
|
|
402
|
-
{
|
|
403
|
-
style: {
|
|
404
|
-
position: 'fixed',
|
|
405
|
-
top: 64,
|
|
406
|
-
right: 20,
|
|
407
|
-
width: 380,
|
|
408
|
-
maxWidth: 'calc(100vw - 40px)',
|
|
409
|
-
zIndex: 400,
|
|
410
|
-
background: 'var(--color-bg-elevated, #ffffff)',
|
|
411
|
-
color: 'var(--color-text, #1a1a1a)',
|
|
412
|
-
border: '1px solid var(--color-border, rgba(127,127,127,0.35))',
|
|
413
|
-
borderRadius: 12,
|
|
414
|
-
boxShadow: '0 8px 32px rgba(0,0,0,0.18)',
|
|
415
|
-
padding: 14,
|
|
416
|
-
fontSize: 13,
|
|
417
|
-
},
|
|
418
|
-
},
|
|
526
|
+
return [
|
|
419
527
|
React.createElement(
|
|
420
528
|
'div',
|
|
421
529
|
{ style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 } },
|
|
422
|
-
React.createElement('div', { style: { fontWeight: 600, fontSize: 14 } }, t('panel.title')),
|
|
530
|
+
React.createElement('div', { style: { fontWeight: 600, fontSize: 14, color: TOKEN.ink } }, t('panel.title')),
|
|
423
531
|
React.createElement(
|
|
424
532
|
'button',
|
|
425
533
|
{
|
|
426
534
|
type: 'button',
|
|
427
535
|
title: t('title.close'),
|
|
428
536
|
onClick: function () { patch({ open: false }); },
|
|
429
|
-
style: { padding: '2px 8px', borderRadius: 6, border: '1px solid transparent', background: 'transparent', cursor: 'pointer' },
|
|
537
|
+
style: { padding: '2px 8px', borderRadius: 6, border: '1px solid transparent', background: 'transparent', color: TOKEN.inkMuted, cursor: 'pointer' },
|
|
430
538
|
},
|
|
431
539
|
'✕',
|
|
432
540
|
),
|
|
@@ -434,21 +542,21 @@ window.__ModuleLoader__.load({
|
|
|
434
542
|
store.workspace
|
|
435
543
|
? React.createElement(
|
|
436
544
|
'div',
|
|
437
|
-
{ style: { marginBottom: 8, color:
|
|
545
|
+
{ style: { marginBottom: 8, color: TOKEN.inkMuted, fontSize: 12, wordBreak: 'break-all' } },
|
|
438
546
|
t('panel.project', { path: store.workspace }),
|
|
439
547
|
)
|
|
440
548
|
: (!sessionMode
|
|
441
549
|
? React.createElement(
|
|
442
550
|
'div',
|
|
443
|
-
{ style: { marginBottom: 8, color:
|
|
551
|
+
{ style: { marginBottom: 8, color: TOKEN.inkMuted } },
|
|
444
552
|
t('panel.noWorkspaceHint'),
|
|
445
553
|
)
|
|
446
554
|
: null),
|
|
447
555
|
usable
|
|
448
|
-
? (rows.length > 0 ? rows : React.createElement('div', { style: { marginBottom: 8, color:
|
|
449
|
-
: React.createElement('div', { style: { marginBottom: 8, color:
|
|
556
|
+
? (rows.length > 0 ? rows : React.createElement('div', { style: { marginBottom: 8, color: TOKEN.inkMuted } }, t('panel.empty')))
|
|
557
|
+
: React.createElement('div', { style: { marginBottom: 8, color: TOKEN.inkMuted } }, t('panel.pickWorkspaceHint')),
|
|
450
558
|
store.error
|
|
451
|
-
? React.createElement('div', { style: { marginBottom: 8, color:
|
|
559
|
+
? React.createElement('div', { style: { marginBottom: 8, color: TOKEN.danger, whiteSpace: 'pre-wrap' } }, String(store.error))
|
|
452
560
|
: null,
|
|
453
561
|
React.createElement(
|
|
454
562
|
'div',
|
|
@@ -463,9 +571,9 @@ window.__ModuleLoader__.load({
|
|
|
463
571
|
flex: 1,
|
|
464
572
|
padding: '6px 10px',
|
|
465
573
|
borderRadius: 8,
|
|
466
|
-
border: '1px solid
|
|
467
|
-
background:
|
|
468
|
-
color:
|
|
574
|
+
border: '1px solid ' + TOKEN.border,
|
|
575
|
+
background: TOKEN.fill,
|
|
576
|
+
color: TOKEN.ink,
|
|
469
577
|
cursor: store.busy || !usable ? 'default' : 'pointer',
|
|
470
578
|
opacity: store.busy || !usable ? 0.6 : 1,
|
|
471
579
|
},
|
|
@@ -487,9 +595,9 @@ window.__ModuleLoader__.load({
|
|
|
487
595
|
style: {
|
|
488
596
|
padding: '6px 10px',
|
|
489
597
|
borderRadius: 8,
|
|
490
|
-
border: '1px solid
|
|
598
|
+
border: '1px solid ' + TOKEN.border,
|
|
491
599
|
background: 'transparent',
|
|
492
|
-
color:
|
|
600
|
+
color: TOKEN.ink,
|
|
493
601
|
cursor: store.busy || !usable ? 'default' : 'pointer',
|
|
494
602
|
opacity: store.busy || !usable ? 0.6 : 1,
|
|
495
603
|
},
|
|
@@ -504,33 +612,262 @@ window.__ModuleLoader__.load({
|
|
|
504
612
|
marginTop: 10,
|
|
505
613
|
fontSize: 11,
|
|
506
614
|
lineHeight: 1.5,
|
|
507
|
-
color:
|
|
615
|
+
color: TOKEN.inkMuted,
|
|
508
616
|
},
|
|
509
617
|
},
|
|
510
618
|
t('panel.footnote'),
|
|
511
619
|
),
|
|
620
|
+
];
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/** Spread `children` as the direct children of one element (keeps the
|
|
624
|
+
* panel's DOM shape identical across both placements). */
|
|
625
|
+
function element(type, props, children) {
|
|
626
|
+
return React.createElement.apply(null, [type, props].concat(children));
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// Overlay panel ------------------------------------------------------
|
|
630
|
+
function Panel(props) {
|
|
631
|
+
var store = useStore();
|
|
632
|
+
var t = props.t;
|
|
633
|
+
if (!store.open || !store.mode) return null;
|
|
634
|
+
// An inline chip owns its own popover; the overlay stands down.
|
|
635
|
+
if (store.anchor !== 'overlay') return null;
|
|
636
|
+
return element(
|
|
637
|
+
'div',
|
|
638
|
+
{
|
|
639
|
+
style: {
|
|
640
|
+
position: 'fixed',
|
|
641
|
+
top: 64,
|
|
642
|
+
right: 20,
|
|
643
|
+
width: 380,
|
|
644
|
+
maxWidth: 'calc(100vw - 40px)',
|
|
645
|
+
zIndex: 400,
|
|
646
|
+
background: TOKEN.surface,
|
|
647
|
+
color: TOKEN.ink,
|
|
648
|
+
border: '1px solid ' + TOKEN.border,
|
|
649
|
+
borderRadius: 12,
|
|
650
|
+
boxShadow: TOKEN.shadow,
|
|
651
|
+
padding: 14,
|
|
652
|
+
fontSize: 13,
|
|
653
|
+
},
|
|
654
|
+
},
|
|
655
|
+
panelBody(store, t),
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// Anchored popover ---------------------------------------------------
|
|
660
|
+
/** The same panel body as a popover anchored to a chip. `direction`
|
|
661
|
+
* 'up' opens above the chip (the dock row sits above the composer
|
|
662
|
+
* card), 'down' opens below it (the hero chip row). */
|
|
663
|
+
function AnchoredPanel(props) {
|
|
664
|
+
var store = useStore();
|
|
665
|
+
var t = props.t;
|
|
666
|
+
var up = props.direction !== 'down';
|
|
667
|
+
var placement = up ? { bottom: 'calc(100% + 6px)' } : { top: 'calc(100% + 6px)' };
|
|
668
|
+
return element(
|
|
669
|
+
'div',
|
|
670
|
+
{
|
|
671
|
+
style: Object.assign(
|
|
672
|
+
{
|
|
673
|
+
position: 'absolute',
|
|
674
|
+
left: 0,
|
|
675
|
+
zIndex: 40,
|
|
676
|
+
width: 380,
|
|
677
|
+
maxWidth: 'calc(100vw - 40px)',
|
|
678
|
+
// The composer stack lives in a scroll container with
|
|
679
|
+
// `overflow: hidden auto`; capping the height keeps a tall
|
|
680
|
+
// list inside the viewport instead of clipping it.
|
|
681
|
+
maxHeight: 'min(60vh, 420px)',
|
|
682
|
+
overflowY: 'auto',
|
|
683
|
+
background: TOKEN.surface,
|
|
684
|
+
color: TOKEN.ink,
|
|
685
|
+
border: '1px solid ' + TOKEN.border,
|
|
686
|
+
borderRadius: 12,
|
|
687
|
+
boxShadow: TOKEN.shadow,
|
|
688
|
+
padding: 14,
|
|
689
|
+
fontSize: 13,
|
|
690
|
+
textAlign: 'left',
|
|
691
|
+
},
|
|
692
|
+
placement,
|
|
693
|
+
),
|
|
694
|
+
},
|
|
695
|
+
panelBody(store, t),
|
|
696
|
+
);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/** Click-outside catcher for an open popover. */
|
|
700
|
+
function Backdrop() {
|
|
701
|
+
return React.createElement('div', {
|
|
702
|
+
onClick: function () { patch({ open: false }); },
|
|
703
|
+
style: { position: 'fixed', inset: 0, zIndex: 30 },
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/** One chip + its popover, wrapped in the positioning context the
|
|
708
|
+
* popover anchors to. Shared by the dock row and the hero chip. */
|
|
709
|
+
function chipWithPopover(seat, direction, t, button) {
|
|
710
|
+
var store = getSnapshot();
|
|
711
|
+
var open = store.open && store.anchor === seat;
|
|
712
|
+
return React.createElement(
|
|
713
|
+
'div',
|
|
714
|
+
{ style: { position: 'relative', display: 'inline-flex', minWidth: 0 } },
|
|
715
|
+
button,
|
|
716
|
+
open ? React.createElement(Backdrop, { key: 'backdrop' }) : null,
|
|
717
|
+
open ? React.createElement(AnchoredPanel, { key: 'panel', t: t, direction: direction }) : null,
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/** The chip pill, styled from the official chip recipe. */
|
|
722
|
+
function chipButton(options) {
|
|
723
|
+
return React.createElement(
|
|
724
|
+
'button',
|
|
725
|
+
{
|
|
726
|
+
type: 'button',
|
|
727
|
+
title: options.title,
|
|
728
|
+
onClick: options.onClick,
|
|
729
|
+
style: {
|
|
730
|
+
display: 'inline-flex',
|
|
731
|
+
alignItems: 'center',
|
|
732
|
+
gap: 6,
|
|
733
|
+
height: 24,
|
|
734
|
+
padding: '0 10px',
|
|
735
|
+
borderRadius: 999,
|
|
736
|
+
border: '1px solid ' + (options.open ? TOKEN.accent : TOKEN.border),
|
|
737
|
+
background: options.open ? TOKEN.hover : TOKEN.fill,
|
|
738
|
+
color: options.open ? TOKEN.ink : TOKEN.inkSoft,
|
|
739
|
+
fontSize: 12,
|
|
740
|
+
lineHeight: 1,
|
|
741
|
+
whiteSpace: 'nowrap',
|
|
742
|
+
cursor: 'pointer',
|
|
743
|
+
maxWidth: 260,
|
|
744
|
+
overflow: 'hidden',
|
|
745
|
+
opacity: options.dimmed ? 0.7 : 1,
|
|
746
|
+
},
|
|
747
|
+
},
|
|
748
|
+
React.createElement(
|
|
749
|
+
'span',
|
|
750
|
+
{ style: { overflow: 'hidden', textOverflow: 'ellipsis' } },
|
|
751
|
+
options.label,
|
|
752
|
+
),
|
|
753
|
+
React.createElement('span', { style: { color: TOKEN.inkFaint, fontSize: 10 } }, '▾'),
|
|
512
754
|
);
|
|
513
755
|
}
|
|
514
756
|
|
|
515
757
|
// Hero (session-creation page) support --------------------------------
|
|
758
|
+
/** The shell's own hero predicate, read from the dock's owner share: a
|
|
759
|
+
* blank conversation with an open session. A still-loading blank
|
|
760
|
+
* session already lists as blank, so it may enter the hero phase
|
|
761
|
+
* before the composer snapshot settles to `open`. */
|
|
762
|
+
function isHeroPhase(session, blank) {
|
|
763
|
+
if (!session) return false;
|
|
764
|
+
return session.composerPhase === 'blank' && (session.openState === 'open' || blank === true);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/** The workspace path a session belongs to, from the workspaces list. */
|
|
768
|
+
function workspacePathIn(snapshot, sessionId) {
|
|
769
|
+
var items = snapshot && snapshot.items ? snapshot.items : [];
|
|
770
|
+
for (var i = 0; i < items.length; i++) {
|
|
771
|
+
var workspace = items[i];
|
|
772
|
+
if (workspace && workspace.path && Array.isArray(workspace.sessionIds) && workspace.sessionIds.indexOf(sessionId) >= 0) {
|
|
773
|
+
return workspace.path;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
return null;
|
|
777
|
+
}
|
|
778
|
+
|
|
516
779
|
/** Workspace path of the current session (blank-session hero), or null
|
|
517
|
-
* while no session/workspace is selected at all.
|
|
780
|
+
* while no session/workspace is selected at all. Store-read twin of
|
|
781
|
+
* `workspacePathIn` for the fallback launcher, which has no props. */
|
|
518
782
|
function heroWorkspacePath() {
|
|
519
783
|
var sessionList = sessions && sessions.list ? sessions.list.getSnapshot() : null;
|
|
520
784
|
var id = sessionList && sessionList.current;
|
|
521
785
|
if (!id) return null;
|
|
522
786
|
var workspaceList = workspaces && workspaces.list ? workspaces.list.getSnapshot() : null;
|
|
523
|
-
var
|
|
524
|
-
|
|
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
|
-
}
|
|
787
|
+
var fromWorkspaces = workspacePathIn(workspaceList, id);
|
|
788
|
+
if (fromWorkspaces !== null) return fromWorkspaces;
|
|
530
789
|
var row = sessionList && sessionList.byId ? sessionList.byId[id] : undefined;
|
|
531
790
|
return row && row.cwd ? row.cwd : null;
|
|
532
791
|
}
|
|
533
792
|
|
|
793
|
+
/**
|
|
794
|
+
* Dock chip (session-creation page, preferred shipped seat): one in-flow
|
|
795
|
+
* row in `conversation.input.dock`, directly above the composer card and
|
|
796
|
+
* left-aligned with the official hero chip row. The row stays IN FLOW —
|
|
797
|
+
* the framework arranges co-registered dock entries as sibling rows, so
|
|
798
|
+
* absolute positioning here would silently overlap a neighbour's chip.
|
|
799
|
+
*
|
|
800
|
+
* Renders only on the session-creation page: an active session keeps its
|
|
801
|
+
* entry in the session header, so the two never appear at once.
|
|
802
|
+
*/
|
|
803
|
+
function DockChip(props) {
|
|
804
|
+
var store = useStore();
|
|
805
|
+
var t = props.t;
|
|
806
|
+
var sessionId = props.sessionId;
|
|
807
|
+
// The standard kit always supplies these selector hooks; the absent
|
|
808
|
+
// form keeps the hook call unconditional (stable hook order) for
|
|
809
|
+
// hosts and tests that render the entry without the kit.
|
|
810
|
+
var useSessions = typeof props.useSessions === 'function' ? props.useSessions : selectNothing;
|
|
811
|
+
var useWorkspaces = typeof props.useWorkspaces === 'function' ? props.useWorkspaces : selectNothing;
|
|
812
|
+
var blank = useSessions(function (s) {
|
|
813
|
+
return !!(s && s.byId && sessionId !== undefined && s.byId[sessionId] && s.byId[sessionId].blank === true);
|
|
814
|
+
});
|
|
815
|
+
var cwd = useSessions(function (s) {
|
|
816
|
+
var row = s && s.byId && sessionId !== undefined ? s.byId[sessionId] : undefined;
|
|
817
|
+
return row && row.cwd ? row.cwd : null;
|
|
818
|
+
});
|
|
819
|
+
var fromWorkspaces = useWorkspaces(function (s) { return workspacePathIn(s, sessionId); });
|
|
820
|
+
var workspacePath = fromWorkspaces || cwd;
|
|
821
|
+
var hero = isHeroPhase(props.session, blank);
|
|
822
|
+
var mine = bestHeroSeat(store.heroClaims) === 'dock';
|
|
823
|
+
// Fill the count cache once per workspace. Workspace mode rides the
|
|
824
|
+
// sessionless RPC, so this read produces no conversation row.
|
|
825
|
+
React.useEffect(
|
|
826
|
+
function () {
|
|
827
|
+
if (!hero || !mine || !workspacePath) return undefined;
|
|
828
|
+
if (workspaceCache[workspacePathKey(workspacePath)] === undefined) refreshWorkspace(workspacePath);
|
|
829
|
+
return undefined;
|
|
830
|
+
},
|
|
831
|
+
[hero, mine, workspacePath],
|
|
832
|
+
);
|
|
833
|
+
if (!hero || !mine) return null;
|
|
834
|
+
var open = store.open && store.anchor === 'dock';
|
|
835
|
+
var count = dirCountOf(workspacePath);
|
|
836
|
+
return React.createElement(
|
|
837
|
+
'div',
|
|
838
|
+
{
|
|
839
|
+
style: {
|
|
840
|
+
// The dock outlet is display:contents, so this row is a direct
|
|
841
|
+
// child of the composer stack — the same 20px indent as the
|
|
842
|
+
// official hero chip row above it.
|
|
843
|
+
display: 'flex',
|
|
844
|
+
alignItems: 'center',
|
|
845
|
+
gap: 6,
|
|
846
|
+
minWidth: 0,
|
|
847
|
+
paddingLeft: 20,
|
|
848
|
+
},
|
|
849
|
+
},
|
|
850
|
+
chipWithPopover(
|
|
851
|
+
'dock',
|
|
852
|
+
'up',
|
|
853
|
+
t,
|
|
854
|
+
chipButton({
|
|
855
|
+
open: open,
|
|
856
|
+
dimmed: !workspacePath,
|
|
857
|
+
title: workspacePath ? t('title.heroChip') : t('title.heroLauncher.noWorkspace'),
|
|
858
|
+
label: count !== null && count > 0 ? t('label.withCount', { count: count }) : t('label'),
|
|
859
|
+
onClick: function () {
|
|
860
|
+
if (open) {
|
|
861
|
+
patch({ open: false });
|
|
862
|
+
} else {
|
|
863
|
+
openForWorkspace(workspacePath, 'dock');
|
|
864
|
+
}
|
|
865
|
+
},
|
|
866
|
+
}),
|
|
867
|
+
),
|
|
868
|
+
);
|
|
869
|
+
}
|
|
870
|
+
|
|
534
871
|
/** Re-read the conversation root's `data-phase` attribute (authoritative
|
|
535
872
|
* hero signal) and the derivable workspace, then patch the store. */
|
|
536
873
|
function syncHero() {
|
|
@@ -546,13 +883,17 @@ window.__ModuleLoader__.load({
|
|
|
546
883
|
}
|
|
547
884
|
}
|
|
548
885
|
|
|
549
|
-
/** Fixed-position hero launcher
|
|
550
|
-
*
|
|
886
|
+
/** Fixed-position hero launcher: the last-resort fallback, mounted only
|
|
887
|
+
* while NO declared slot seat is available. Its DOM/store probing (and
|
|
888
|
+
* the MutationObserver behind it) stays unwired whenever a real seat
|
|
889
|
+
* holds the page. */
|
|
551
890
|
function HeroLauncher(props) {
|
|
552
891
|
var store = useStore();
|
|
553
892
|
var t = props.t;
|
|
893
|
+
var seat = bestHeroSeat(store.heroClaims);
|
|
554
894
|
React.useEffect(
|
|
555
895
|
function () {
|
|
896
|
+
if (bestHeroSeat(getSnapshot().heroClaims) !== null) return undefined;
|
|
556
897
|
var disposers = [];
|
|
557
898
|
if (sessions && sessions.list && typeof sessions.list.subscribe === 'function') {
|
|
558
899
|
disposers.push(sessions.list.subscribe(syncHero));
|
|
@@ -576,15 +917,16 @@ window.__ModuleLoader__.load({
|
|
|
576
917
|
if (observer) observer.disconnect();
|
|
577
918
|
};
|
|
578
919
|
},
|
|
579
|
-
[],
|
|
920
|
+
[seat],
|
|
580
921
|
);
|
|
922
|
+
if (seat !== null) return null;
|
|
581
923
|
if (!store.hero) return null;
|
|
582
924
|
return React.createElement(
|
|
583
925
|
'button',
|
|
584
926
|
{
|
|
585
927
|
type: 'button',
|
|
586
928
|
title: store.heroWorkspace ? t('title.heroLauncher.hasWorkspace') : t('title.heroLauncher.noWorkspace'),
|
|
587
|
-
onClick: function () { openForWorkspace(store.heroWorkspace); },
|
|
929
|
+
onClick: function () { openForWorkspace(store.heroWorkspace, 'overlay'); },
|
|
588
930
|
style: {
|
|
589
931
|
position: 'fixed',
|
|
590
932
|
bottom: 24,
|
|
@@ -595,12 +937,12 @@ window.__ModuleLoader__.load({
|
|
|
595
937
|
gap: 6,
|
|
596
938
|
padding: '6px 12px',
|
|
597
939
|
borderRadius: 999,
|
|
598
|
-
border: '1px solid
|
|
599
|
-
background:
|
|
600
|
-
color:
|
|
940
|
+
border: '1px solid ' + TOKEN.border,
|
|
941
|
+
background: TOKEN.surface,
|
|
942
|
+
color: TOKEN.ink,
|
|
601
943
|
fontSize: 13,
|
|
602
944
|
cursor: 'pointer',
|
|
603
|
-
boxShadow:
|
|
945
|
+
boxShadow: TOKEN.shadow,
|
|
604
946
|
opacity: store.heroWorkspace ? 1 : 0.7,
|
|
605
947
|
},
|
|
606
948
|
},
|
|
@@ -609,32 +951,33 @@ window.__ModuleLoader__.load({
|
|
|
609
951
|
}
|
|
610
952
|
|
|
611
953
|
/** Inline chip for the upstream `conversation.hero.workspaceExtras`
|
|
612
|
-
* slot
|
|
613
|
-
*
|
|
954
|
+
* slot: rendered beside the workspace picker once the DSH core declares
|
|
955
|
+
* the slot; a no-op registration until then. Its popover opens
|
|
956
|
+
* DOWNWARD, matching the official workspace picker on the same row. */
|
|
614
957
|
function HeroChip(props) {
|
|
615
958
|
var store = useStore();
|
|
616
959
|
var t = props.t;
|
|
617
960
|
var workspacePath = props && props.workspacePath ? props.workspacePath : store.heroWorkspace;
|
|
618
|
-
return
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
961
|
+
if (bestHeroSeat(store.heroClaims) !== 'extras') return null;
|
|
962
|
+
var open = store.open && store.anchor === 'extras';
|
|
963
|
+
var count = dirCountOf(workspacePath);
|
|
964
|
+
return chipWithPopover(
|
|
965
|
+
'extras',
|
|
966
|
+
'down',
|
|
967
|
+
t,
|
|
968
|
+
chipButton({
|
|
969
|
+
open: open,
|
|
970
|
+
dimmed: !workspacePath,
|
|
971
|
+
title: workspacePath ? t('title.heroChip') : t('title.heroLauncher.noWorkspace'),
|
|
972
|
+
label: count !== null && count > 0 ? t('label.withCount', { count: count }) : t('label'),
|
|
973
|
+
onClick: function () {
|
|
974
|
+
if (open) {
|
|
975
|
+
patch({ open: false });
|
|
976
|
+
} else {
|
|
977
|
+
openForWorkspace(workspacePath, 'extras');
|
|
978
|
+
}
|
|
635
979
|
},
|
|
636
|
-
},
|
|
637
|
-
t('label'),
|
|
980
|
+
}),
|
|
638
981
|
);
|
|
639
982
|
}
|
|
640
983
|
|
|
@@ -651,19 +994,40 @@ window.__ModuleLoader__.load({
|
|
|
651
994
|
function (props) { return React.createElement(Panel, props); },
|
|
652
995
|
);
|
|
653
996
|
});
|
|
997
|
+
// Session-creation page, shipped seat: the official input dock band (a
|
|
998
|
+
// session-scoped `list` slot rendered directly above the composer
|
|
999
|
+
// card). One unique id, one explicit order, one in-flow row — the
|
|
1000
|
+
// framework arranges this row against every other plugin's dock entry.
|
|
1001
|
+
slots.inject('conversation.input.dock', function () {
|
|
1002
|
+
var release = claimHeroSeat('dock');
|
|
1003
|
+
var dispose = slots.register(
|
|
1004
|
+
{ name: 'conversation.input.dock', id: 'multi-folder', order: DOCK_ORDER, label: function () { return t('label.heroDock'); }, locale: NS },
|
|
1005
|
+
function (props) { return React.createElement(DockChip, props); },
|
|
1006
|
+
);
|
|
1007
|
+
return function () {
|
|
1008
|
+
dispose();
|
|
1009
|
+
release();
|
|
1010
|
+
};
|
|
1011
|
+
});
|
|
654
1012
|
slots.inject('shell.overlay', function () {
|
|
655
1013
|
return slots.register(
|
|
656
1014
|
{ name: 'shell.overlay', id: 'multi-folder-hero', order: 200, label: function () { return t('label.heroLauncher'); }, locale: NS },
|
|
657
1015
|
function (props) { return React.createElement(HeroLauncher, props); },
|
|
658
1016
|
);
|
|
659
1017
|
});
|
|
660
|
-
// Upstream slot
|
|
661
|
-
// `conversation.hero.workspaceExtras`; until then this contributes
|
|
1018
|
+
// Upstream slot: the callback fires only once a DSH build declares
|
|
1019
|
+
// `conversation.hero.workspaceExtras`; until then this contributes
|
|
1020
|
+
// nothing and the dock row above holds the page.
|
|
662
1021
|
slots.inject('conversation.hero.workspaceExtras', function () {
|
|
663
|
-
|
|
1022
|
+
var release = claimHeroSeat('extras');
|
|
1023
|
+
var dispose = slots.register(
|
|
664
1024
|
{ name: 'conversation.hero.workspaceExtras', id: 'multi-folder', order: 30, label: function () { return t('label'); }, locale: NS },
|
|
665
1025
|
function (props) { return React.createElement(HeroChip, props); },
|
|
666
1026
|
);
|
|
1027
|
+
return function () {
|
|
1028
|
+
dispose();
|
|
1029
|
+
release();
|
|
1030
|
+
};
|
|
667
1031
|
});
|
|
668
1032
|
}
|
|
669
1033
|
|
package/lib/index.js
CHANGED
|
@@ -39,6 +39,12 @@
|
|
|
39
39
|
* directly. The `/multi-folder` command and the remote methods share
|
|
40
40
|
* one core so validation, canonicalization, and the config guard are
|
|
41
41
|
* identical on both channels.
|
|
42
|
+
* 7. Failure diagnosis: a `pwsh`/`bash` run that ends in an OS-level
|
|
43
|
+
* `Permission denied` touching a secondary working directory (the ACL
|
|
44
|
+
* runner confines each process tree to ONE writable root, so `git -C
|
|
45
|
+
* <secondary>` launched from the primary workspace cannot write the
|
|
46
|
+
* repo) gets a workdir-fix hint attached as an additional context at
|
|
47
|
+
* the `tools/post-execute` boundary.
|
|
42
48
|
*/
|
|
43
49
|
|
|
44
50
|
import { join } from 'node:path'
|
|
@@ -321,6 +327,100 @@ export function apply(ctx) {
|
|
|
321
327
|
return text
|
|
322
328
|
}
|
|
323
329
|
|
|
330
|
+
// -------------------------------------------- failure diagnosis (post-exec)
|
|
331
|
+
// The Windows ACL runner confines each process tree to exactly ONE writable
|
|
332
|
+
// workspace root (a single `--write-sid` that must match `--workspace`). A
|
|
333
|
+
// command that creates files inside a secondary directory while its cwd is
|
|
334
|
+
// confined elsewhere therefore fails with an OS-level `Permission denied`
|
|
335
|
+
// (git: `fatal: Unable to create '.../.git/index.lock': Permission denied`)
|
|
336
|
+
// and carries NO sandbox marker — the sandbox worked as designed. These
|
|
337
|
+
// helpers surface the workdir fix at the next tool-call boundary instead.
|
|
338
|
+
|
|
339
|
+
const DENIAL_MARK = /permission denied|access(?: is)? denied|eacces|is denied/i
|
|
340
|
+
|
|
341
|
+
const flattenResultText = (result) => {
|
|
342
|
+
const parts = []
|
|
343
|
+
if (result && Array.isArray(result.content)) {
|
|
344
|
+
for (const block of result.content) {
|
|
345
|
+
if (block && block.type === 'text' && typeof block.text === 'string') parts.push(block.text)
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
const value = result && result.value
|
|
349
|
+
if (value && typeof value === 'object') {
|
|
350
|
+
for (const stream of [value.stdout, value.stderr]) {
|
|
351
|
+
if (stream && typeof stream.text === 'string') parts.push(stream.text)
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
// The plugin's own `[sandbox: ...]` markers describe runner-level denials,
|
|
355
|
+
// not path-level EACCES; keep them out of the scan.
|
|
356
|
+
return parts
|
|
357
|
+
.join('\n')
|
|
358
|
+
.split('\n')
|
|
359
|
+
.filter((line) => !line.startsWith('[sandbox:'))
|
|
360
|
+
.join('\n')
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** The configured secondary dir a call's workdir lands in, or null. */
|
|
364
|
+
const workdirHitFor = async (exec) => {
|
|
365
|
+
if (!exec || !exec.agent || !exec.agent.session || !exec.agent.session.header) return null
|
|
366
|
+
const primary = exec.agent.session.header.cwd
|
|
367
|
+
if (typeof primary !== 'string' || primary.length === 0) return null
|
|
368
|
+
const dirs = dirsForSync(primary)
|
|
369
|
+
if (dirs === null) return null
|
|
370
|
+
const args = exec.arguments || {}
|
|
371
|
+
const rawWorkdir = typeof args.workdir === 'string' ? args.workdir : null
|
|
372
|
+
const joined =
|
|
373
|
+
rawWorkdir === null
|
|
374
|
+
? String(primary)
|
|
375
|
+
: isAbsolute(rawWorkdir)
|
|
376
|
+
? rawWorkdir
|
|
377
|
+
: String(primary).replace(/[\\/]+$/, '') + '/' + rawWorkdir
|
|
378
|
+
const target = await fs.resolve(joined, { cwd: primary })
|
|
379
|
+
const abs = fs.processPath(target)
|
|
380
|
+
return longestRootFirst(dirs).find((d) => pathInside(abs, d)) ?? null
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* A user-visible diagnostic for a shell run that ended in an OS-level
|
|
385
|
+
* permission denial touching a secondary working directory. Returns the
|
|
386
|
+
* hint text or undefined. Never throws — a hint failure must never touch
|
|
387
|
+
* the tool pipeline.
|
|
388
|
+
*/
|
|
389
|
+
const permissionHint = async (exec, result) => {
|
|
390
|
+
try {
|
|
391
|
+
if (!exec || (exec.name !== 'pwsh' && exec.name !== 'bash')) return undefined
|
|
392
|
+
if (!exec.agent || !exec.agent.session || !exec.agent.session.header) return undefined
|
|
393
|
+
const exitCode =
|
|
394
|
+
result && result.value && typeof result.value.exitCode === 'number' ? result.value.exitCode : null
|
|
395
|
+
if (exitCode !== null && exitCode === 0) return undefined
|
|
396
|
+
if (!DENIAL_MARK.test(flattenResultText(result))) return undefined
|
|
397
|
+
const primary = exec.agent.session.header.cwd
|
|
398
|
+
if (typeof primary !== 'string' || primary.length === 0) return undefined
|
|
399
|
+
const dirs = dirsForSync(primary)
|
|
400
|
+
if (dirs === null) return undefined
|
|
401
|
+
const reRooted = await workdirHitFor(exec)
|
|
402
|
+
if (reRooted !== null) {
|
|
403
|
+
return (
|
|
404
|
+
'This command ran confined to the secondary working directory "' +
|
|
405
|
+
reRooted +
|
|
406
|
+
'", so writes OUTSIDE that directory (for example to the primary workspace or another secondary directory) were denied at the OS level. ' +
|
|
407
|
+
"Split the work into per-directory commands and set each command's `workdir` to the directory it writes into."
|
|
408
|
+
)
|
|
409
|
+
}
|
|
410
|
+
const cmdNorm = wsKey(String((exec.arguments || {}).command || ''))
|
|
411
|
+
const referenced = longestRootFirst(dirs).find((d) => cmdNorm.includes(wsKey(d)))
|
|
412
|
+
if (referenced === undefined) return undefined
|
|
413
|
+
return (
|
|
414
|
+
'This command ran with its cwd confined to the primary workspace, so creating files inside the secondary working directory "' +
|
|
415
|
+
referenced +
|
|
416
|
+
'" was denied at the OS level — each command can write inside only ONE root. ' +
|
|
417
|
+
'Re-run it with `workdir` set to that directory; for git, run the command from inside the repository instead of using `git -C` from the primary workspace.'
|
|
418
|
+
)
|
|
419
|
+
} catch {
|
|
420
|
+
return undefined
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
324
424
|
// ------------------------------------------------------ prompt injection
|
|
325
425
|
systemPrompt.section({
|
|
326
426
|
name: SECTION_NAME,
|
|
@@ -336,8 +436,11 @@ export function apply(ctx) {
|
|
|
336
436
|
return (
|
|
337
437
|
'Secondary working directories are available in this session (dsh-multi-folder plugin):\n' +
|
|
338
438
|
dirs.map((d) => '- ' + d).join('\n') +
|
|
339
|
-
'\nYou have the SAME read/write/edit and command-execution permissions on these directories as on the primary workspace under the current sandbox mode
|
|
439
|
+
'\nYou have the SAME read/write/edit and command-execution permissions on these directories as on the primary workspace under the current sandbox mode, ' +
|
|
440
|
+
'but each command can write inside only ONE root — the directory its workdir resolves to. ' +
|
|
441
|
+
'A command whose cwd stays the primary workspace CANNOT create files inside a secondary directory. ' +
|
|
340
442
|
'For shell tools, pass `workdir` pointing inside one of these directories — foreground and background (`run_in_background`) runs alike. ' +
|
|
443
|
+
'File-creating commands, git included, MUST set `workdir` to the secondary directory: do not run `git -C <secondary>` or `cd <secondary>` inside a command launched from the primary workspace. ' +
|
|
341
444
|
'Reads from these directories work without `workdir`. The primary workspace remains the default working directory.'
|
|
342
445
|
)
|
|
343
446
|
},
|
|
@@ -358,17 +461,21 @@ export function apply(ctx) {
|
|
|
358
461
|
// --------------------------------------- notification (tool-call boundary)
|
|
359
462
|
ctx.on('tools/post-execute', async (exec, result, next) => {
|
|
360
463
|
const decision = await next()
|
|
361
|
-
const
|
|
362
|
-
|
|
363
|
-
|
|
464
|
+
const extras = []
|
|
465
|
+
const notice = takeNotice(exec.agent)
|
|
466
|
+
if (notice !== undefined) extras.push(notice)
|
|
467
|
+
const hint = await permissionHint(exec, result)
|
|
468
|
+
if (hint !== undefined) extras.push(hint)
|
|
469
|
+
if (extras.length === 0) return decision
|
|
470
|
+
const msgs = extras.map((text) => noticeMessage(text))
|
|
364
471
|
if (decision.kind === 'block') {
|
|
365
472
|
return {
|
|
366
473
|
kind: 'block',
|
|
367
474
|
feedback: decision.feedback,
|
|
368
|
-
additionalContexts: [
|
|
475
|
+
additionalContexts: [...msgs, ...(decision.additionalContexts || [])],
|
|
369
476
|
}
|
|
370
477
|
}
|
|
371
|
-
return { ...decision, additionalContexts: [
|
|
478
|
+
return { ...decision, additionalContexts: [...msgs, ...(decision.additionalContexts || [])] }
|
|
372
479
|
})
|
|
373
480
|
|
|
374
481
|
// ------------------------------------------------- tool-pipeline intercept
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-multi-folder",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
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",
|