dsh-multi-folder 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,6 +6,9 @@
6
6
 
7
7
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
8
8
  [![Node.js >= 20](https://img.shields.io/badge/Node.js-%3E%3D20-brightgreen)](https://nodejs.org/)
9
+ [![npm version](https://img.shields.io/npm/v/dsh-multi-folder)](https://www.npmjs.com/package/dsh-multi-folder)
10
+ [![GitHub issues](https://img.shields.io/github/issues/AngelosZou/dsh-multi-folder)](https://github.com/AngelosZou/dsh-multi-folder/issues)
11
+ [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)
9
12
 
10
13
  A [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) plugin bundle that gives one project (workspace) a set of **secondary working directories**:
11
14
 
@@ -13,7 +16,7 @@ A [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) plugin bun
13
16
  - 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
17
  - The directory list is **injected into the system prompt** and re-rendered per session assembly.
15
18
  - Configuration changes notify the agent through a **non-interrupting message queue** — delivered at the next message boundary (user send or tool-call end), and **only when the directory set actually changed**.
16
- - Configurable **before the session starts**: the session-creation page (new-session screen) offers a 「多工作目录」 entry that reads and edits the same per-workspace configuration through a **sessionless remote API** (`multiFolder/*` endpoints) — no session id required.
19
+ - Configurable **before the session starts**: the session-creation page (new-session screen) offers a Multi-folder entry that reads and edits the same per-workspace configuration through a **sessionless remote API** (`multiFolder/*` endpoints) — no session id required.
17
20
  - **No new tools.** Everything is a framework-level change (tool-pipeline interception) plus a UI-level change (a session-scoped header entry).
18
21
 
19
22
  ## Requirements
@@ -33,7 +36,7 @@ Then **restart the DSH backend** (host composition loads at process start) and *
33
36
 
34
37
  ## Usage
35
38
 
36
- A **「多工作目录 / Multi-folder」** button appears in the session header, and a second entry appears on the **session-creation page** (fixed launcher in the bottom-right corner while the new-session screen is shown; an inline chip beside the workspace picker once the upstream `conversation.hero.workspaceExtras` slot is available). The panel lets you:
39
+ A Multi-folder button appears in the session header, and a second entry appears on the **session-creation page** (fixed launcher in the bottom-right corner while the new-session screen is shown; an inline chip beside the workspace picker once the upstream `conversation.hero.workspaceExtras` slot is available). The panel lets you:
37
40
 
38
41
  | Action | Behavior |
39
42
  | ------ | -------- |
package/README.zh.md CHANGED
@@ -6,6 +6,9 @@
6
6
 
7
7
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
8
8
  [![Node.js >= 20](https://img.shields.io/badge/Node.js-%3E%3D20-brightgreen)](https://nodejs.org/)
9
+ [![npm version](https://img.shields.io/npm/v/dsh-multi-folder)](https://www.npmjs.com/package/dsh-multi-folder)
10
+ [![GitHub issues](https://img.shields.io/github/issues/AngelosZou/dsh-multi-folder)](https://github.com/AngelosZou/dsh-multi-folder/issues)
11
+ [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)
9
12
 
10
13
  一个 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 插件 bundle,为一个 Project(工作区)提供一组**副工作目录**:
11
14
 
@@ -13,7 +16,7 @@
13
16
  - 在 **Workspace Write** 模式下,Agent 对配置的副工作目录拥有与主工作目录**同等的读取、写入、编辑与命令执行权限**——实现方式是重定向会话自身的沙箱策略根,因此每种模式语义都自然保持(`read-only` 依旧拒绝、`workspace-write` 放行、`danger-full-access` 放行);
14
17
  - 目录列表**注入系统提示词**,每次组装按会话求值;
15
18
  - 配置变更通过**不打断的消息队列**通知 Agent——在下一次消息边界(用户发送或工具调用结束)送达,且**仅在目录集合实际变化时**发送;
16
- - **会话开始前即可配置**:会话创建页(新会话界面)提供「多工作目录」入口,通过**无会话远程 API**(`multiFolder/*` 端点)读写同一份 per-workspace 配置——无需 session id;
19
+ - **会话开始前即可配置**:会话创建页(新会话界面)提供「多工作目录」入口(英文界面显示 "Multi-folder"),通过**无会话远程 API**(`multiFolder/*` 端点)读写同一份 per-workspace 配置——无需 session id;
17
20
  - **不新增任何工具**:改动全部位于框架级(工具流水线拦截)与 UI 级(会话级头部入口)。
18
21
 
19
22
  ## 环境要求
@@ -33,7 +36,7 @@ dsh plugin --profile web add dsh-multi-folder
33
36
 
34
37
  ## 使用
35
38
 
36
- 会话头部出现「多工作目录」按钮;**会话创建页**也有入口(新会话界面右下角的浮动按钮;当上游 DSH 声明 `conversation.hero.workspaceExtras` 插槽后,还会在工作区选择器旁显示内联 chip)。打开面板即可:
39
+ 会话头部出现「多工作目录」按钮(英文界面显示 "Multi-folder");**会话创建页**也有入口(新会话界面右下角的浮动按钮;当上游 DSH 声明 `conversation.hero.workspaceExtras` 插槽后,还会在工作区选择器旁显示内联 chip)。打开面板即可:
37
40
 
38
41
  | 操作 | 行为 |
39
42
  | ---- | ---- |
package/docs/design.md CHANGED
@@ -35,13 +35,19 @@ A listener on the `tools/execute` around-dispatch waterfall handles `write`, `ed
35
35
  4. If the canonical path is inside a configured secondary directory, execute the
36
36
  operation directly with `{ ...standingPolicy, workspaceRoot: <secondary dir> }`:
37
37
  - `write`/`edit` → `fs.writeText` / `fs.editText`;
38
- - `pwsh`/`bash` → `shell.resolve({ command, workdir, dshEnv, sandboxPolicy })` +
39
- `shell.run`, with the canonical workdir so the confinement root and the process
40
- cwd agree exactly.
38
+ - `pwsh`/`bash`, foreground → `shell.resolve({ command, workdir, dshEnv,
39
+ sandboxPolicy })` + `shell.run`, with the canonical workdir so the confinement
40
+ root and the process cwd agree exactly;
41
+ - `pwsh`/`bash`, background (`run_in_background: true`) → the same re-rooted
42
+ request registered through the generic jobs runtime (`ctx.jobs`) exactly like
43
+ the shipped shell tools (`kind` = tool name, `owner` = calling agent, streamed
44
+ reads shaped for `job_output` with sandbox markers, terminal outcome in the
45
+ `completed`/`killed` vocabulary). A caller-aborted call falls through to the
46
+ default pipeline, which raises the canonical abort error.
41
47
  The result carries the same canonical value/content shapes as the shipped tools, so
42
48
  downstream presentation keeps working.
43
49
  5. Anything else — unknown tools, paths outside every secondary directory, escalation
44
- arguments (`sandbox_permissions`), `run_in_background`, missing optional services,
50
+ arguments (`sandbox_permissions`), missing optional services (`shell`, `jobs`),
45
51
  or any error — falls through to `next()` and the default pipeline.
46
52
 
47
53
  **Why mode parity is free:** the mode field of the standing policy is never touched.
@@ -145,14 +151,28 @@ window.__ModuleLoader__.load({
145
151
  })
146
152
  ```
147
153
 
148
- - `inject: ['remote', 'remote.commands', 'slots', 'workspaces', 'connection', 'sessions']`; the package's
154
+ - `inject: ['remote', 'remote.commands', 'slots', 'workspaces', 'connection', 'sessions', 'locale']`; the package's
149
155
  `dsh.client.inject` lists the packages providing them
150
156
  (`@deepseek-ai/dsh-api-gateway`, `@deepseek-ai/dsh-api-remotes`,
151
- `@deepseek-ai/dsh-client-connection`, `@deepseek-ai/dsh-client-runtime`).
157
+ `@deepseek-ai/dsh-client-connection`, `@deepseek-ai/dsh-client-locale`,
158
+ `@deepseek-ai/dsh-client-runtime`).
152
159
  - UI registrations: `conversation.session.header.actions` (session-scoped button),
153
160
  `shell.overlay` panel, `shell.overlay` hero launcher (root-scoped, fixed
154
161
  position), and `conversation.hero.workspaceExtras` (upstream slot; see
155
162
  below). One module-level store is shared by all of them.
163
+ - **Localization (zh / en).** All client copy goes through
164
+ `@deepseek-ai/dsh-client-locale` (always composed by the standard web
165
+ profile). The bundle registers a `multi-folder` dictionary namespace with
166
+ `ctx.effect(() => locale.register(NS, { zh, en }))` — the locale service
167
+ enforces bilingual balance, and the effect ties the dictionaries to the
168
+ plugin fiber. Every slot registration declares `locale: 'multi-folder'`,
169
+ so the renderer synthesizes the `t` seat on component props and
170
+ re-renders mounted outlets on locale switch; list-entry `label`s are
171
+ thunks (`() => t('label')`) that `resolveSlotLabel` re-evaluates per read,
172
+ so registration-time text follows the active locale without
173
+ re-registering. The active locale is the browser language or the user's
174
+ Language preference in Settings; the English UI reads "Multi-folder", the
175
+ Chinese UI keeps 「多工作目录」.
156
176
  - Host communication, two channels:
157
177
  - session mode: `ctx.remote.commands.execute(sessionId, line)`. The return
158
178
  value is the RPC envelope `{ ok, value }` where `value` is the
@@ -192,8 +212,17 @@ window.__ModuleLoader__.load({
192
212
  observation domain.
193
213
  - `presentationMeta` is not computed on the short-circuit path; tool cards fall back to
194
214
  their default presentation.
195
- - `run_in_background` and `sandbox_permissions` escalation on `pwsh`/`bash` calls in
196
- secondary directories are passed through to the default pipeline.
215
+ - `sandbox_permissions` escalation on `pwsh`/`bash` calls in secondary directories is
216
+ passed through to the default pipeline, which re-roots the escalated run at the
217
+ PRIMARY workspace — escalation never widens a secondary root. (Background runs are
218
+ NOT passed through: they register with `ctx.jobs` under the same re-rooted policy
219
+ as foreground runs.)
220
+ - The interceptor registers a background `pwsh`/`bash` job whenever `ctx.jobs` is
221
+ available; it cannot read the shipped shell tools' per-tool
222
+ `enableRunInBackground: false` config, so a deployment that disables background
223
+ execution would still serve secondary-dir background jobs. Deployments that
224
+ disable background execution should also disable this plugin's shell interception
225
+ or accept that exception.
197
226
  - The `/multi-folder` command lifecycle rows (`command/run`, `command/done`) are
198
227
  visible in the conversation UI by framework design; they are log-only and never
199
228
  reach the model. Workspace-mode (session-creation page) operations avoid them
@@ -78,17 +78,23 @@ above is only authorized once it is listed):
78
78
 
79
79
  ## 4. Plugin side (already implemented)
80
80
 
81
- `lib/client.js` registers into the slot the moment it is declared:
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: 「多工作目录」):
82
85
 
83
86
  ```js
84
87
  slots.inject('conversation.hero.workspaceExtras', function () {
85
88
  return slots.register(
86
- { name: 'conversation.hero.workspaceExtras', id: 'multi-folder', order: 30, label: '多工作目录' },
89
+ { name: 'conversation.hero.workspaceExtras', id: 'multi-folder', order: 30, label: () => t('label'), locale: NS },
87
90
  function (props) { return React.createElement(HeroChip, props); },
88
91
  );
89
92
  });
90
93
  ```
91
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
+
92
98
  `HeroChip` prefers the owner-supplied `props.workspacePath` and falls back to
93
99
  the store-derived hero workspace. It opens the same overlay panel in workspace
94
100
  mode, which reads and writes the configuration through the sessionless
@@ -115,6 +121,7 @@ bundle; the same change there is three touchpoints:
115
121
  - Unit: the plugin's `test/smoke-client.mjs` registers and drives `HeroChip`
116
122
  with a mock owner share (`workspacePath`), asserting the panel opens in
117
123
  workspace mode and reuses the per-workspace cache.
118
- - Manual: with the patched build, the 「多工作目录」 chip renders in the hero
119
- workspace row between the preset chip and the composer; clicking it lists
120
- the workspace's secondary directories before any message is sent.
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,9 +1,11 @@
1
1
  /**
2
2
  * dsh-multi-folder — client half (hand-written factory bundle, no build step).
3
3
  *
4
- * Session-scoped UI: a "多工作目录" button in the conversation session header
5
- * (`conversation.session.header.actions`, scope: session) that opens a panel in
6
- * `shell.overlay` listing the project's secondary working directories.
4
+ * Session-scoped UI: a localized "Multi-folder" (「多工作目录」) button in the
5
+ * conversation session header (`conversation.session.header.actions`, scope:
6
+ * session) that opens a panel in `shell.overlay` listing the project's
7
+ * secondary working directories. All copy goes through the framework locale
8
+ * service (`@deepseek-ai/dsh-client-locale`) — see the "i18n" section below.
7
9
  * Mutations go through the Host `/multi-folder` command via the Remote BFF
8
10
  * (`ctx.remote.commands.execute(sessionId, line)`); the Host answers with a
9
11
  * human-readable result carrying a `[MF:JSON]` line the panel parses for
@@ -62,9 +64,60 @@ window.__ModuleLoader__.load({
62
64
  try { return JSON.parse(m[1]); } catch (_) { return null; }
63
65
  }
64
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
+
65
118
  // ------------------------------------------------------------- plugin
66
119
  var name = 'dsh-multi-folder';
67
- var inject = ['remote', 'remote.commands', 'slots', 'workspaces', 'connection', 'sessions'];
120
+ var inject = ['remote', 'remote.commands', 'slots', 'workspaces', 'connection', 'sessions', 'locale'];
68
121
 
69
122
  function apply(ctx) {
70
123
  var slots = ctx.slots;
@@ -72,6 +125,22 @@ window.__ModuleLoader__.load({
72
125
  var workspaces = ctx.workspaces;
73
126
  var connection = ctx.connection;
74
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);
75
144
 
76
145
  // -------------------------------------------------- sessionless RPC
77
146
  /** Call one `multiFolder/*` endpoint over the shared /api channel.
@@ -232,6 +301,7 @@ window.__ModuleLoader__.load({
232
301
  function HeaderButton(props) {
233
302
  var store = useStore();
234
303
  var sessionId = props.sessionId;
304
+ var t = props.t;
235
305
  var open = store.open && store.sessionId === sessionId;
236
306
  // Session switch: keep the panel in sync with the session this
237
307
  // header belongs to. On a changed sessionId (or first mount) with
@@ -250,7 +320,7 @@ window.__ModuleLoader__.load({
250
320
  'button',
251
321
  {
252
322
  type: 'button',
253
- title: '多工作目录(副工作目录)',
323
+ title: t('title.header'),
254
324
  onClick: function () {
255
325
  var current = getSnapshot();
256
326
  var isOpen = current.open && current.sessionId === sessionId;
@@ -273,14 +343,15 @@ window.__ModuleLoader__.load({
273
343
  cursor: 'pointer',
274
344
  },
275
345
  },
276
- open ? '多工作目录 ▾' : '多工作目录',
346
+ open ? t('label.open') : t('label'),
277
347
  );
278
348
  return btn;
279
349
  }
280
350
 
281
351
  // Overlay panel ------------------------------------------------------
282
- function Panel() {
352
+ function Panel(props) {
283
353
  var store = useStore();
354
+ var t = props.t;
284
355
  if (!store.open || !store.mode) return null;
285
356
  var sessionMode = store.mode === 'session';
286
357
  var usable = sessionMode || !!store.workspace;
@@ -311,7 +382,7 @@ window.__ModuleLoader__.load({
311
382
  'button',
312
383
  {
313
384
  type: 'button',
314
- title: '移除此副工作目录',
385
+ title: t('title.remove'),
315
386
  disabled: !usable,
316
387
  onClick: function () {
317
388
  if (sessionMode) {
@@ -322,7 +393,7 @@ window.__ModuleLoader__.load({
322
393
  },
323
394
  style: { padding: '2px 8px', borderRadius: 6, border: '1px solid transparent', background: 'transparent', color: 'var(--color-danger, #c62828)', cursor: 'pointer' },
324
395
  },
325
- '移除',
396
+ t('panel.remove'),
326
397
  ),
327
398
  );
328
399
  });
@@ -348,12 +419,12 @@ window.__ModuleLoader__.load({
348
419
  React.createElement(
349
420
  'div',
350
421
  { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 } },
351
- React.createElement('div', { style: { fontWeight: 600, fontSize: 14 } }, '多工作目录(副工作目录)'),
422
+ React.createElement('div', { style: { fontWeight: 600, fontSize: 14 } }, t('panel.title')),
352
423
  React.createElement(
353
424
  'button',
354
425
  {
355
426
  type: 'button',
356
- title: '关闭',
427
+ title: t('title.close'),
357
428
  onClick: function () { patch({ open: false }); },
358
429
  style: { padding: '2px 8px', borderRadius: 6, border: '1px solid transparent', background: 'transparent', cursor: 'pointer' },
359
430
  },
@@ -364,18 +435,18 @@ window.__ModuleLoader__.load({
364
435
  ? React.createElement(
365
436
  'div',
366
437
  { style: { marginBottom: 8, color: 'var(--color-text-muted, #6b6b6b)', fontSize: 12, wordBreak: 'break-all' } },
367
- '项目:' + store.workspace,
438
+ t('panel.project', { path: store.workspace }),
368
439
  )
369
440
  : (!sessionMode
370
441
  ? React.createElement(
371
442
  'div',
372
443
  { style: { marginBottom: 8, color: 'var(--color-text-muted, #6b6b6b)' } },
373
- '尚未选择工作区。请先在上方选择项目,再配置多工作目录。',
444
+ t('panel.noWorkspaceHint'),
374
445
  )
375
446
  : null),
376
447
  usable
377
- ? (rows.length > 0 ? rows : React.createElement('div', { style: { marginBottom: 8, color: 'var(--color-text-muted, #6b6b6b)' } }, '尚未配置副工作目录。'))
378
- : React.createElement('div', { style: { marginBottom: 8, color: 'var(--color-text-muted, #6b6b6b)' } }, '选择工作区后可在此添加副工作目录。'),
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')),
379
450
  store.error
380
451
  ? React.createElement('div', { style: { marginBottom: 8, color: 'var(--color-danger, #c62828)', whiteSpace: 'pre-wrap' } }, String(store.error))
381
452
  : null,
@@ -399,7 +470,7 @@ window.__ModuleLoader__.load({
399
470
  opacity: store.busy || !usable ? 0.6 : 1,
400
471
  },
401
472
  },
402
- store.busy ? '处理中…' : '+ 添加目录',
473
+ store.busy ? t('panel.adding') : t('panel.add'),
403
474
  ),
404
475
  React.createElement(
405
476
  'button',
@@ -423,7 +494,7 @@ window.__ModuleLoader__.load({
423
494
  opacity: store.busy || !usable ? 0.6 : 1,
424
495
  },
425
496
  },
426
- '刷新',
497
+ t('panel.refresh'),
427
498
  ),
428
499
  ),
429
500
  React.createElement(
@@ -436,7 +507,7 @@ window.__ModuleLoader__.load({
436
507
  color: 'var(--color-text-muted, #6b6b6b)',
437
508
  },
438
509
  },
439
- 'Agent 的主工作目录不变;在 Workspace Write 模式下,Agent 对上述目录拥有与主工作目录同等的读写与命令执行权限。配置变更会在下一条消息或工具调用结束时通知 Agent。',
510
+ t('panel.footnote'),
440
511
  ),
441
512
  );
442
513
  }
@@ -477,8 +548,9 @@ window.__ModuleLoader__.load({
477
548
 
478
549
  /** Fixed-position hero launcher (B2): visible only while the
479
550
  * conversation root reports the hero phase. */
480
- function HeroLauncher() {
551
+ function HeroLauncher(props) {
481
552
  var store = useStore();
553
+ var t = props.t;
482
554
  React.useEffect(
483
555
  function () {
484
556
  var disposers = [];
@@ -511,7 +583,7 @@ window.__ModuleLoader__.load({
511
583
  'button',
512
584
  {
513
585
  type: 'button',
514
- title: store.heroWorkspace ? '配置此项目的副工作目录(多工作目录)' : '请先选择工作区,再配置多工作目录',
586
+ title: store.heroWorkspace ? t('title.heroLauncher.hasWorkspace') : t('title.heroLauncher.noWorkspace'),
515
587
  onClick: function () { openForWorkspace(store.heroWorkspace); },
516
588
  style: {
517
589
  position: 'fixed',
@@ -532,7 +604,7 @@ window.__ModuleLoader__.load({
532
604
  opacity: store.heroWorkspace ? 1 : 0.7,
533
605
  },
534
606
  },
535
- '多工作目录',
607
+ t('label'),
536
608
  );
537
609
  }
538
610
 
@@ -541,12 +613,13 @@ window.__ModuleLoader__.load({
541
613
  * declares the slot; a no-op registration until then. */
542
614
  function HeroChip(props) {
543
615
  var store = useStore();
616
+ var t = props.t;
544
617
  var workspacePath = props && props.workspacePath ? props.workspacePath : store.heroWorkspace;
545
618
  return React.createElement(
546
619
  'button',
547
620
  {
548
621
  type: 'button',
549
- title: '配置此项目的副工作目录(多工作目录)',
622
+ title: t('title.heroChip'),
550
623
  onClick: function () { openForWorkspace(workspacePath); },
551
624
  style: {
552
625
  display: 'inline-flex',
@@ -561,34 +634,34 @@ window.__ModuleLoader__.load({
561
634
  cursor: 'pointer',
562
635
  },
563
636
  },
564
- '多工作目录',
637
+ t('label'),
565
638
  );
566
639
  }
567
640
 
568
641
  // Registrations ------------------------------------------------------
569
642
  slots.inject('conversation.session.header.actions', function () {
570
643
  return slots.register(
571
- { 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 },
572
645
  function (props) { return React.createElement(HeaderButton, props); },
573
646
  );
574
647
  });
575
648
  slots.inject('shell.overlay', function () {
576
649
  return slots.register(
577
- { name: 'shell.overlay', id: 'multi-folder', order: 100, label: '多工作目录' },
578
- 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); },
579
652
  );
580
653
  });
581
654
  slots.inject('shell.overlay', function () {
582
655
  return slots.register(
583
- { name: 'shell.overlay', id: 'multi-folder-hero', order: 200, label: '多工作目录(新会话)' },
584
- function () { return React.createElement(HeroLauncher); },
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); },
585
658
  );
586
659
  });
587
660
  // Upstream slot (B1): the callback fires only once a DSH build declares
588
661
  // `conversation.hero.workspaceExtras`; until then this contributes nothing.
589
662
  slots.inject('conversation.hero.workspaceExtras', function () {
590
663
  return slots.register(
591
- { name: 'conversation.hero.workspaceExtras', id: 'multi-folder', order: 30, label: '多工作目录' },
664
+ { name: 'conversation.hero.workspaceExtras', id: 'multi-folder', order: 30, label: function () { return t('label'); }, locale: NS },
592
665
  function (props) { return React.createElement(HeroChip, props); },
593
666
  );
594
667
  });
package/lib/index.js CHANGED
@@ -16,7 +16,11 @@
16
16
  * here with the session's standing sandbox policy re-rooted to that
17
17
  * directory — identical semantics to the primary workspace in every mode
18
18
  * (read-only denies, workspace-write allows, danger-full-access allows).
19
- * Reads (read/glob/grep) are unfenced and already work.
19
+ * Background shell runs (`run_in_background: true`) register with the
20
+ * generic jobs runtime (`ctx.jobs`) under the same re-rooted policy,
21
+ * mirroring the shipped pwsh/bash tools so `job_output` / `job_kill` and
22
+ * finish notices keep working. Reads (read/glob/grep) are unfenced and
23
+ * already work.
20
24
  * 3. Prompt injection: one ordered system-prompt section rendered per
21
25
  * assembly from the configured directories of the assembling session.
22
26
  * 4. Non-interrupting change notification: configuration changes made via
@@ -333,7 +337,8 @@ export function apply(ctx) {
333
337
  'Secondary working directories are available in this session (dsh-multi-folder plugin):\n' +
334
338
  dirs.map((d) => '- ' + d).join('\n') +
335
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. ' +
336
- 'Use absolute paths inside them (or pass `workdir` to shell tools). The primary workspace remains the default working directory.'
340
+ 'For shell tools, pass `workdir` pointing inside one of these directories foreground and background (`run_in_background`) runs alike. ' +
341
+ 'Reads from these directories work without `workdir`. The primary workspace remains the default working directory.'
337
342
  )
338
343
  },
339
344
  })
@@ -383,6 +388,49 @@ export function apply(ctx) {
383
388
  return text
384
389
  }
385
390
 
391
+ /** Terminal outcome for a background process, in the jobs-registry vocabulary. */
392
+ const processOutcome = (proc) => {
393
+ if (proc.status === 'killed') {
394
+ return {
395
+ status: 'killed',
396
+ detail: proc.signal !== null && proc.signal !== undefined ? 'signal: ' + proc.signal : 'killed before exit',
397
+ }
398
+ }
399
+ return {
400
+ status: 'completed',
401
+ detail: 'exit code: ' + (proc.exitCode === undefined || proc.exitCode === null ? 0 : proc.exitCode),
402
+ }
403
+ }
404
+
405
+ /**
406
+ * One consuming background read, shaped for `job_output`: the raw delta plus
407
+ * loss/spill notices and sandbox markers, mirroring the shipped pwsh/bash
408
+ * tools' background rendering. No escalation hint is appended — escalation
409
+ * calls stay on the default pipeline, which re-roots at the primary
410
+ * workspace, so this job can never receive a wider policy.
411
+ */
412
+ const renderProcessRead = (read, sandbox) => {
413
+ const notices = []
414
+ if (read.lossy) {
415
+ const paths = [read.stdoutSpillPath, read.stderrSpillPath].filter((path) => path !== undefined)
416
+ notices.push(
417
+ '[some output was dropped from memory; full output: ' +
418
+ (paths.length > 0 ? paths.join(', ') : '(unavailable)') +
419
+ ']',
420
+ )
421
+ }
422
+ if (sandbox && sandbox.runnerFailed) {
423
+ notices.push(
424
+ '[sandbox: the sandbox runner itself failed under ' + sandbox.mode +
425
+ ' mode — the command did not run; this is a sandbox problem, not a command failure]',
426
+ )
427
+ } else if (sandbox && sandbox.denied) {
428
+ notices.push('[sandbox: file access denied under ' + sandbox.mode + ' mode]')
429
+ }
430
+ if (notices.length === 0) return read.delta
431
+ return read.delta + (read.delta.length > 0 && !read.delta.endsWith('\n') ? '\n' : '') + notices.join('\n')
432
+ }
433
+
386
434
  ctx.on('tools/execute', async (exec, next) => {
387
435
  if (exec.agent && exec.agent.session && exec.agent.session.header) {
388
436
  hydrate(exec.agent.session.header.cwd)
@@ -459,7 +507,6 @@ export function apply(ctx) {
459
507
  if (exec.name === 'pwsh' || exec.name === 'bash') {
460
508
  const shell = ctx.get('shell')
461
509
  if (shell === undefined) return next()
462
- if (args && args.run_in_background === true) return next()
463
510
  const dirs = dirsForSync(primary)
464
511
  if (dirs === null) return next()
465
512
  const rawWorkdir = args && typeof args.workdir === 'string' ? args.workdir : null
@@ -483,6 +530,39 @@ export function apply(ctx) {
483
530
  ...(shellEnv !== undefined ? { dshEnv: shellEnv.collect(exec) } : {}),
484
531
  sandboxPolicy: policy,
485
532
  }
533
+
534
+ // Background runs get the SAME re-rooted policy as foreground runs.
535
+ // They register with the generic jobs runtime (`ctx.jobs`) exactly
536
+ // like the shipped pwsh/bash tools do, so `job_output` / `job_kill`
537
+ // and the finish notice keep working for the intercepted job. A
538
+ // background process outlives the tool call, so no caller signal is
539
+ // forwarded; `shell.start` ignores `timeoutMs` by design.
540
+ if (args && args.run_in_background === true) {
541
+ // An aborted call belongs to the default pipeline, which raises the
542
+ // canonical abort error before anything starts.
543
+ if (exec.signal && exec.signal.aborted) return next()
544
+ const jobs = ctx.get('jobs')
545
+ if (jobs === undefined) return next()
546
+ const jobId = jobs.start({
547
+ kind: exec.name,
548
+ label: String(args.command),
549
+ ...(exec.agent ? { owner: exec.agent } : {}),
550
+ run: () => {
551
+ const proc = shell.start(shell.resolve(request))
552
+ return {
553
+ cancel: () => void proc.kill(),
554
+ done: proc.done.then(() => processOutcome(proc)),
555
+ readOutput: () => renderProcessRead(proc.readOutput(), proc.sandbox),
556
+ }
557
+ },
558
+ })
559
+ return {
560
+ isError: false,
561
+ value: { kind: 'background', jobId },
562
+ content: [{ type: 'text', text: 'started background job ' + jobId }],
563
+ }
564
+ }
565
+
486
566
  const result = await shell.run(shell.resolve({ ...request, signal: exec.signal }))
487
567
  if (result.aborted) {
488
568
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-multi-folder",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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",
@@ -48,6 +48,7 @@
48
48
  "@deepseek-ai/dsh-api-gateway",
49
49
  "@deepseek-ai/dsh-api-remotes",
50
50
  "@deepseek-ai/dsh-client-connection",
51
+ "@deepseek-ai/dsh-client-locale",
51
52
  "@deepseek-ai/dsh-client-runtime"
52
53
  ]
53
54
  }