dsh-win-multi-bash 0.3.0 → 0.4.0

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/lib/client.js ADDED
@@ -0,0 +1,558 @@
1
+ /**
2
+ * dsh-win-multi-bash — browser half.
3
+ *
4
+ * The two tool rows carry their own configuration (`enableRunInBackground` plus
5
+ * the backend partition: paths, sandbox stance, probe timeout, sandbox
6
+ * hardening, and the executor's command limits). This module gives each row a
7
+ * **Configure** page on the Web sidebar's Plugins page, through the
8
+ * `plugins.row.config` slot keyed by `<package name>#<row id>`, so the options
9
+ * are edited in the panel instead of by hand in `cordis.patch.yml`.
10
+ *
11
+ * It also labels the bundle itself: the plugin has a **core row** (the package
12
+ * row, `win-mb-plugin`) that carries this browser half and owns the shared shell
13
+ * prompt section, and the panel cannot mark a third-party row read-only — dsh's
14
+ * plugin manager locks only its own protected modules, its own row, and rows the
15
+ * profile patch cannot address. So the core row is named where a user reads it
16
+ * instead: a badge beside the bundle's title and a note under the row list, both
17
+ * through `plugins.detail.badge` / `plugins.detail.section`.
18
+ *
19
+ * Persistence is the platform's own: every knob is declared `.volatile()` in the
20
+ * host half, which is exactly what makes it addressable by the settings service
21
+ * (`ctx.configForms` here, `settings` on the host) and writable through the
22
+ * profile's Cordis patch. This half owns no storage and no HTTP route.
23
+ *
24
+ * The form is staged, not live: a control writes nothing until **Save**, a save
25
+ * is fenced by the revision its drafts were read at, and a save the Host refuses
26
+ * keeps its drafts. See `dsh-settings` for the projection and the mutation
27
+ * contract, and `dsh-client-ui-plugin-manager` for the slots.
28
+ *
29
+ * @module dsh-win-multi-bash/client
30
+ */
31
+ window.__ModuleLoader__.load({
32
+ id: "dsh-win-multi-bash",
33
+ factory: (require) => {
34
+ var module = { exports: {} };
35
+ var exports = module.exports;
36
+
37
+ const React = require("react");
38
+ const primitives = require("@deepseek-ai/dsh-client-ui-primitives");
39
+ const h = React.createElement;
40
+
41
+ //#region locales
42
+ /** Dictionary namespace owned by this plugin. */
43
+ const NS = "settings.win-mb-bash";
44
+ /** The package this browser half belongs to; every detail contribution filters on it. */
45
+ const PACKAGE_NAME = "dsh-win-multi-bash";
46
+ /** The core row's id, as `cordis.patch.yml` declares it. */
47
+ const CORE_ROW_ID = "win-mb-plugin";
48
+ /** English copy. */
49
+ const en = {
50
+ summary: "Configure the Git Bash and WSL tool rows: paths, sandbox stance, and command limits.",
51
+ coreBadge: "core row",
52
+ coreNote: "This plugin is three rows: its core row and one row per tool (git_bash, wsl_bash). Configure each tool on its own row. Keep the core row enabled — it serves this page and owns the shared shell prompt section; with it off the tools still run and carry that guidance in their own descriptions, but the Configure controls on this page disappear.",
53
+ unavailable: "This row is not loaded, so it cannot be configured right now.",
54
+ readOnly: "This deployment stores settings read-only.",
55
+ saveFailed: "The deployment did not accept these values; they were left for you to correct.",
56
+ save: "Save",
57
+ saving: "Saving…",
58
+ overridden: "Overridden",
59
+ reset: "Reset to default",
60
+ invalidNumber: "Enter a number, or leave blank to use the default.",
61
+ "option.auto": "auto",
62
+ "option.none": "none",
63
+ "option.bwrap": "bwrap",
64
+ "enableRunInBackground.label": "Background jobs",
65
+ "enableRunInBackground.hint": "Whether this tool accepts run_in_background. The tool schema carries this argument, so it follows the row's next load.",
66
+ "cwd.label": "Default working directory",
67
+ "cwd.hint": "Used when a call passes no workdir; empty means the session workspace.",
68
+ "timeoutMs.label": "Default command timeout (ms)",
69
+ "timeoutMs.hint": "How long one command may run before it is terminated. A background job ignores it: it runs until it finishes or is cancelled.",
70
+ "maxTimeoutMs.label": "Maximum timeout a call may request (ms)",
71
+ "maxTimeoutMs.hint": "A call asking for more is clamped to this. It never applies to a background job.",
72
+ "maxOutputBytes.label": "Output cap per stream (bytes)",
73
+ "maxOutputBytes.hint": "Output beyond this spills to a temporary file instead of being lost.",
74
+ "maxSpillBytes.label": "Spill file cap (bytes)",
75
+ "maxSpillBytes.hint": "How much of that spilled output is retained.",
76
+ "graceMs.label": "Grace before a forced kill (ms)",
77
+ "graceMs.hint": "How long a terminated command may still flush before it is killed outright.",
78
+ "bashPath.label": "Git Bash executable",
79
+ "bashPath.hint": "Pin an explicit bash.exe. Empty probes Git for Windows instead (PATH, well-known installs, then the registry).",
80
+ "wslPath.label": "WSL launcher",
81
+ "wslPath.hint": "Pin an explicit wsl.exe. Empty uses the standard Windows location.",
82
+ "wslDistro.label": "Distribution",
83
+ "wslDistro.hint": "Pin a distribution by name. Empty uses the first one WSL lists.",
84
+ "sandbox.label": "Sandbox stance",
85
+ "sandbox.hint": "auto probes and degrades honestly; none never confines; bwrap (WSL only) refuses to run unconfined.",
86
+ "probeTimeoutMs.label": "Sandbox probe timeout (ms)",
87
+ "probeTimeoutMs.hint": "How long the confinement probe may take before it is treated as a failure.",
88
+ "requireSandbox.label": "Refuse unconfined runs when the probe fails",
89
+ "requireSandbox.hint": "With it on, read-only and workspace-write calls are refused instead of degrading; danger-full-access still runs.",
90
+ };
91
+ /** Simplified Chinese copy. */
92
+ const zh = {
93
+ summary: "配置 Git Bash 与 WSL 两个工具行:路径、沙箱立场与命令限制。",
94
+ coreBadge: "核心行",
95
+ coreNote: "本插件共三行:核心行 + 每个工具一行(git_bash、wsl_bash)。工具各自在自己的行上配置。请保持核心行开启——本页由它提供,共有的那段 shell 提示词也归它所有;关掉后工具照旧可用(共有指导回落到各自的描述里),但本页的配置入口会消失。",
96
+ unavailable: "该行当前未加载,暂时无法配置。",
97
+ readOnly: "本部署的设置为只读。",
98
+ saveFailed: "本部署没有接受这些值,已保留供你修改。",
99
+ save: "保存",
100
+ saving: "保存中…",
101
+ overridden: "已覆盖",
102
+ reset: "恢复默认",
103
+ invalidNumber: "请填数字;留空表示使用默认值。",
104
+ "option.auto": "自动",
105
+ "option.none": "不限制",
106
+ "option.bwrap": "bwrap",
107
+ "enableRunInBackground.label": "后台任务",
108
+ "enableRunInBackground.hint": "该工具是否接受 run_in_background;该参数属于工具 schema,需该行下次加载后生效。",
109
+ "cwd.label": "默认工作目录",
110
+ "cwd.hint": "调用未传 workdir 时使用;留空表示会话工作区。",
111
+ "timeoutMs.label": "默认命令超时(毫秒)",
112
+ "timeoutMs.hint": "单条命令允许运行多久,超时即终止。后台任务不受此限制:它一直跑到结束或被取消。",
113
+ "maxTimeoutMs.label": "单次调用可请求的最大超时(毫秒)",
114
+ "maxTimeoutMs.hint": "调用请求更大值时会被压到该上限;该上限对后台任务不生效。",
115
+ "maxOutputBytes.label": "单流输出上限(字节)",
116
+ "maxOutputBytes.hint": "超出部分转存到临时文件,而不是被丢弃。",
117
+ "maxSpillBytes.label": "转存文件上限(字节)",
118
+ "maxSpillBytes.hint": "转存输出保留多少。",
119
+ "graceMs.label": "强制终止前的宽限(毫秒)",
120
+ "graceMs.hint": "命令被终止后仍可冲刷输出的时间。",
121
+ "bashPath.label": "Git Bash 可执行文件",
122
+ "bashPath.hint": "钉定显式 bash.exe;留空则自动探测 Git for Windows(PATH、常见安装位置、注册表)。",
123
+ "wslPath.label": "WSL 启动器",
124
+ "wslPath.hint": "钉定显式 wsl.exe;留空使用 Windows 标准位置。",
125
+ "wslDistro.label": "发行版",
126
+ "wslDistro.hint": "按名钉定发行版;留空使用 WSL 列出的第一个。",
127
+ "sandbox.label": "沙箱立场",
128
+ "sandbox.hint": "auto 探测并如实降级;none 从不限制;bwrap(仅 WSL)拒绝无沙箱运行。",
129
+ "probeTimeoutMs.label": "沙箱探测超时(毫秒)",
130
+ "probeTimeoutMs.hint": "探测限制能力最多耗时多久,超时按失败处理。",
131
+ "requireSandbox.label": "探测失败时拒绝无沙箱运行",
132
+ "requireSandbox.hint": "开启后 read-only / workspace-write 直接拒绝而非降级;danger-full-access 仍放行。",
133
+ };
134
+ //#endregion
135
+
136
+ //#region fields
137
+ /**
138
+ * One editable knob. `path` addresses it inside the row's config, `kind`
139
+ * picks the control, and `key` names its copy in the dictionary
140
+ * (`<key>.label` / `<key>.hint`). Every path here is `.volatile()` in the
141
+ * host half — the settings service refuses anything else, and the host
142
+ * audit pins that list.
143
+ */
144
+ const field = (path, kind, options) => ({ key: path.join("."), path, kind, options });
145
+ /** The knobs every backend shares: the executor's own command limits. */
146
+ const limitFields = (partition) => [
147
+ field([partition, "cwd"], "text"),
148
+ field([partition, "timeoutMs"], "number"),
149
+ field([partition, "maxTimeoutMs"], "number"),
150
+ field([partition, "maxOutputBytes"], "number"),
151
+ field([partition, "maxSpillBytes"], "number"),
152
+ field([partition, "graceMs"], "number"),
153
+ ];
154
+ /** What the two tool rows expose, in form order. */
155
+ const ROWS = [
156
+ {
157
+ rowId: "win-mb-tool-git",
158
+ fields: [
159
+ field(["enableRunInBackground"], "boolean"),
160
+ ...limitFields("gitBash"),
161
+ field(["gitBash", "bashPath"], "text"),
162
+ field(["gitBash", "sandbox"], "enum", ["auto", "none"]),
163
+ field(["gitBash", "probeTimeoutMs"], "number"),
164
+ field(["gitBash", "requireSandbox"], "boolean"),
165
+ ],
166
+ },
167
+ {
168
+ rowId: "win-mb-tool-wsl",
169
+ fields: [
170
+ field(["enableRunInBackground"], "boolean"),
171
+ ...limitFields("wslBash"),
172
+ field(["wslBash", "wslPath"], "text"),
173
+ field(["wslBash", "wslDistro"], "text"),
174
+ field(["wslBash", "sandbox"], "enum", ["auto", "none", "bwrap"]),
175
+ field(["wslBash", "probeTimeoutMs"], "number"),
176
+ field(["wslBash", "requireSandbox"], "boolean"),
177
+ ],
178
+ },
179
+ ];
180
+ /** The slot key of one row's page: `<package name>#<row id>`. */
181
+ const rowKey = (rowId) => `dsh-win-multi-bash#${rowId}`;
182
+ //#endregion
183
+
184
+ //#region form model
185
+ /** Read a nested path out of a settings layer, `undefined` when absent. */
186
+ function at(layer, path) {
187
+ let node = layer;
188
+ for (const key of path) {
189
+ if (node === null || typeof node !== "object") return undefined;
190
+ node = node[key];
191
+ }
192
+ return node;
193
+ }
194
+ /** Whether a settings layer carries this path at all (what marks a field overridden). */
195
+ function has(layer, path) {
196
+ let node = layer;
197
+ for (const key of path) {
198
+ if (node === null || typeof node !== "object" || !Object.hasOwn(node, key)) return false;
199
+ node = node[key];
200
+ }
201
+ return true;
202
+ }
203
+ /**
204
+ * Turn one staged draft into the value a write carries.
205
+ * @returns the value, or `undefined` for a draft no control accepts.
206
+ */
207
+ function coerce(kind, draft) {
208
+ if (kind === "number") {
209
+ if (typeof draft === "number") return Number.isFinite(draft) ? draft : undefined;
210
+ const text = String(draft).trim();
211
+ if (text.length === 0) return undefined;
212
+ const value = Number(text);
213
+ return Number.isFinite(value) ? value : undefined;
214
+ }
215
+ if (kind === "boolean") return draft === true;
216
+ return String(draft);
217
+ }
218
+ /**
219
+ * The ordered writes one save sends: a clear for every reset and a set for
220
+ * every staged value. A draft no control accepts contributes no write —
221
+ * the form still blocks the save, rather than dropping the edit.
222
+ * @param fields - the row's knobs.
223
+ * @param staged - field key → `{ op: 'set', draft }` or `{ op: 'unset' }`.
224
+ * @returns the path ops `mutate` takes.
225
+ */
226
+ function planOps(fields, staged) {
227
+ const ops = [];
228
+ for (const knob of fields) {
229
+ const entry = staged.get(knob.key);
230
+ if (entry === undefined) continue;
231
+ if (entry.op === "unset") {
232
+ ops.push({ op: "unset", path: knob.path });
233
+ continue;
234
+ }
235
+ const value = coerce(knob.kind, entry.draft);
236
+ if (value === undefined) continue;
237
+ ops.push({ op: "set", path: knob.path, value });
238
+ }
239
+ return ops;
240
+ }
241
+ /** A minimal snapshot store: one cached snapshot, replaced on publish. */
242
+ function storeOf(compute) {
243
+ let snapshot = compute();
244
+ const listeners = new Set();
245
+ return {
246
+ getSnapshot: () => snapshot,
247
+ subscribe: (listener) => {
248
+ listeners.add(listener);
249
+ return () => listeners.delete(listener);
250
+ },
251
+ publish: () => {
252
+ snapshot = compute();
253
+ for (const listener of [...listeners]) listener();
254
+ },
255
+ };
256
+ }
257
+ /**
258
+ * One row's staged form over its settings namespace. Drafts live here, not
259
+ * in React state, so the card's component stays a pure function of the
260
+ * injected store (the shape every shipped settings page uses).
261
+ */
262
+ class RowConfigForm {
263
+ /** @param scope - the settings form the host serves for this row id. */
264
+ constructor(scope, row) {
265
+ this.scope = scope;
266
+ this.row = row;
267
+ this.staged = new Map();
268
+ this.saving = false;
269
+ this.failed = false;
270
+ this.store = storeOf(() => this.project());
271
+ this.unsubscribe = scope.subscribe(() => this.store.publish());
272
+ }
273
+ /** Everything the card renders, recomputed as a whole on every change. */
274
+ project() {
275
+ const snapshot = this.scope.getSnapshot();
276
+ const fields = this.row.fields.map((knob) => {
277
+ const staged = this.staged.get(knob.key);
278
+ const stored = at(snapshot.value, knob.path);
279
+ const inherited = at(snapshot.base, knob.path);
280
+ const shown = staged === undefined ? stored : staged.op === "unset" ? inherited : staged.draft;
281
+ const text = shown === undefined || shown === null ? "" : String(shown);
282
+ return {
283
+ key: knob.key,
284
+ kind: knob.kind,
285
+ path: knob.path,
286
+ options: knob.options,
287
+ text,
288
+ value: shown,
289
+ checked: shown === true,
290
+ // A staged draft answers for itself, so the badge previews the save.
291
+ overridden: staged === undefined ? has(snapshot.user, knob.path) : staged.op === "set",
292
+ invalid: knob.kind === "number" && text.trim().length > 0 && !Number.isFinite(Number(text.trim())),
293
+ };
294
+ });
295
+ return {
296
+ available: snapshot.status === "ready",
297
+ writable: snapshot.writable === true,
298
+ dirty: this.staged.size > 0,
299
+ invalid: fields.some((entry) => entry.invalid),
300
+ saving: this.saving,
301
+ failed: this.failed,
302
+ fields,
303
+ };
304
+ }
305
+ /** Stage one control's draft; an emptied text control stages a clear. */
306
+ edit(key, draft) {
307
+ const knob = this.row.fields.find((entry) => entry.key === key);
308
+ if (knob === undefined) return;
309
+ const empty = (knob.kind === "text" || knob.kind === "number") && String(draft).length === 0;
310
+ this.staged.set(key, empty ? { op: "unset" } : { op: "set", draft });
311
+ this.failed = false;
312
+ this.store.publish();
313
+ }
314
+ /** Stage a clear, so saving lets the field re-inherit the composition layer. */
315
+ resetField(key) {
316
+ this.staged.set(key, { op: "unset" });
317
+ this.failed = false;
318
+ this.store.publish();
319
+ }
320
+ /** Drop every staged draft. */
321
+ discard() {
322
+ this.staged.clear();
323
+ this.failed = false;
324
+ this.store.publish();
325
+ }
326
+ /** Write every staged draft in one revision-fenced mutation. */
327
+ async save() {
328
+ const snapshot = this.scope.getSnapshot();
329
+ const ops = planOps(this.row.fields, this.staged);
330
+ if (snapshot.writable !== true || ops.length === 0) return;
331
+ this.saving = true;
332
+ this.failed = false;
333
+ this.store.publish();
334
+ let accepted = false;
335
+ try {
336
+ accepted = await this.scope.mutate(ops, snapshot.revision);
337
+ } catch {
338
+ accepted = false;
339
+ }
340
+ this.saving = false;
341
+ if (accepted) this.staged.clear();
342
+ this.failed = !accepted;
343
+ this.store.publish();
344
+ }
345
+ /** The face the slot registration injects: the store and the edit actions. */
346
+ inject() {
347
+ return {
348
+ hooks: { rowForm: this.store },
349
+ edit: (key, draft) => this.edit(key, draft),
350
+ resetField: (key) => this.resetField(key),
351
+ discard: () => this.discard(),
352
+ save: () => this.save(),
353
+ };
354
+ }
355
+ dispose() {
356
+ this.unsubscribe();
357
+ }
358
+ }
359
+ //#endregion
360
+
361
+ //#region card
362
+ /** One knob's control: a staged value field, a checkbox, or a segmented choice. */
363
+ function fieldControl(knob, disabled, props, t) {
364
+ const shared = {
365
+ id: `win-mb-${knob.key.replace(/\./g, "-")}`,
366
+ label: t(`${knob.key.split(".").pop()}.label`),
367
+ hint: t(`${knob.key.split(".").pop()}.hint`),
368
+ overridden: knob.overridden,
369
+ overriddenLabel: t("overridden"),
370
+ resetLabel: t("reset"),
371
+ invalidLabel: t("invalidNumber"),
372
+ disabled,
373
+ };
374
+ if (knob.kind === "text" || knob.kind === "number")
375
+ return h(primitives.SettingsValueField, {
376
+ ...shared,
377
+ key: knob.key,
378
+ text: knob.text,
379
+ invalid: knob.invalid,
380
+ numeric: knob.kind === "number",
381
+ onEdit: (text) => props.edit(knob.key, text),
382
+ onReset: () => props.resetField(knob.key),
383
+ });
384
+ if (knob.kind === "boolean")
385
+ return h(
386
+ "div",
387
+ { key: knob.key, className: "dshwmb-row" },
388
+ h(primitives.Checkbox, {
389
+ label: shared.label,
390
+ checked: knob.checked,
391
+ disabled,
392
+ onChange: (next) => props.edit(knob.key, next),
393
+ }),
394
+ h("p", { className: "dshwmb-hint" }, shared.hint),
395
+ knob.overridden
396
+ ? h(primitives.Button, { variant: "ghost", size: "sm", type: "button", disabled, onClick: () => props.resetField(knob.key) }, shared.resetLabel)
397
+ : null,
398
+ );
399
+ return h(
400
+ "div",
401
+ { key: knob.key, className: "dshwmb-row" },
402
+ h("p", { className: "dshwmb-label" }, shared.label),
403
+ h(primitives.SegmentedControl, {
404
+ id: shared.id,
405
+ value: String(knob.value ?? ""),
406
+ label: shared.label,
407
+ disabled,
408
+ options: knob.options.map((option) => ({ value: option, label: t(`option.${option}`) })),
409
+ onChange: (next) => props.edit(knob.key, next),
410
+ }),
411
+ h("p", { className: "dshwmb-hint" }, shared.hint),
412
+ knob.overridden
413
+ ? h(primitives.Button, { variant: "ghost", size: "sm", type: "button", disabled, onClick: () => props.resetField(knob.key) }, shared.resetLabel)
414
+ : null,
415
+ );
416
+ }
417
+ /**
418
+ * One row's configuration page as the Plugins page renders it. No React
419
+ * state: the store and the actions arrive through the slot's inject, and
420
+ * the page drops every draft when it unmounts.
421
+ */
422
+ function RowConfigCard(props) {
423
+ const t = props.t;
424
+ if (props.view === "summary") return t("summary");
425
+ const state = props.useRowForm((snapshot) => snapshot);
426
+ const disabled = state.writable !== true;
427
+ return h(
428
+ primitives.SettingsForm,
429
+ {
430
+ labels: {
431
+ unavailable: t("unavailable"),
432
+ readOnly: t("readOnly"),
433
+ saveFailed: t("saveFailed"),
434
+ save: t("save"),
435
+ saving: t("saving"),
436
+ },
437
+ state,
438
+ onSave: props.save,
439
+ onDiscard: props.discard,
440
+ },
441
+ ...state.fields.map((knob) => fieldControl(knob, disabled, props, t)),
442
+ );
443
+ }
444
+ //#endregion
445
+
446
+ //#region detail page
447
+ /** The page's localizer, or the English copy when the slot hands none over. */
448
+ function textOf(props) {
449
+ return typeof props?.t === "function" ? props.t : (key) => en[key] ?? key;
450
+ }
451
+
452
+ /**
453
+ * The detail pages this bundle owns: the bundle's own page, and the core
454
+ * row's page. Every other subject (another package, or one of the tool
455
+ * rows) renders nothing, which is what the slots expect of a contribution
456
+ * that has nothing to say about the subject.
457
+ * @param subject - the page subject the slot renders with.
458
+ * @returns the owned page's kind, or `undefined` for a foreign subject.
459
+ */
460
+ function ownedPage(subject) {
461
+ if (subject === null || typeof subject !== "object") return undefined;
462
+ if (subject.kind === "bundle" && subject.pkg?.name === PACKAGE_NAME) return "bundle";
463
+ if (subject.kind === "row" && subject.pkg?.name === PACKAGE_NAME && subject.row?.rowId === CORE_ROW_ID) return "core-row";
464
+ return undefined;
465
+ }
466
+
467
+ /**
468
+ * Tags the bundle's title with its core row. dsh cannot make a third-party
469
+ * row read-only, so the row that must stay enabled is named where a user
470
+ * actually reads it.
471
+ */
472
+ function CoreBadge(props) {
473
+ const t = textOf(props);
474
+ if (ownedPage(props?.subject) === undefined) return null;
475
+ return h("span", { className: "dshwmb-badge" }, t("coreBadge"));
476
+ }
477
+
478
+ /** Says which row is which, and what switching the core one off costs. */
479
+ function CoreNote(props) {
480
+ const t = textOf(props);
481
+ if (ownedPage(props?.subject) === undefined) return null;
482
+ return h("section", { className: "dshwmb-note" }, h("p", { className: "dshwmb-hint" }, t("coreNote")));
483
+ }
484
+ //#endregion
485
+
486
+ //#region plugin
487
+ /** Required services (cordis fiber inject). */
488
+ const inject = ["slots", "locale", "configForms"];
489
+ /** This plugin's own layout, injected once per document. */
490
+ const CSS = [
491
+ ".dshwmb-row{margin:0 0 14px}",
492
+ ".dshwmb-label{margin:0 0 6px;font-size:13px;font-weight:500}",
493
+ ".dshwmb-hint{margin:6px 0 0;font-size:12px;opacity:.65;line-height:1.5}",
494
+ ".dshwmb-badge{display:inline-block;margin-left:8px;padding:1px 7px;border-radius:9px;border:1px solid currentColor;font-size:11px;font-weight:500;opacity:.75;vertical-align:middle}",
495
+ ".dshwmb-note{margin:14px 0 0}",
496
+ ].join("");
497
+ /** Install the stylesheet once, guarded for a host that serves this twice. */
498
+ function installStyles() {
499
+ if (typeof document === "undefined" || document.querySelector("style[data-plugin='dsh-win-multi-bash']") !== null) return;
500
+ const tag = document.createElement("style");
501
+ tag.dataset.plugin = "dsh-win-multi-bash";
502
+ tag.textContent = CSS;
503
+ document.head.appendChild(tag);
504
+ }
505
+ /**
506
+ * Give each tool row its Configure page on the Plugins page, while the host
507
+ * serves that row's settings namespace.
508
+ * @param ctx - the browser plugin context.
509
+ */
510
+ function apply(ctx) {
511
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), "dsh-win-multi-bash: dictionaries");
512
+ installStyles();
513
+ for (const row of ROWS) {
514
+ const form = new RowConfigForm(ctx.configForms.get(row.rowId), row);
515
+ ctx.effect(() => () => form.dispose(), `dsh-win-multi-bash: ${row.rowId} form`);
516
+ ctx.effect(
517
+ () =>
518
+ ctx.configForms.whileServed([row.rowId], () =>
519
+ ctx.slots.inject("plugins.row.config", () =>
520
+ ctx.slots.register(
521
+ {
522
+ name: "plugins.row.config",
523
+ key: rowKey(row.rowId),
524
+ locale: NS,
525
+ inject: () => form.inject(),
526
+ },
527
+ RowConfigCard,
528
+ ),
529
+ ),
530
+ ),
531
+ `dsh-win-multi-bash: ${row.rowId} page`,
532
+ );
533
+ }
534
+ // The bundle's own page and the core row's page are the same subject to a
535
+ // reader: label the row that has to stay on, and say what its switch costs.
536
+ for (const [slotName, component] of [
537
+ ["plugins.detail.badge", CoreBadge],
538
+ ["plugins.detail.section", CoreNote],
539
+ ]) {
540
+ ctx.effect(
541
+ () =>
542
+ ctx.slots.inject(slotName, () =>
543
+ ctx.slots.register({ name: slotName, id: `${PACKAGE_NAME}:core`, order: 100, locale: NS }, component),
544
+ ),
545
+ `dsh-win-multi-bash: ${slotName}`,
546
+ );
547
+ }
548
+ }
549
+ //#endregion
550
+
551
+ exports.NS = NS;
552
+ exports.apply = apply;
553
+ exports.inject = inject;
554
+ /** Pure helpers the audit exercises without a renderer. */
555
+ exports.__internals = { ROWS, rowKey, at, has, coerce, planOps, RowConfigForm, RowConfigCard, CoreBadge, CoreNote, ownedPage, CORE_ROW_ID, PACKAGE_NAME, en, zh };
556
+ return module.exports;
557
+ },
558
+ });
package/lib/index.js CHANGED
@@ -11,13 +11,58 @@
11
11
  // (types/backend.js) instead of sharing the ctx.shell
12
12
  // seat: dsh 0.1.7 removed ShellExecRequest.shell, the
13
13
  // routing field the old shell-select design used.
14
+ // lib/tool-bash/shell-prompt.js
15
+ // — the shared prompt section's single owner row.
14
16
  // lib/vendor/ — helpers.js (bash-sandbox classification, including the
15
17
  // anchored runner-failure rule the base runtime does
16
18
  // not export) and bwrap-profiles.js (bwrap rules/args).
19
+ // lib/client.js — the browser half: the Plugins page's configuration
20
+ // form for the two tool rows.
17
21
  //
18
22
  // Only published @deepseek-ai base packages are imported (dsh-shell,
19
23
  // dsh-sandbox, dsh-bash-local, dsh-tools, dsh-llm, ...), so the plugin runs
20
24
  // on any standard deployment — no additional runtime packages required.
21
25
  //
22
26
  // The composition wiring lives in cordis.patch.yml.
23
- export {}
27
+ import {
28
+ SHELL_FAMILY_SECTION_NAME,
29
+ shellFamilySectionOrder,
30
+ shellFamilySectionTextFor,
31
+ } from './tool-bash/types/shell-family.js';
32
+
33
+ /** Row name; the Loader also uses it for logging and for the prompt-section owner. */
34
+ export const name = 'dsh-win-multi-bash';
35
+
36
+ /**
37
+ * `systemPrompt` is the registry the family section belongs to; `tools` is read
38
+ * at every assembly so the section can describe exactly the shell tools this
39
+ * composition mounts. Without a tools registry there are no such tools, and the
40
+ * row correctly never applies.
41
+ */
42
+ export const inject = ['systemPrompt', 'tools'];
43
+
44
+ /**
45
+ * The package row — the plugin's core row.
46
+ *
47
+ * It exists because the Web client's module system discovers a browser half by
48
+ * resolving a Loader row's **bare package specifier** to its `package.json` and
49
+ * reading `dsh.client` there (client-modules' `locatePkgJson` →
50
+ * `exactPackageSpecifier`, which answers `undefined` for a subpath). The tool
51
+ * rows this bundle inserts are subpaths, so none of them can carry that
52
+ * declaration; without this row `lib/client.js` is never served and the tool
53
+ * rows show no Configure page.
54
+ *
55
+ * It is also the owner of the one prompt section the bash family shares
56
+ * (`tool:win-mb-bash`), which is why it is not an empty `apply` any more: the
57
+ * section used to live in a row of its own, and merging the two removes a
58
+ * switch that could only ever make the composition worse. The section's text is
59
+ * resolved per assembly, so a composition that switched every tool row off
60
+ * mounts no family tool and this section then contributes no text at all.
61
+ */
62
+ export function apply(ctx) {
63
+ ctx.systemPrompt.section({
64
+ name: SHELL_FAMILY_SECTION_NAME,
65
+ order: shellFamilySectionOrder(ctx.systemPrompt),
66
+ text: ({ scope }) => shellFamilySectionTextFor(ctx, scope),
67
+ });
68
+ }