@banana-peeljj12/dsh-trellis 0.3.4 → 0.3.6

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
@@ -62,6 +62,25 @@ dsh plugin --profile web add @banana-peeljj12/dsh-trellis@latest
62
62
 
63
63
  安装完成后**重启 DSH 服务**。
64
64
 
65
+ > **桌面版(DeepSeek Harness App)请用自带的 carrier CLI。** 普通 `dsh` CLI 会以
66
+ > `error: profile "desktop" is managed exclusively by the Electron application` 拒绝操作
67
+ > `desktop` profile(该名字被硬编码保护)。请改用桌面版自带的 CLI:
68
+ >
69
+ > ```sh
70
+ > & "F:\DeepSeek Harness\resources\runtime\cli\bin\dsh.cmd" plugin --profile desktop add link:F:\dsh-plugins\dsh-trellis
71
+ > ```
72
+ >
73
+ > 装的若是本地开发目录,用 `link:<仓库绝对路径>`;装完成后**完整退出并重启桌面版**
74
+ > (含托盘图标,确认任务管理器中不再有 `DeepSeek Harness.exe`)才会生效。
75
+
76
+ > **版本兼容性**:插件声明的 `@deepseek-ai/dsh-*` peer 范围必须覆盖当前 DSH 运行时版本。
77
+ > DSH 会在安装前置检查(pnpm 运行之前)与 profile 启动时校验该范围,不匹配会直接
78
+ > `installation rejected` 并拒绝加载——这不是代码问题,而是声明问题:
79
+ > 例如运行时为 `0.2.0-rc.2` 时,`^0.1.7-rc.1`(= `>=0.1.7-rc.1 <0.2.0-0`)不覆盖它,
80
+ > 必须提升到 `^0.2.0-rc.2`。升级 DSH 后若插件突然失效,请先核对 peer 范围。
81
+ > 本仓库当前面向 **DSH 0.2.0-rc.2**;审计证据见
82
+ > [`docs/ref/dsh-0.2.0-rc.2-audit.md`](docs/ref/dsh-0.2.0-rc.2-audit.md)。
83
+
65
84
  ### 2. 配置白名单目录(重要)
66
85
 
67
86
  为避免非预期介入,插件默认不会拦截未授权的目录。请在 Web 端或配置文件中添加生效路径:
package/README_EN.md CHANGED
@@ -62,6 +62,28 @@ dsh plugin --profile web add @banana-peeljj12/dsh-trellis@latest
62
62
 
63
63
  After installation, **restart the DSH service**.
64
64
 
65
+ > **Desktop app (DeepSeek Harness App): use the bundled carrier CLI.** The ordinary `dsh`
66
+ > CLI refuses to touch the `desktop` profile with
67
+ > `error: profile "desktop" is managed exclusively by the Electron application` (that name is
68
+ > hard-coded as protected). Use the CLI shipped inside the app instead:
69
+ >
70
+ > ```sh
71
+ > & "F:\DeepSeek Harness\resources\runtime\cli\bin\dsh.cmd" plugin --profile desktop add link:F:\dsh-plugins\dsh-trellis
72
+ > ```
73
+ >
74
+ > For a local development checkout, install it as `link:<absolute repo path>`. Afterwards
75
+ > **fully quit and relaunch the desktop app** (including the tray icon — confirm no
76
+ > `DeepSeek Harness.exe` remains in Task Manager) for the change to take effect.
77
+
78
+ > **Version compatibility**: the `@deepseek-ai/dsh-*` peer ranges a plugin declares must cover
79
+ > the running DSH version. DSH validates those ranges both in the install preflight (before pnpm
80
+ > runs) and at profile startup; a mismatch produces `installation rejected` and the plugin is not
81
+ > loaded. This is a declaration problem, not a code problem: on a `0.2.0-rc.2` runtime,
82
+ > `^0.1.7-rc.1` (= `>=0.1.7-rc.1 <0.2.0-0`) does not cover it and must be raised to
83
+ > `^0.2.0-rc.2`. If the plugin suddenly stops working after a DSH upgrade, check the peer ranges
84
+ > first. This repository currently targets **DSH 0.2.0-rc.2**; see
85
+ > [`docs/ref/dsh-0.2.0-rc.2-audit.md`](docs/ref/dsh-0.2.0-rc.2-audit.md) for the audit evidence.
86
+
65
87
  ### 2. Configure Allowed Directories (Important)
66
88
 
67
89
  By default, the plugin will not intercept any unconfigured workspaces. Add your target workspace directory to the allowlist:
package/lib/client.js CHANGED
@@ -1,21 +1,21 @@
1
1
  /**
2
- * trellis-workflow client half — Web Settings tab for the allowlist.
2
+ * trellis-workflow client half — configuration page on the Plugins list.
3
3
  *
4
- * Contributes a tab to the Plugins settings section (slot
5
- * `settings.plugins.tab`) that reads/writes the same `trellis-workflow`
6
- * settings namespace the Host half registers, so the injection allowlist and
7
- * related config are editable in the Web UI and take effect on the next turn
8
- * without a restart.
4
+ * Contributes the `trellis-workflow` loader entry's configuration to the
5
+ * Plugins page (slot `plugins.row.config`, keyed
6
+ * `<package name>#<row id>`) so the injection allowlist, injectStep,
7
+ * skipKeywords, inline, and enforceReadonlyPlanning fields are editable there
8
+ * and take effect on the next turn without a restart.
9
9
  *
10
10
  * This file is a client bundle in the web shell's module format
11
11
  * (`window.__ModuleLoader__.load({ id, factory })`); it is served under
12
12
  * /plugins by the harness when the package is an enabled Loader entry whose
13
13
  * manifest declares `dsh.client` with `platform: web`.
14
14
  *
15
- * NOTE: the harness only exposes settings namespaces listed in
16
- * `WEB_SETTINGS_NAMESPACES` (dsh-host-apiproxy) to the Web client. Install
17
- * with `--patch-harness` (scripts/install.mjs) so `trellis-workflow` joins
18
- * that allowlist; otherwise the tab renders the "unavailable" fallback below.
15
+ * DSH 0.1.7 settings are Loader-entry driven: the namespace IS the loader entry
16
+ * id (`trellis-workflow`) and the value is the entry's projected Config. The
17
+ * Web client reads and writes it through the shared `configForms` service, and
18
+ * the card registers into the Plugins page's `plugins.row.config` seat.
19
19
  */
20
20
 
21
21
  window.__ModuleLoader__.load({
@@ -26,6 +26,7 @@ window.__ModuleLoader__.load({
26
26
  Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
27
27
 
28
28
  let react = require('react');
29
+ let primitives = require('@deepseek-ai/dsh-client-ui-primitives');
29
30
 
30
31
  /**
31
32
  * Module-level client root context, captured in apply(ctx). Components (slot
@@ -35,38 +36,45 @@ window.__ModuleLoader__.load({
35
36
  */
36
37
  let clientCtx = null;
37
38
 
38
- // #region lib/types/client/trellis-settings-tab.js
39
+ // #region lib/types/client/trellis-shared.js
39
40
 
40
- /** Settings namespace registered by the Host half (see lib/meta.js). */
41
+ /**
42
+ * The bundle patch row id — also the settings entry id this page edits. A
43
+ * row inserted by a bundle patch is loaded under a namespaced entry id
44
+ * (`include:trellis-workflow`) while a directly-declared row keeps the bare
45
+ * id, so consumers match it as a suffix (see `entryIdOf`).
46
+ */
41
47
  const NS = 'trellis-workflow';
42
48
 
43
49
  /** Locale dictionary namespace owned by this client half. */
44
50
  const LOCALE_NS = 'settings.trellisWorkflow';
45
51
 
46
52
  /** Services required by this client half. */
47
- const inject = ['slots', 'locale', 'settingsScope', 'sidebarRight', 'uiWorkspace'];
53
+ const inject = ['slots', 'locale', 'configForms', 'uiWorkspace', 'sidebarRight'];
48
54
 
49
55
  const zh = {
50
- tab: 'Trellis 工作流',
51
- loading: '正在读取配置…',
52
- unavailable:
53
- '当前 harness 未向 Web 暴露 trellis-workflow 命名空间(dsh-host-apiproxy 的 WEB_SETTINGS_NAMESPACES 白名单未包含它)。请用 scripts/install.mjs 的 --patch-harness 补丁后重启 DSH。',
56
+ unavailable: '该插件当前未加载,暂不可配置(宿主未提供 trellis-workflow 条目)。',
57
+ servedNamespaces: '宿主已服务的命名空间:',
58
+ noneServed: '(无)',
54
59
  allowlist: '注入白名单',
55
60
  allowlistHint: '命中这些项目根的会话才会收到工作流面包屑。',
56
- addPlaceholder: '输入项目根路径,如 F:/Projects/FordProject',
57
- add: '添加',
58
- remove: '移除',
59
- empty: '(空)',
60
61
  injectStep: '注入步数(injectStep)',
62
+ injectStepHint: '只在该步注入(1 = 每条用户消息的第一步)。',
61
63
  skipKeywords: '跳过关键词(逗号分隔)',
64
+ skipKeywordsHint: '出现这些词(独立单词,逗号分隔)时,本轮跳过注入。',
62
65
  inline: '按 codex-inline 调度解析阶段',
63
66
  enforceReadonlyPlanning: '规划期只读保护(enforceReadonlyPlanning)',
64
67
  enforceReadonlyPlanningHint:
65
68
  '开启后,命中白名单的项目里:新对话(未建任务)只裁剪 write/edit 与任务写工具(trellis_task_update / trellis_artifact_update / trellis_task_archive / trellis_ui_update),其余工具(含其他插件的工具)保留;规划中的任务只裁剪 write/edit 与创建/跳过/归档类 trellis 工具,其余工具保留;经 trellis_task_skip 跳过任务的会话恢复完整工具。',
66
- saved: '已保存',
67
- writeFailed: '保存失败',
69
+ save: '保存',
70
+ saving: '保存中…',
71
+ saveFailed: '本部署没有接受这些值,已保留供你修改。',
72
+ readOnly: '本部署的设置为只读。',
73
+ overridden: '已覆盖',
74
+ reset: '恢复默认',
75
+ invalidNumber: '请填数字;留空表示使用默认值。',
76
+ desc: 'Trellis 工作流:按会话项目注入阶段面包屑、提供任务阶段工具与看板。',
68
77
  browse: '浏览…',
69
- browseBusy: '选择中…',
70
78
  browseFailed: '调用文件夹选择器失败,请手动输入路径',
71
79
  chipTitle: 'Trellis Task',
72
80
  chipNoTask: '无活动任务',
@@ -128,27 +136,30 @@ window.__ModuleLoader__.load({
128
136
  };
129
137
 
130
138
  const en = {
131
- tab: 'Trellis Workflow',
132
- loading: 'Loading configuration…',
133
139
  unavailable:
134
- 'The current harness does not expose the trellis-workflow namespace to the Web client (it is not in dsh-host-apiproxy WEB_SETTINGS_NAMESPACES). Run scripts/install.mjs --patch-harness and restart DSH.',
140
+ 'This plugin is not loaded, so it cannot be configured right now (the Host does not serve the trellis-workflow entry).',
141
+ servedNamespaces: 'Namespaces the Host serves:',
142
+ noneServed: '(none)',
135
143
  allowlist: 'Injection allowlist',
136
144
  allowlistHint: 'Sessions whose cwd matches these project roots receive the workflow breadcrumb.',
137
- addPlaceholder: 'Project root path, e.g. F:/Projects/FordProject',
138
- add: 'Add',
139
145
  browse: 'Browse…',
140
- browseBusy: 'Picking…',
141
146
  browseFailed: 'Could not open the directory picker — enter the path manually',
142
- remove: 'Remove',
143
- empty: '(empty)',
144
147
  injectStep: 'Inject step (injectStep)',
148
+ injectStepHint: 'Only inject on this step index (1 = the first step of each user message).',
145
149
  skipKeywords: 'Skip keywords (comma-separated)',
150
+ skipKeywordsHint: 'Standalone words (comma-separated) that suppress injection for a turn.',
146
151
  inline: 'Resolve phases as codex-inline dispatch',
147
152
  enforceReadonlyPlanning: 'Read-only planning (enforceReadonlyPlanning)',
148
153
  enforceReadonlyPlanningHint:
149
154
  'When enabled, in allowlisted projects: a fresh conversation (no task yet) trims only write/edit and task-write trellis tools (trellis_task_update / trellis_artifact_update / trellis_task_archive / trellis_ui_update), keeping all other tools (including other plugins\'); a task in the planning phase trims only write/edit and create/skip/archive trellis tools, keeping all other tools; a session that skipped the task via trellis_task_skip regains the full tool surface.',
150
- saved: 'Saved',
151
- writeFailed: 'Save failed',
155
+ save: 'Save',
156
+ saving: 'Saving…',
157
+ saveFailed: 'The deployment did not accept these values; they were left for you to correct.',
158
+ readOnly: 'This deployment stores settings read-only.',
159
+ overridden: 'Overridden',
160
+ reset: 'Reset to default',
161
+ invalidNumber: 'Enter a number, or leave blank to use the default.',
162
+ desc: 'Trellis workflow: injects a per-project phase breadcrumb and provides the task/phase tools and board.',
152
163
  chipTitle: 'Trellis Task',
153
164
  chipNoTask: 'No active task',
154
165
  chipNoSummary: 'No state yet — click to refresh',
@@ -208,217 +219,426 @@ window.__ModuleLoader__.load({
208
219
  selectTaskHint: 'Click a task on the left to view details',
209
220
  };
210
221
 
211
- function TrellisSettingsTab(props) {
212
- const { scope, t } = props;
213
- const [draftPath, setDraftPath] = react.useState('');
214
- const [skipDraft, setSkipDraft] = react.useState('');
215
- const [saved, setSaved] = react.useState(false);
222
+ // #region lib/types/client/trellis-settings-card.js
216
223
 
217
- const snapshot = react.useSyncExternalStore(
218
- (listener) => scope.subscribe(listener),
219
- () => scope.getSnapshot(),
220
- );
221
- const ready = snapshot && snapshot.status === 'ready';
222
- const value = ready ? snapshot.value : undefined;
224
+ /**
225
+ * DSH 0.1.7 settings surface for the `trellis-workflow` loader entry.
226
+ *
227
+ * The old per-plugin settings-scope model is gone: namespaces are loader
228
+ * entry ids, and the Web client reads/writes them through the shared
229
+ * `configForms` service (the same path the official settings cards use).
230
+ * The custom page registers into the Plugins page's `plugins.row.config`
231
+ * seat, keyed `<package name>#<row id>` — the key the plugin list page
232
+ * dispatches for this bundle's `trellis-workflow` row.
233
+ */
223
234
 
224
- react.useEffect(() => {
225
- if (!saved) return undefined;
226
- const timer = setTimeout(() => setSaved(false), 2000);
227
- return () => clearTimeout(timer);
228
- }, [saved]);
235
+ /** The Plugins-page row key this bundle's configuration registers under. */
236
+ const ROW_CONFIG_KEY = '@banana-peeljj12/dsh-trellis#trellis-workflow';
237
+
238
+ /** Minimal button style for the browse control (the form frame owns the rest). */
239
+ const btnStyle = {
240
+ height: 30,
241
+ padding: '0 12px',
242
+ fontSize: 13,
243
+ font: 'inherit',
244
+ color: 'var(--dsw-alias-label-primary)',
245
+ background: 'transparent',
246
+ border: '1px solid var(--dsw-alias-border-l2)',
247
+ borderRadius: 8,
248
+ cursor: 'pointer',
249
+ };
229
250
 
230
- if (snapshot && snapshot.status === 'unavailable') {
231
- return react.createElement('p', { style: { color: 'var(--dsw-alias-state-error-primary)' } }, t('unavailable'));
232
- }
233
- if (!ready) {
234
- return react.createElement('p', { style: { color: 'var(--dsw-alias-label-tertiary)' } }, t('loading'));
235
- }
251
+ /** The label frame the shared settings form renders. */
252
+ function formLabels(t) {
253
+ return {
254
+ unavailable: t('unavailable'),
255
+ readOnly: t('readOnly'),
256
+ saveFailed: t('saveFailed'),
257
+ save: t('save'),
258
+ saving: t('saving'),
259
+ };
260
+ }
236
261
 
237
- const write = (field, next) => {
238
- scope.set(field, next).then(
239
- () => setSaved(true),
240
- () => setSaved(false),
241
- );
262
+ /**
263
+ * A comma-separated list field. The shared form model is text based, so a
264
+ * list stages as one line; an empty draft clears the override.
265
+ * @param {string} field field name inside the entry's config section.
266
+ * @returns {{ field: string, format: (v: unknown) => string, parse: (t: string) => unknown }}
267
+ */
268
+ function settingsListField(field) {
269
+ return {
270
+ field,
271
+ format: (value) => (Array.isArray(value) ? value.join(', ') : ''),
272
+ parse: (text) => {
273
+ const items = String(text)
274
+ .split(',')
275
+ .map((s) => s.trim())
276
+ .filter(Boolean);
277
+ return items.length === 0 ? { kind: 'clear' } : { kind: 'set', value: items };
278
+ },
242
279
  };
280
+ }
243
281
 
244
- const allowlist = Array.isArray(value && value.allowlist) ? value.allowlist : [];
245
- const skipKeywords = Array.isArray(value && value.skipKeywords) ? value.skipKeywords : [];
282
+ /**
283
+ * A boolean field staged as the text `true`/`false`, so a checkbox can ride
284
+ * the same staged-draft and save semantics as every other control.
285
+ * @param {string} field field name inside the entry's config section.
286
+ * @returns {{ field: string, format: (v: unknown) => string, parse: (t: string) => unknown }}
287
+ */
288
+ function settingsBoolField(field) {
289
+ return {
290
+ field,
291
+ format: (value) => (value === true ? 'true' : 'false'),
292
+ parse: (text) => ({ kind: 'set', value: String(text).trim() === 'true' }),
293
+ };
294
+ }
295
+
296
+ /** Parse a staged comma-separated draft back into a list. */
297
+ function listFromText(text) {
298
+ return String(text || '')
299
+ .split(',')
300
+ .map((s) => s.trim())
301
+ .filter(Boolean);
302
+ }
303
+
304
+ /**
305
+ * A minimal snapshot store for the card's injected hook. The shared form
306
+ * model owns its own store, but the card must also render before the entry
307
+ * is resolved, so the controller publishes through this one.
308
+ * @param {object} initial the first snapshot.
309
+ * @returns {{ getSnapshot: () => object, subscribe: (fn: () => void) => (() => void), set: (next: object) => void }}
310
+ */
311
+ function createStore(initial) {
312
+ let snapshot = initial;
313
+ const listeners = new Set();
314
+ return {
315
+ getSnapshot: () => snapshot,
316
+ subscribe(listener) {
317
+ listeners.add(listener);
318
+ return () => listeners.delete(listener);
319
+ },
320
+ set(next) {
321
+ snapshot = next;
322
+ for (const listener of listeners) {
323
+ try {
324
+ listener();
325
+ } catch {
326
+ /* one listener must not break the others */
327
+ }
328
+ }
329
+ },
330
+ };
331
+ }
246
332
 
247
- const addPath = () => {
248
- const p = draftPath.trim();
249
- if (!p) return;
250
- if (!allowlist.includes(p)) write('allowlist', allowlist.concat(p));
251
- setDraftPath('');
333
+ /** The form state before the entry is resolved: nothing to edit yet. */
334
+ function emptyFormState() {
335
+ const empty = { text: '', overridden: false, invalid: false };
336
+ return {
337
+ available: false,
338
+ writable: false,
339
+ dirty: false,
340
+ invalid: false,
341
+ saving: false,
342
+ failed: false,
343
+ served: [],
344
+ allowlist: empty,
345
+ injectStep: empty,
346
+ skipKeywords: empty,
347
+ inline: empty,
348
+ enforceReadonlyPlanning: empty,
252
349
  };
350
+ }
351
+
352
+ /**
353
+ * Resolve this bundle's loader entry id from the served namespaces.
354
+ *
355
+ * A row inserted by a bundle patch is loaded under a namespaced entry id
356
+ * (`include:trellis-workflow`), while the same row declared directly in a
357
+ * profile patch keeps the bare id — so match the row id as a suffix rather
358
+ * than assuming one form.
359
+ * @param {Iterable<string>} served namespaces the Host currently serves.
360
+ * @returns {string | undefined} the entry id, when served.
361
+ */
362
+ function entryIdOf(served) {
363
+ for (const ns of served) {
364
+ if (ns === NS || ns.endsWith(':' + NS)) return ns;
365
+ }
366
+ return undefined;
367
+ }
253
368
 
369
+ /**
370
+ * Stage this entry's config edits over the shared `configForms` form and
371
+ * write them on save (the same controller shape the official cards use).
372
+ *
373
+ * The entry id is resolved lazily from the describe mirror: the Web client
374
+ * can mount before the Host serves the entry, and the id a bundle patch
375
+ * produces is namespaced.
376
+ */
377
+ class TrellisConfigController {
254
378
  /**
255
- * Folder picker browse button: open the Host's native directory picker
256
- * (`ctx.uiWorkspace.pickDirectory()`), normalize the picked path
257
- * (backslashes → forward slashes) and append it to the allowlist
258
- * directly (deduped). Cancel (`null`) is a no-op; failures surface the
259
- * `browseFailed` copy and keep the manual input path untouched.
379
+ * @param {object} configForms the shared `configForms` service.
260
380
  */
261
- const [picking, setPicking] = react.useState(false);
262
- const [pickError, setPickError] = react.useState(false);
263
- react.useEffect(() => {
264
- if (!pickError) return undefined;
265
- const timer = setTimeout(() => setPickError(false), 2600);
266
- return () => clearTimeout(timer);
267
- }, [pickError]);
381
+ constructor(configForms) {
382
+ this.configForms = configForms;
383
+ this.mirror = configForms.describe();
384
+ this.model = null;
385
+ this.entryId = undefined;
386
+ this.store = createStore(emptyFormState());
387
+ this.modelStore = null;
388
+ this.modelOff = null;
389
+ this.mirrorOff = this.mirror.subscribe(() => this.settle());
390
+ this.mirror.ensure();
391
+ this.settle();
392
+ }
393
+
394
+ /**
395
+ * The namespaces the Host currently serves, read from the shared mirror.
396
+ * @returns {string[]} served namespace ids.
397
+ */
398
+ servedNamespaces() {
399
+ try {
400
+ const view = this.mirror.getSnapshot().view;
401
+ const rows = view && view.namespaces ? view.namespaces : [];
402
+ return rows.map((row) => row && row.ns).filter(Boolean);
403
+ } catch {
404
+ return [];
405
+ }
406
+ }
407
+
408
+ /** Build the shared form model once the Host serves this entry. */
409
+ settle() {
410
+ if (this.model) return;
411
+ const entryId = entryIdOf(this.servedNamespaces());
412
+ if (entryId === undefined) return;
413
+ this.entryId = entryId;
414
+ this.model = new primitives.SettingsFormModel(this.configForms.get(entryId), [
415
+ settingsListField('allowlist'),
416
+ primitives.settingsNumberField('injectStep'),
417
+ settingsListField('skipKeywords'),
418
+ settingsBoolField('inline'),
419
+ settingsBoolField('enforceReadonlyPlanning'),
420
+ ]);
421
+ this.modelStore = this.model.bind(() => this.projection());
422
+ this.modelOff = this.modelStore.subscribe(() => this.store.set(this.projection()));
423
+ this.store.set(this.projection());
424
+ }
425
+
426
+ /** Build the snapshot the card renders from. */
427
+ projection() {
428
+ const served = this.servedNamespaces();
429
+ if (!this.model) return { ...emptyFormState(), served };
430
+ return {
431
+ ...this.model.shell(),
432
+ served,
433
+ allowlist: this.model.field('allowlist'),
434
+ injectStep: this.model.field('injectStep'),
435
+ skipKeywords: this.model.field('skipKeywords'),
436
+ inline: this.model.field('inline'),
437
+ enforceReadonlyPlanning: this.model.field('enforceReadonlyPlanning'),
438
+ };
439
+ }
440
+
441
+ /**
442
+ * Build the face the slot registration injects.
443
+ * @returns the form snapshot hook and the staged-form actions.
444
+ */
445
+ inject() {
446
+ const call = (name) => (...args) => {
447
+ if (!this.model) return undefined;
448
+ return this.model.actions()[name](...args);
449
+ };
450
+ return {
451
+ hooks: { trellisConfig: this.store },
452
+ edit: call('edit'),
453
+ resetField: call('resetField'),
454
+ save: call('save'),
455
+ discard: call('discard'),
456
+ };
457
+ }
458
+
459
+ /** Release every subscription. */
460
+ dispose() {
461
+ if (this.mirrorOff) this.mirrorOff();
462
+ if (this.modelOff) this.modelOff();
463
+ if (this.model) this.model.dispose();
464
+ this.mirrorOff = null;
465
+ this.modelOff = null;
466
+ this.model = null;
467
+ }
468
+ }
469
+
470
+ /** One labelled checkbox row for a boolean config field. */
471
+ function BooleanRow(props) {
472
+ const { id, label, hint, checked, disabled, onToggle } = props;
473
+ return react.createElement(
474
+ 'div',
475
+ { style: { margin: '12px 0 2px' } },
476
+ react.createElement(
477
+ 'label',
478
+ { htmlFor: id, style: { display: 'flex', gap: 8, alignItems: 'center', fontSize: 13 } },
479
+ react.createElement('input', {
480
+ id,
481
+ type: 'checkbox',
482
+ checked,
483
+ disabled,
484
+ onChange: (e) => onToggle(e.target.checked),
485
+ }),
486
+ label,
487
+ ),
488
+ hint
489
+ ? react.createElement(
490
+ 'p',
491
+ { style: { margin: '4px 0 0', color: 'var(--dsw-alias-label-secondary)', fontSize: 12 } },
492
+ hint,
493
+ )
494
+ : null,
495
+ );
496
+ }
268
497
 
269
- const browsePath = () => {
270
- if (picking) return;
271
- const workspaces = clientCtx && clientCtx.uiWorkspace;
272
- if (!workspaces || typeof workspaces.pickDirectory !== 'function') {
273
- setPickError(true);
498
+ /**
499
+ * Render this entry's configuration: the one-liner when the page asks for a
500
+ * summary, otherwise the shared form with its staged fields and one save.
501
+ * @param {object} props the view asked for, locale copy, the form snapshot,
502
+ * its actions, and the injected slot face.
503
+ * @returns {unknown} the summary line, or the form.
504
+ */
505
+ function TrellisConfigCard(props) {
506
+ const { t } = props;
507
+ if (props.view === 'summary') return t('desc');
508
+ const state = props.useTrellisConfig((snapshot) => snapshot);
509
+ const disabled = !state.writable;
510
+ const field = (name) => state[name] || { text: '', overridden: false, invalid: false };
511
+
512
+ const onBrowse = () => {
513
+ const ws = clientCtx && clientCtx.uiWorkspace;
514
+ if (!ws || typeof ws.pickDirectory !== 'function') {
515
+ setBrowseError(true);
274
516
  return;
275
517
  }
276
- setPicking(true);
277
- // Reuse the saved-flag effect's timer slot: browse errors auto-clear
278
- // via pickError's own effect above; picking ends on any outcome.
279
- workspaces
280
- .pickDirectory()
518
+ ws.pickDirectory()
281
519
  .then((picked) => {
282
- setPicking(false);
283
- if (picked === null || picked === undefined) return; // user cancelled
520
+ if (picked === null || picked === undefined) return;
284
521
  const normalized = String(picked).trim().replace(/\\/g, '/');
285
- if (!normalized) {
286
- setPickError(true);
287
- return;
288
- }
289
- if (!allowlist.includes(normalized)) write('allowlist', allowlist.concat(normalized));
290
- else setSaved(true); // already present — still confirm the click landed
522
+ if (!normalized) return;
523
+ const current = listFromText(field('allowlist').text);
524
+ if (current.includes(normalized)) return;
525
+ props.edit('allowlist', current.concat(normalized).join(', '));
291
526
  })
292
- .catch(() => {
293
- setPicking(false);
294
- setPickError(true);
295
- });
527
+ .catch(() => setBrowseError(true));
296
528
  };
297
529
 
298
- const rowStyle = { display: 'flex', gap: 8, alignItems: 'center', marginBottom: 6 };
299
- const inputStyle = {
300
- flex: 1,
301
- height: 32,
302
- padding: '0 10px',
303
- fontSize: 13,
304
- color: 'var(--dsw-alias-label-primary)',
305
- background: TB.color.surfaceMuted,
306
- border: '1px solid ' + TB.color.borderStrong,
307
- borderRadius: 8,
308
- font: 'inherit',
309
- };
310
- const btnStyle = {
311
- height: 32,
312
- padding: '0 12px',
313
- fontSize: 13,
314
- font: 'inherit',
315
- color: 'var(--dsw-alias-label-primary)',
316
- background: TB.color.surface,
317
- border: '1px solid var(--dsw-alias-border-l2)',
318
- borderRadius: 8,
319
- cursor: 'pointer',
320
- };
321
- const labelStyle = { fontSize: 13, fontWeight: 600, margin: '14px 0 6px', color: 'var(--dsw-alias-label-primary)' };
530
+ const [browseError, setBrowseError] = react.useState(false);
531
+ react.useEffect(() => {
532
+ if (!browseError) return undefined;
533
+ const timer = setTimeout(() => setBrowseError(false), 2600);
534
+ return () => clearTimeout(timer);
535
+ }, [browseError]);
536
+
537
+ if (!state.available) {
538
+ // The Host does not (yet) serve this entry. Say so, and name what it
539
+ // does serve, so a mis-resolved entry id is diagnosable from the page
540
+ // itself instead of showing up as an unexplained blank.
541
+ const served = Array.isArray(state.served) ? state.served : [];
542
+ return react.createElement(
543
+ 'div',
544
+ { style: { display: 'flex', flexDirection: 'column', gap: 6 } },
545
+ react.createElement(
546
+ 'p',
547
+ { role: 'status', style: { margin: 0, color: 'var(--dsw-alias-label-tertiary)', fontSize: 13 } },
548
+ t('unavailable'),
549
+ ),
550
+ react.createElement(
551
+ 'p',
552
+ {
553
+ style: {
554
+ margin: 0,
555
+ color: 'var(--dsw-alias-label-tertiary)',
556
+ fontSize: 12,
557
+ fontFamily: 'var(--ds-font-family-code)',
558
+ overflowWrap: 'anywhere',
559
+ },
560
+ },
561
+ `${t('servedNamespaces')} ${served.length ? served.join(', ') : t('noneServed')}`,
562
+ ),
563
+ );
564
+ }
322
565
 
323
566
  return react.createElement(
324
- 'div',
325
- { style: { width: '100%', maxWidth: 760, display: 'flex', flexDirection: 'column', gap: 4 } },
326
- react.createElement('p', { style: { margin: 0, color: TB.color.textSecondary, fontSize: 13 } }, t('allowlistHint')),
327
- react.createElement('label', { style: labelStyle }, t('allowlist')),
328
- allowlist.length === 0
329
- ? react.createElement('p', { style: { margin: 0, color: 'var(--dsw-alias-label-tertiary)', fontSize: 13 } }, t('empty'))
330
- : allowlist.map((p) =>
331
- react.createElement(
332
- 'div',
333
- { key: p, style: rowStyle },
334
- react.createElement(
335
- 'code',
336
- { style: { flex: 1, overflowWrap: 'anywhere', fontFamily: 'var(--ds-font-family-code)', fontSize: 12 } },
337
- p,
338
- ),
339
- react.createElement(
340
- 'button',
341
- { type: 'button', style: btnStyle, onClick: () => write('allowlist', allowlist.filter((x) => x !== p)) },
342
- t('remove'),
343
- ),
344
- ),
345
- ),
567
+ primitives.SettingsForm,
568
+ { labels: formLabels(t), state, onSave: props.save, onDiscard: props.discard },
569
+ react.createElement(primitives.SettingsValueField, {
570
+ id: 'trellis-config-allowlist',
571
+ label: t('allowlist'),
572
+ hint: t('allowlistHint'),
573
+ overriddenLabel: t('overridden'),
574
+ resetLabel: t('reset'),
575
+ invalidLabel: t('invalidNumber'),
576
+ disabled,
577
+ ...field('allowlist'),
578
+ onEdit: (text) => props.edit('allowlist', text),
579
+ onReset: () => props.resetField('allowlist'),
580
+ }),
346
581
  react.createElement(
347
582
  'div',
348
- { style: rowStyle },
349
- react.createElement('input', {
350
- style: inputStyle,
351
- placeholder: t('addPlaceholder'),
352
- value: draftPath,
353
- onChange: (e) => setDraftPath(e.target.value),
354
- onKeyDown: (e) => { if (e.key === 'Enter') addPath(); },
355
- }),
583
+ { style: { display: 'flex', gap: 8, alignItems: 'center' } },
356
584
  react.createElement(
357
585
  'button',
358
- {
359
- type: 'button',
360
- style: { ...btnStyle, opacity: picking ? 0.6 : 1, cursor: picking ? 'wait' : 'pointer' },
361
- disabled: picking,
362
- onClick: browsePath,
363
- },
364
- picking ? t('browseBusy') : t('browse'),
586
+ { type: 'button', style: btnStyle, disabled, onClick: onBrowse },
587
+ t('browse'),
365
588
  ),
366
- react.createElement('button', { type: 'button', style: btnStyle, onClick: addPath }, t('add')),
589
+ browseError
590
+ ? react.createElement(
591
+ 'p',
592
+ { style: { margin: 0, color: 'var(--dsw-alias-state-error-primary)', fontSize: 12 }, role: 'alert' },
593
+ t('browseFailed'),
594
+ )
595
+ : null,
367
596
  ),
368
- pickError
369
- ? react.createElement(
370
- 'p',
371
- {
372
- style: { margin: 0, color: 'var(--dsw-alias-state-error-primary)', fontSize: 12 },
373
- role: 'alert',
374
- },
375
- t('browseFailed'),
376
- )
377
- : null,
378
- react.createElement('label', { style: labelStyle }, t('injectStep')),
379
- react.createElement('input', {
380
- type: 'number',
381
- min: 1,
382
- style: { ...inputStyle, maxWidth: 160 },
383
- value: value && typeof value.injectStep === 'number' ? value.injectStep : 1,
384
- onChange: (e) => write('injectStep', Number(e.target.value) || 1),
597
+ react.createElement(primitives.SettingsValueField, {
598
+ id: 'trellis-config-inject-step',
599
+ label: t('injectStep'),
600
+ hint: t('injectStepHint'),
601
+ overriddenLabel: t('overridden'),
602
+ resetLabel: t('reset'),
603
+ invalidLabel: t('invalidNumber'),
604
+ numeric: true,
605
+ disabled,
606
+ ...field('injectStep'),
607
+ onEdit: (text) => props.edit('injectStep', text),
608
+ onReset: () => props.resetField('injectStep'),
385
609
  }),
386
- react.createElement('label', { style: labelStyle }, t('skipKeywords')),
387
- react.createElement('input', {
388
- style: inputStyle,
389
- value: skipDraft || skipKeywords.join(', '),
390
- onChange: (e) => {
391
- setSkipDraft(e.target.value);
392
- write('skipKeywords', e.target.value.split(',').map((s) => s.trim()).filter(Boolean));
393
- },
610
+ react.createElement(primitives.SettingsValueField, {
611
+ id: 'trellis-config-skip-keywords',
612
+ label: t('skipKeywords'),
613
+ hint: t('skipKeywordsHint'),
614
+ overriddenLabel: t('overridden'),
615
+ resetLabel: t('reset'),
616
+ invalidLabel: t('invalidNumber'),
617
+ disabled,
618
+ ...field('skipKeywords'),
619
+ onEdit: (text) => props.edit('skipKeywords', text),
620
+ onReset: () => props.resetField('skipKeywords'),
621
+ }),
622
+ react.createElement(BooleanRow, {
623
+ id: 'trellis-config-inline',
624
+ label: t('inline'),
625
+ checked: field('inline').text === 'true',
626
+ disabled,
627
+ onToggle: (next) => props.edit('inline', next ? 'true' : 'false'),
628
+ }),
629
+ react.createElement(BooleanRow, {
630
+ id: 'trellis-config-readonly',
631
+ label: t('enforceReadonlyPlanning'),
632
+ hint: t('enforceReadonlyPlanningHint'),
633
+ checked: field('enforceReadonlyPlanning').text === 'true',
634
+ disabled,
635
+ onToggle: (next) => props.edit('enforceReadonlyPlanning', next ? 'true' : 'false'),
394
636
  }),
395
- react.createElement(
396
- 'label',
397
- { style: { display: 'flex', gap: 8, alignItems: 'center', margin: '14px 0 6px', fontSize: 13 } },
398
- react.createElement('input', {
399
- type: 'checkbox',
400
- checked: !!(value && value.inline),
401
- onChange: (e) => write('inline', e.target.checked),
402
- }),
403
- t('inline'),
404
- ),
405
- react.createElement(
406
- 'label',
407
- { style: { display: 'flex', gap: 8, alignItems: 'center', margin: '14px 0 6px', fontSize: 13 } },
408
- react.createElement('input', {
409
- type: 'checkbox',
410
- checked: !!(value && value.enforceReadonlyPlanning),
411
- onChange: (e) => write('enforceReadonlyPlanning', e.target.checked),
412
- }),
413
- t('enforceReadonlyPlanning'),
414
- ),
415
- react.createElement('p', { style: { margin: 0, color: 'var(--dsw-alias-label-secondary)', fontSize: 12 } }, t('enforceReadonlyPlanningHint')),
416
- saved
417
- ? react.createElement('p', { style: { margin: 0, color: 'var(--dsw-alias-state-success-primary)', fontSize: 12 } }, t('saved'))
418
- : null,
419
637
  );
420
638
  }
421
639
 
640
+ // #endregion
641
+
422
642
  // #region lib/types/client/trellis-task-chip.js
423
643
 
424
644
  // NOTE: stage lanes are NOT maintained here anymore — the board payload
@@ -3039,19 +3259,22 @@ window.__ModuleLoader__.load({
3039
3259
  clientCtx = ctx;
3040
3260
  ctx.effect(() => ctx.locale.register(LOCALE_NS, { zh, en }), 'trellis-workflow: dictionaries');
3041
3261
  const t = ctx.locale.bind(LOCALE_NS);
3042
- // Bound once on this plugin's fiber: the scope's disposer follows the fiber.
3043
- const scope = ctx.settingsScope.bind({ namespace: NS });
3044
- ctx.slots.inject('settings.plugins.tab', () =>
3262
+ // The entry's configuration on the Plugins page: staged edits over the
3263
+ // shared `configForms` form, registered under the row key that page
3264
+ // dispatches for this bundle's `trellis-workflow` row. The controller
3265
+ // resolves the entry's (possibly namespaced) id from the describe mirror
3266
+ // itself, so the card also works when the Host has not served it yet.
3267
+ const controller = new TrellisConfigController(ctx.configForms);
3268
+ ctx.effect(() => () => controller.dispose(), 'trellis-workflow: config form');
3269
+ ctx.slots.inject('plugins.row.config', () =>
3045
3270
  ctx.slots.register(
3046
3271
  {
3047
- name: 'settings.plugins.tab',
3048
- id: 'trellis-workflow',
3049
- order: 20,
3050
- label: () => t('tab'),
3272
+ name: 'plugins.row.config',
3273
+ key: ROW_CONFIG_KEY,
3051
3274
  locale: LOCALE_NS,
3052
- inject: () => ({ scope }),
3275
+ inject: () => controller.inject(),
3053
3276
  },
3054
- TrellisSettingsTab,
3277
+ TrellisConfigCard,
3055
3278
  ),
3056
3279
  );
3057
3280
  // Session-header phase chip: additive list seat; the framework injects
package/lib/index.js CHANGED
@@ -269,10 +269,14 @@ function lruSet(map, key, value, cap) {
269
269
  /** The Cordis plugin. */
270
270
  export default {
271
271
  name: NAME,
272
- inject: ['fs', 'skills', 'tools'],
273
- config: SCHEMA,
272
+ inject: ['fs', 'skills', 'tools', 'agents'],
273
+ // The schema MUST be declared as `Config` (capital): cordis exposes a plugin's
274
+ // schema at `runtime.Config`, which is exactly what dsh-settings' describe()
275
+ // reads (`entry.fiber.runtime.Config`). With a lowercase `config` the entry is
276
+ // never listed, so its Plugins-page form silently disappears.
277
+ Config: SCHEMA,
274
278
  apply(ctx, config) {
275
- // --- effective config: Web Settings namespace layered over entry config ---
279
+ // --- effective config: Web Settings form layered over entry config ---
276
280
  const effectiveConfig = registerTrellisSettings(ctx, config)
277
281
 
278
282
  // --- skill provisioning (project-level, not bundled registration) -------
@@ -1058,7 +1062,9 @@ export default {
1058
1062
  const effective = effectiveConfig.get()
1059
1063
  if (!effective.enforceReadonlyPlanning) return assembled
1060
1064
 
1061
- const agent = context && context.agent
1065
+ // AssembleContext carries no `agent` (0.1.7): the assembling agent is the
1066
+ // process-local initiator, so resolve the session cwd from it.
1067
+ const agent = (ctx.agents && typeof ctx.agents.currentInitiator === 'function' && ctx.agents.currentInitiator()) || undefined
1062
1068
  const cwd = normalizePath(agent && agent.session && agent.session.header ? agent.session.header.cwd : undefined)
1063
1069
  const root = matchAllowlist(cwd, effective.allowlist)
1064
1070
  if (!root) return assembled
package/lib/meta.js CHANGED
@@ -22,17 +22,26 @@ export const SETTINGS_NAMESPACE = 'trellis-workflow'
22
22
  */
23
23
  export const API_PREFIX = '/trellis-workflow/api'
24
24
 
25
- /** Shared config shape: plugin entry config and the user-settings namespace. */
25
+ /**
26
+ * Shared config shape: plugin entry config and the user-settings form.
27
+ *
28
+ * DSH 0.1.7 settings are Loader-entry driven: the settings form for this
29
+ * plugin IS the `Config` schema below, keyed by the loader entry id
30
+ * (`trellis-workflow`). Only fields marked `.volatile()` are editable through
31
+ * the Web settings form; at resolve time a volatile field wraps its value in a
32
+ * cosmokit volatile (an object with a `get()` accessor), so consumers must
33
+ * unwrap before use (see `unwrapVolatileConfig`).
34
+ */
26
35
  function configShape() {
27
36
  return {
28
37
  /** Project roots whose cwd should receive a Trellis breadcrumb. */
29
- allowlist: z.array(z.string()).default(DEFAULT_ALLOWLIST),
38
+ allowlist: z.array(z.string()).default(DEFAULT_ALLOWLIST).volatile(),
30
39
  /** Only inject on this step index (1 = first step of each user message). */
31
- injectStep: z.number().default(1),
40
+ injectStep: z.number().default(1).volatile(),
32
41
  /** Standalone words that suppress injection for a turn. */
33
- skipKeywords: z.array(z.string()).default(['no-trellis']),
42
+ skipKeywords: z.array(z.string()).default(['no-trellis']).volatile(),
34
43
  /** Assume codex-inline dispatch mode when resolving phase names. */
35
- inline: z.boolean().default(false),
44
+ inline: z.boolean().default(false).volatile(),
36
45
  /**
37
46
  * Read-only planning enforcement: when a session is in the undecided or
38
47
  * planning authorization state, TRIM only the specified tools from the
@@ -40,13 +49,52 @@ function configShape() {
40
49
  * tools invalid for that state), keeping every other tool — including
41
50
  * tools registered by other plugins — intact.
42
51
  */
43
- enforceReadonlyPlanning: z.boolean().default(false),
52
+ enforceReadonlyPlanning: z.boolean().default(false).volatile(),
44
53
  }
45
54
  }
46
55
 
47
56
  export const SCHEMA = z.object(configShape())
48
57
 
49
- /** Schema for the user-settings namespace; mirrors SCHEMA so the Web Settings page can edit the same fields. */
58
+ /**
59
+ * Resolve a config value produced by a `.volatile()` schema field. schemastery
60
+ * wraps volatile values in a cosmokit volatile — a plain object exposing
61
+ * `get()` — so `.get()` returns the underlying value. Non-volatile (already
62
+ * plain) values pass through unchanged.
63
+ * @param {unknown} value a config field value (possibly volatile-wrapped).
64
+ * @returns {unknown} the unwrapped value.
65
+ */
66
+ export function unwrapVolatile(value) {
67
+ if (value !== null && typeof value === 'object' && typeof value.get === 'function') {
68
+ try {
69
+ return value.get()
70
+ } catch {
71
+ return value
72
+ }
73
+ }
74
+ return value
75
+ }
76
+
77
+ /**
78
+ * Deep-unwrap a resolved config object whose `.volatile()` fields are wrapped
79
+ * in cosmokit volatiles. Returns a plain config safe for the existing allowlist
80
+ * / keyword / step reads. Non-object input passes through.
81
+ * @param {unknown} config resolved config (e.g. from `SCHEMA(cfg)` or `ctx.config`).
82
+ * @returns {object} the unwrapped config.
83
+ */
84
+ export function unwrapVolatileConfig(config) {
85
+ if (config === null || typeof config !== 'object') return {}
86
+ const out = {}
87
+ for (const [key, value] of Object.entries(config)) {
88
+ out[key] = unwrapVolatile(value)
89
+ }
90
+ // Defensive: fields consumed as iterables must be arrays even if a volatile
91
+ // wrapper resolved to something unexpected.
92
+ if (!Array.isArray(out.allowlist)) out.allowlist = []
93
+ if (!Array.isArray(out.skipKeywords)) out.skipKeywords = ['no-trellis']
94
+ return out
95
+ }
96
+
97
+ /** @deprecated DSH 0.1.7 projects the Config schema directly; kept for backward imports. */
50
98
  export function settingsSchema() {
51
- return z.object(configShape())
99
+ return SCHEMA
52
100
  }
package/lib/settings.js CHANGED
@@ -1,53 +1,69 @@
1
1
  /**
2
- * Optional user-settings integration for the trellis trigger.
2
+ * Settings integration for the trellis trigger (DSH 0.1.7 model).
3
3
  *
4
- * When a settings provider is mounted (`ctx.get('settings')`), the plugin's
5
- * config (allowlist, injectStep, skipKeywords, inline) is exposed in the Web
6
- * GUI Settings page under the `trellis-workflow` namespace, layered as:
4
+ * Since 0.1.7 the settings service is Loader-entry driven: each plugin's
5
+ * `Config` schema is projected into a settings form keyed by the loader entry
6
+ * id (`trellis-workflow`), and the Web client reads/writes it through the
7
+ * shared `configForms` service. Plugins no longer register a namespace —
8
+ * `ctx.settings.register()` is gone.
7
9
  *
8
- * schema defaults <- composition entry config (base) <- user document
10
+ * Two properties of that model shape this module:
9
11
  *
10
- * `scope.get()` re-resolves live, so an edit made in the GUI applies to the
11
- * next turn without a restart. Without a provider, resolution falls back to
12
- * the entry config unchanged, so every composition works with or without
13
- * settings (same rule as the rest of the harness).
12
+ * 1. The entry's form is generated by default: dsh-settings resolves
13
+ * `autoGenerate` as `presentations.get(fiber)?.auto ?? true`, so a plugin
14
+ * does NOT have to opt in. Calling `ctx.settings.configure()` from an
15
+ * `inject` callback would in fact fail with "cannot create effect on
16
+ * inactive context", so this module deliberately does not call it.
17
+ * 2. The live effective value must be read from the plugin's own Cordis fiber.
18
+ * `ctx.config` is guard-blocked ("cannot get property \"config\" without
19
+ * inject"); `ctx.fiber.config` carries the resolved config the Loader keeps
20
+ * current as Web-form edits apply. The mount-time argument stays the
21
+ * fallback, so a composition without a settings provider still works.
14
22
  */
15
23
 
16
- import { SETTINGS_NAMESPACE, SCHEMA, settingsSchema } from './meta.js'
24
+ import { SCHEMA, unwrapVolatileConfig } from './meta.js'
17
25
 
18
26
  /**
19
- * Register the settings namespace for this plugin's config.
27
+ * Return a live getter for this plugin's effective config.
20
28
  *
21
- * Registration goes through `ctx.inject(['settings'], …)` rather than a
22
- * one-shot `ctx.get('settings')` probe: plugin activation order is
23
- * service-availability driven, so the settings service may not be mounted yet
24
- * when this plugin's `apply` runs. The inject callback fires once settings is
25
- * available (or never, when a composition mounts no settings provider), so the
26
- * namespace is registered whenever the host can serve it. Without a provider,
27
- * resolution falls back to the entry config unchanged.
28
29
  * @param {import('@deepseek-ai/cordis').Context} ctx plugin context.
29
30
  * @param {object} config the plugin's entry config (composition `base`).
30
31
  * @returns {{ get: () => object }} a live getter for the effective config.
31
32
  */
32
33
  export function registerTrellisSettings(ctx, config) {
33
- // Normalize the entry config through the schema so the returned getter is
34
- // never undefined: a plugin bundle loaded without an explicit config gives
35
- // `apply(ctx, config)` an undefined `config`, and schemastery fills in the
36
- // field defaults (allowlist, injectStep, ...) for us.
37
- const base = SCHEMA(config ?? {})
38
- let scope = null
39
- ctx.inject(['settings'], (settingsCtx) => {
34
+ // `apply` receives the config cordis already resolved against `Config`:
35
+ // defaults are applied and every `.volatile()` field arrives as a cosmokit
36
+ // wrapper. So normalize by unwrapping (never by re-validating through the
37
+ // schema, which would resolve the wrappers a second time) and layering it
38
+ // over the schema defaults for anything the entry omitted entirely.
39
+ const defaults = unwrapVolatileConfig(SCHEMA({}))
40
+ const base = { ...defaults }
41
+ for (const [key, value] of Object.entries(unwrapVolatileConfig(config ?? {}))) {
42
+ if (value !== undefined) base[key] = value
43
+ }
44
+
45
+ /**
46
+ * Resolve the effective config, preferring the fiber's live resolved config
47
+ * (Web edits land there) and falling back to the mount-time config. Never
48
+ * throws: a throwing getter would fail every tool call and the per-turn
49
+ * breadcrumb with it.
50
+ * @returns {object} plain config values.
51
+ */
52
+ function get() {
40
53
  try {
41
- scope = settingsCtx.settings.register(
42
- SETTINGS_NAMESPACE,
43
- settingsSchema(),
44
- { base },
45
- )
46
- } catch (error) {
47
- console.warn(
48
- `[trellis-workflow] settings namespace unavailable (${error && error.message}); using entry config`,
49
- )
54
+ const live = ctx.fiber && ctx.fiber.config
55
+ if (live !== null && typeof live === 'object') {
56
+ const out = { ...base }
57
+ for (const [key, value] of Object.entries(unwrapVolatileConfig(live))) {
58
+ if (value !== undefined) out[key] = value
59
+ }
60
+ return out
61
+ }
62
+ } catch {
63
+ /* unreachable fiber, or a build whose config is unreadable — use base */
50
64
  }
51
- })
52
- return { get: () => (scope ? scope.get() ?? base : base) }
65
+ return base
66
+ }
67
+
68
+ return { get }
53
69
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@banana-peeljj12/dsh-trellis",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "Self-contained Trellis workflow trigger for DeepSeek Harness: per-turn breadcrumb injection via agent/pre-step, a bundled skill provider, and DSH-native task/phase state resolution. No Trellis AGPL code is vendored; workflow semantics are rewritten and licensed MIT.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -12,9 +12,11 @@
12
12
  "client": {
13
13
  "inject": [
14
14
  "@deepseek-ai/dsh-api-remotes",
15
- "@deepseek-ai/dsh-client-runtime",
15
+ "@deepseek-ai/dsh-client-locale",
16
+ "@deepseek-ai/dsh-client-ui-plugin-manager",
16
17
  "@deepseek-ai/dsh-client-ui-settings",
17
- "@deepseek-ai/dsh-client-locale"
18
+ "@deepseek-ai/dsh-client-ui-sidebar-right",
19
+ "@deepseek-ai/dsh-client-ui-workspace"
18
20
  ],
19
21
  "platform": "web"
20
22
  }
@@ -25,8 +27,7 @@
25
27
  "scripts": {
26
28
  "test": "node --test",
27
29
  "install:trellis": "node ./scripts/install.mjs"
28
- },
29
- "exports": {
30
+ }, "exports": {
30
31
  ".": {
31
32
  "types": "./lib/types/index.d.ts",
32
33
  "default": "./lib/index.js"
@@ -54,14 +55,24 @@
54
55
  "url": "git+https://github.com/1264459640/dsh-trellis.git"
55
56
  },
56
57
  "peerDependencies": {
57
- "@deepseek-ai/cordis": "^4.0.1",
58
- "@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
59
- "@deepseek-ai/dsh-fs": "^0.1.0-rc.6",
60
- "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
61
- "@deepseek-ai/dsh-session": "^0.1.0-rc.6",
62
- "@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
63
- "@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
64
- "@deepseek-ai/schemastery": "^3.18.1"
58
+ "@deepseek-ai/cordis": "^4.0.4",
59
+ "@deepseek-ai/dsh-agent": "^0.2.0-rc.2",
60
+ "@deepseek-ai/dsh-fs": "^0.2.0-rc.2",
61
+ "@deepseek-ai/dsh-llm": "^0.2.0-rc.2",
62
+ "@deepseek-ai/dsh-session": "^0.2.0-rc.2",
63
+ "@deepseek-ai/dsh-settings": "^0.2.0-rc.2",
64
+ "@deepseek-ai/dsh-tools": "^0.2.0-rc.2",
65
+ "@deepseek-ai/schemastery": "^3.18.4"
66
+ },
67
+ "devDependencies": {
68
+ "@deepseek-ai/cordis": "4.0.4",
69
+ "@deepseek-ai/dsh-agent": "0.2.0-rc.2",
70
+ "@deepseek-ai/dsh-fs": "0.2.0-rc.2",
71
+ "@deepseek-ai/dsh-llm": "0.2.0-rc.2",
72
+ "@deepseek-ai/dsh-session": "0.2.0-rc.2",
73
+ "@deepseek-ai/dsh-settings": "0.2.0-rc.2",
74
+ "@deepseek-ai/dsh-tools": "0.2.0-rc.2",
75
+ "@deepseek-ai/schemastery": "3.18.4"
65
76
  },
66
77
  "peerDependenciesMeta": {
67
78
  "@deepseek-ai/dsh-settings": {
@@ -13,9 +13,12 @@
13
13
  * 3. (optional) — `--fix-deps` removes stale `link:` deps whose target
14
14
  * path does not exist; `--uninstall` removes the config row, the
15
15
  * dependency junction, and the package.json dep entries in one step.
16
- * `--patch-harness` — standalone: only patch the harness
17
- * `WEB_SETTINGS_NAMESPACES` allowlist (no profile required), so the Web
18
- * client can read/write the `trellis-workflow` settings namespace.
16
+ *
17
+ * DSH 0.1.7 note: settings are Loader-entry driven — the Web settings form is
18
+ * the entry's Config schema keyed by `trellis-workflow`, reached by the client
19
+ * through the generated `remote.settings` namespace. No harness
20
+ * `WEB_SETTINGS_NAMESPACES` patch is needed anymore, so this installer no
21
+ * longer patches dsh-host-apiproxy.
19
22
  *
20
23
  * After a successful run, restart the DSH process for the profile. The package
21
24
  * `postinstall` hook invokes this script with `--auto` (safe, idempotent, and
@@ -114,18 +117,16 @@ trellis-workflow 注入层一键安装器
114
117
  --inline 按 codex-inline 调度模式解析阶段
115
118
  --auto 幂等自动模式(postinstall 使用;无法识别时静默退出)
116
119
  --dry-run 只预览将发生的改动,不写盘
117
- --uninstall 一步卸载:配置行 + 依赖 junction + package.json 依赖项 + harness 白名单回撤
120
+ --uninstall 一步卸载:配置行 + 依赖 junction + package.json 依赖项
118
121
  --fix-deps 清理 package.json 中指向不存在路径的 trellis link 依赖
119
- --patch-harness 只对 dsh-host-apiproxy 的 WEB_SETTINGS_NAMESPACES 白名单做幂等补丁
120
- (新增 trellis-workflow,无需 profile;DSH 升级覆盖后可重跑补回)
121
122
  -h, --help 显示本帮助
122
123
 
123
- 安装时会额外对 dsh-host-apiproxy 的 WEB_SETTINGS_NAMESPACES 白名单做幂等补丁
124
- (新增 trellis-workflow,Web 设置页签必需);DSH 升级覆盖后可重跑本命令补回。
124
+ DSH 0.1.7 起 settings 由 Loader 条目驱动:Web 设置表单即条目 Config(命名空间为
125
+ loader 条目 id trellis-workflow),客户端经生成的 remote.settings 命名空间读写,
126
+ 不再需要打 harness 白名单补丁。
125
127
 
126
128
  示例:
127
129
  node scripts/install.mjs --profile web --allowlist "F:/Projects/FordProject"
128
- node scripts/install.mjs --patch-harness
129
130
  node scripts/install.mjs --dry-run
130
131
  node scripts/install.mjs --uninstall --dry-run
131
132
  node scripts/install.mjs --uninstall
@@ -532,10 +533,9 @@ if (process.env.TRELLIS_SKIP_AUTO === '1' && args.auto) {
532
533
  process.exit(0)
533
534
  }
534
535
  if (args.patchHarness) {
535
- // Standalone harness allowlist patch — no profile required.
536
- console.log('== trellis-workflow harness 白名单补丁 ==')
537
- for (const message of patchHarnessAllowlist(args.dryRun)) console.log(message)
538
- console.log('\n补丁完成。重启 DSH(对应 profile 的进程)后,Web 设置页签生效。')
536
+ // DSH 0.1.7: settings are Loader-entry driven and reached by the client via
537
+ // the generated remote.settings namespace — no harness allowlist patch.
538
+ console.log('DSH 0.1.7 起 Web 设置由 Loader 条目驱动(remote.settings),无需再补丁 harness 白名单。')
539
539
  process.exit(0)
540
540
  }
541
541
 
@@ -578,8 +578,6 @@ if (args.uninstall) {
578
578
  console.log(removeLink(profileDir, args.dryRun).message)
579
579
  // 3. package.json dep entries
580
580
  for (const message of removeTrellisDeps(profileDir, args.dryRun)) console.log(message)
581
- // 4. harness settings-exposure allowlist (path A reversal)
582
- for (const message of unpatchHarnessAllowlist(args.dryRun)) console.log(message)
583
581
  console.log('\n卸载完成。重启 DSH(对应 profile 的进程)后,插件完全移除。')
584
582
  process.exit(0)
585
583
  }
@@ -614,8 +612,8 @@ if (args.fixDeps) {
614
612
  if (!bad.length) console.log('依赖检查:无失效 link 依赖')
615
613
  }
616
614
 
617
- // harness settings-exposure allowlist (path A): the Web settings tab needs it
618
- for (const message of patchHarnessAllowlist(args.dryRun)) console.log(message)
615
+ // DSH 0.1.7: the Web settings form is the entry's Config reached via
616
+ // remote.settings — no harness WEB_SETTINGS_NAMESPACES patch is required.
619
617
 
620
618
  if (ok) {
621
619
  console.log('\n完成。重启 DSH(对应 profile 的进程)后,注入层与 Web 设置页签生效。')