dsh-tool-adapt 0.2.5 → 0.2.9

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
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## English
4
4
 
5
- **Current release: 0.2.3** — Remote settings are capability-detected and optional, so older DSH RC hosts continue to start the plugin.
5
+ **Current release: 0.2.9** — Portable settings transport: the plugin now resolves its settings scope on both the `settingsScope` (≤ 0.1.5) and `configForms` (≥ 0.1.7-rc.1) hosts, so the Settings card and pill switch keep working across the rename.
6
6
 
7
7
  A compatibility and safety adaptation layer for non-DeepSeek model families in DeepSeek Harness Web. It guards dead escalation states, injects model-family conventions, fuses repeated failures, and provides a lifecycle-safe Settings card and optional composer pill. Its DSH 0.1.2+ `remote.settings` path falls back to the legacy connection API on older RC hosts.
8
8
 
@@ -26,9 +26,15 @@ them run with the fields stripped (strip) — no error, no loop.
26
26
  veto.
27
27
 
28
28
  Config is hot. When Host `ctx.settings` is available the plugin registers the
29
- `tool-adapt` namespace and an official-style expandable Settings Card
30
- (`settings.plugin.item` / key `tool-adapt`, same disclosure chrome as
31
- Shell / Agent loop) becomes the writable source of truth. A legacy `plugins/tool-adapt.config.json` is migrated once if the
29
+ `tool-adapt` namespace and an official-style expandable Settings Card (same
30
+ disclosure chrome as Shell / Agent loop) becomes the writable source of truth.
31
+ That card registers on both settings seats DSH has used: the legacy
32
+ `settings.plugin.item` (key `tool-adapt`) on ≤ 0.1.5, and the keyed row seat
33
+ `plugins.row.config` (key `dsh-tool-adapt#tool-adapt`, i.e.
34
+ `<package name>#<row id>`) on ≥ 0.1.7-rc.2, where the **插件** panel (the first
35
+ sidebar panel icon) shows a
36
+ configure control for the row only while that exact key is registered.
37
+ A legacy `plugins/tool-adapt.config.json` is migrated once if the
32
38
  settings user layer is empty; the old file is kept for rollback. The
33
39
  `GET/POST /api/tool-adapt/status|set` routes remain as a compatibility
34
40
  surface (loopback + same-origin fenced, 8 KiB body cap). The pill snaps to
@@ -36,7 +42,8 @@ one of the chat input's four corners (drag to switch); the anchor is
36
42
  remembered in `localStorage` (`dsh.toolAdapt.anchor`). The pill mounts inside
37
43
  the composer seat (same stacking level as the input box) at a normal
38
44
  `z-index`, so DSH web popups (modal / menu / toast) can cover it. The pill
39
- itself is status-only — edit the full form under Settings → Plugins. The
45
+ itself is status-only — edit the full form in the **插件** panel (on ≤ 0.1.5:
46
+ Settings → Plugins). The
40
47
  pill is hidden by DEFAULT: the「显示状态胶囊」switch (`ui.pill`, default
41
48
  `false`) at the top of the ADAPT card owns its visibility, and the pill
42
49
  follows the hot `/status` config within one poll (or instantly after a save
@@ -44,8 +51,19 @@ in the same tab).
44
51
 
45
52
  ## Install
46
53
 
47
- Add to the profile's `package.json` dependencies (`link:` for local dev) and
48
- to `dsh.profile.bundles`, then `pnpm install` and restart `dsh web`.
54
+ ```powershell
55
+ dsh plugin --profile web add dsh-tool-adapt
56
+ ```
57
+
58
+ Restart the existing DSH Web process afterwards: the Host scans the browser plugin roster at startup, so the Settings card and the optional pill appear only after that restart. Then open the ADAPT card — on ≤ 0.1.5 it is a card in **Settings → Plugins**; on ≥ 0.1.7-rc.2 open the **插件** panel (the first sidebar panel icon), find the `dsh-tool-adapt` bundle, and use the `tool-adapt` row's configure control; the「显示状态胶囊」switch (`ui.pill`, default `false`) owns the composer pill's visibility.
59
+
60
+ Local development, from this package directory:
61
+
62
+ ```powershell
63
+ dsh plugin --profile web add .
64
+ ```
65
+
66
+ Either form records the package in the profile's `dsh.profile.bundles`, which is what mounts the Host half and serves the client bundle.
49
67
 
50
68
  ## Config
51
69
 
package/lib/client.js CHANGED
@@ -2,7 +2,10 @@
2
2
  //
3
3
  // Browser-only bundle: consumed by the DSH web loader (window.__ModuleLoader__).
4
4
  // Not importable in Node. Renders the ADAPT status pill and an official-style
5
- // expandable Settings Card under settings.plugin.item / key tool-adapt.
5
+ // expandable Settings Card on BOTH settings seats, because DSH moved it when
6
+ // 0.1.7-rc.2 removed the old one: `settings.plugin.item` / key `tool-adapt` on
7
+ // ≤ 0.1.5, and the keyed row seat `plugins.row.config` / key ROW_CONFIG_KEY on
8
+ // ≥ 0.1.7-rc.2.
6
9
  //
7
10
  // The pill is hidden by DEFAULT: the ui.pill switch in the Settings Card
8
11
  // (设置 → 插件 → ADAPT「显示状态胶囊」) owns visibility; the polled /status
@@ -24,6 +27,51 @@ window.__ModuleLoader__.load({
24
27
  const React = require('react')
25
28
 
26
29
  const NS = 'tool-adapt'
30
+
31
+ // ── the rc.2 settings seat key ───────────────────────────────────────────
32
+ //
33
+ // DSH 0.1.7-rc.2 REMOVED the slot `settings.plugin.item` — the seat this card
34
+ // registers on for ≤ 0.1.5. On the corridor a bundle ROW's configuration seat
35
+ // is the keyed slot `plugins.row.config`, and the official plugin-manager
36
+ // renders a row's configure control only while the EXACT key below sits on
37
+ // that slot's registration ledger:
38
+ //
39
+ // rowConfigKey(pkg.name, row.rowId) → `${pkg.name}#${row.rowId}`
40
+ // has: (row) => ledger.rows.has(rowConfigKey(pkg.name, row.rowId))
41
+ //
42
+ // So the key IS the contract: package.json#name plus the row id this package's
43
+ // own cordis.patch.yml declares. One constant, exported for inspection;
44
+ // test/client-source.test.mjs re-derives both halves from those two files and
45
+ // fails on any drift.
46
+ const ROW_CONFIG_KEY = 'dsh-tool-adapt#tool-adapt'
47
+
48
+ // The rc.2 `view === 'summary'` one-liner carries its style inline on purpose:
49
+ // the card's style tag is created lazily by a SettingsCard render, and the
50
+ // summary can render before any SettingsCard ever has.
51
+ const SUMMARY_STYLE = { color: 'var(--dsw-alias-label-tertiary)', fontSize: '13px', lineHeight: 1.5 }
52
+
53
+ // ── settings-scope portability (DSH 0.1.5 ↔ 0.1.7-rc.1) ──────────────────
54
+ //
55
+ // The two supported hosts expose the same scope CONTRACT under different
56
+ // service names, so the adapter lives here instead of in every call site:
57
+ //
58
+ // ≤ 0.1.5 settingsScope.bind({ namespace }) → SettingsScope<T>
59
+ // ≥ 0.1.7 configForms.get(entryId) → ConfigForm<T>
60
+ //
61
+ // Both return getSnapshot()/subscribe()/set()/unset()/mutate() over the same
62
+ // { status, value, base, user, revision, writable, mode } snapshot, so the
63
+ // resolved object is used unchanged by the card, the pill and the tests.
64
+ // `ctx.get` (never a direct property read) keeps the lookup safe when only
65
+ // one service is mounted: an uninjected property read throws.
66
+ // For this plugin the settings namespace and the profile entry id are both
67
+ // 'tool-adapt', so one key resolves on either host.
68
+ function resolveSettingsScopeFrom(ctx, namespace) {
69
+ const binder = ctx.get('settingsScope')
70
+ if (binder && typeof binder.bind === 'function') return binder.bind({ namespace })
71
+ const forms = ctx.get('configForms')
72
+ if (forms && typeof forms.get === 'function') return forms.get(namespace)
73
+ return undefined
74
+ }
27
75
  const API = '/api/tool-adapt'
28
76
  const POLL_MS = 5000
29
77
  const e = React.createElement
@@ -388,7 +436,11 @@ window.__ModuleLoader__.load({
388
436
  let pillMounted = false
389
437
  let pillDispose = null
390
438
 
391
- function startPill(scope) {
439
+ // `getScope` is a GETTER: the settings transport is awaited with a
440
+ // non-gating ctx.inject callback, so the pill may start before (or entirely
441
+ // without) a scope. Reading it lazily keeps the pill working either way and
442
+ // lets the settings subscription attach if/when the transport appears.
443
+ function startPill(getScope) {
392
444
  if (typeof document === 'undefined') return
393
445
  if (pillMounted) {
394
446
  // Previous mount leaked (disposal was skipped). Dispose it first, then
@@ -396,7 +448,7 @@ window.__ModuleLoader__.load({
396
448
  const previous = pillDispose
397
449
  pillDispose = null
398
450
  if (typeof previous === 'function') previous()
399
- return startPill()
451
+ return startPill(getScope)
400
452
  }
401
453
  pillMounted = true
402
454
 
@@ -764,6 +816,7 @@ window.__ModuleLoader__.load({
764
816
  applyVisibility()
765
817
  }
766
818
 
819
+ const scope = getScope()
767
820
  if (scope && typeof scope.subscribe === 'function') {
768
821
  // A settings save reaches the host config immediately; re-fetch right
769
822
  // away so the pill flips without waiting up to POLL_MS. The 5s poll
@@ -802,23 +855,78 @@ window.__ModuleLoader__.load({
802
855
  }
803
856
 
804
857
  function apply(ctx) {
805
- // Settings card first: pill mounting waits for the composer seat
806
- // asynchronously and must never delay or break the Settings Slot.
807
- const scope = ctx.settingsScope.bind({ namespace: NS })
808
858
  // DSH 0.1.2+ exposes fine-grained remote settings through
809
859
  // @deepseek-ai/dsh-api-remotes (ctx.remote). The legacy RC connection API
810
860
  // is no longer provided on the Client, so there is no fallback branch.
861
+ // Defined BEFORE the settings wait: the inject callback can fire while
862
+ // apply is still running, and the card closure reads both of these.
811
863
  const remote = ctx.get('remote')
812
864
  const api = remote && remote.settings
813
865
  ? { settings: { mutate: (payload) => remote.settings.mutate(payload.ns, payload.ops, payload.expectedRevision).then((result) => ({ result })) } }
814
866
  : undefined
815
- const disposeSlot = ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({
816
- name: 'settings.plugin.item',
817
- key: NS,
818
- label: 'ADAPT',
819
- }, function ToolAdaptCard() {
867
+ let scope
868
+ const disposeSlots = []
869
+
870
+ // Settings card FIRST (pill mounting waits for the composer seat
871
+ // asynchronously and must never delay or break the Settings Slot), and
872
+ // registered from inside the settings-transport callback on the CHILD
873
+ // context it hands us. Registering `settings.plugin.item` from the bare
874
+ // apply context puts the entry where the Plugins tab's ledger never sees
875
+ // it: the tab reads `ctx.slots.entries('settings.plugin.item')` from its
876
+ // own context, got an empty list, and the card never rendered even though
877
+ // `register` returned normally. (dshmarket, an out-of-repo bundle that
878
+ // does render, uses this nested shape.)
879
+ // Both transport names are awaited WITHOUT gating activation, and whichever
880
+ // this host mounts resolves; see resolveSettingsScopeFrom.
881
+ const registerCard = (sctx) => {
882
+ if (scope !== undefined) return
883
+ scope = resolveSettingsScopeFrom(sctx, NS)
884
+ if (scope === undefined) return
885
+ disposeSlots.push(sctx.slots.inject('settings.plugin.item', () => sctx.slots.register({
886
+ name: 'settings.plugin.item',
887
+ key: NS,
888
+ label: 'ADAPT',
889
+ }, function ToolAdaptCard() {
890
+ return e(SettingsCard, { scope, api })
891
+ })))
892
+ }
893
+ // The first wait fires only when settingsScope exists; the second covers a
894
+ // host that renamed the transport, and its guard keeps the card from
895
+ // registering twice when both names exist.
896
+ ctx.inject(['settingsScope'], registerCard)
897
+ ctx.inject(['configForms'], (sctx) => { if (scope === undefined) registerCard(sctx) })
898
+
899
+ // ── rc.2 seat: the keyed slot `plugins.row.config` ──────────────────────
900
+ //
901
+ // 0.1.7-rc.2 removed `settings.plugin.item`; a bundle row's configuration
902
+ // seat is `plugins.row.config`, declared by the official plugin-manager
903
+ // page, and the row's configure control exists only while an occupant holds
904
+ // the exact ledger key ROW_CONFIG_KEY. The old seat is still declared (and
905
+ // still used) on ≤ 0.1.5, so both registrations sit side by side with no
906
+ // version sniffing: `slots.inject` fires only where its own slot is declared
907
+ // and disposes the entry when that declaration collapses.
908
+ //
909
+ // The page renders this ONE component in two views:
910
+ // view === 'summary' → the row's one-liner: text only, no controls
911
+ // view === 'page' → the configuration page, i.e. the existing card
912
+ // The optional, host-owned `form` prop is deliberately NOT consumed: values
913
+ // keep flowing through the single transport resolved by
914
+ // resolveSettingsScopeFrom (settingsScope ≤ 0.1.5 / configForms ≥ 0.1.7), so
915
+ // this plugin keeps exactly one read path and one write path.
916
+ //
917
+ // `slots` is awaited NON-GATINGLY, and the callback returns the registration
918
+ // disposer so the entry is owned by the registering fiber and released with
919
+ // the rest of disposeSlots on stop / update / HMR.
920
+ const registerRowConfig = (sctx) => sctx.slots.inject('plugins.row.config', () => sctx.slots.register({
921
+ name: 'plugins.row.config',
922
+ key: ROW_CONFIG_KEY,
923
+ }, function ToolAdaptRowConfig(props) {
924
+ if (props && props.view === 'summary') {
925
+ return e('span', { style: SUMMARY_STYLE }, '非 DeepSeek 模型的工具适配开关(守卫 / L2 惯例 / L0 熔断 / 状态胶囊)。')
926
+ }
820
927
  return e(SettingsCard, { scope, api })
821
928
  }))
929
+ ctx.inject(['slots'], registerRowConfig)
822
930
 
823
931
  // Pill: fully lifecycle-owned. The effect runs startPill() now and the
824
932
  // returned disposer on stop / update / HMR, idempotently releasing the
@@ -826,17 +934,24 @@ window.__ModuleLoader__.load({
826
934
  // and every listener so the next apply re-mounts from scratch. The pill
827
935
  // starts hidden: startPill gates visibility on ui.pill (default false)
828
936
  // via the settings scope plus the /status poll fallback.
829
- ctx.effect(() => startPill(scope), 'dsh-tool-adapt: composer pill')
937
+ ctx.effect(() => startPill(() => scope), 'dsh-tool-adapt: composer pill')
830
938
 
831
939
  // Plugin-card style tag: created lazily by SettingsCard renders and
832
940
  // removed here so no style is left behind after stop / update.
833
941
  ctx.effect(() => () => removeCardStyles(), 'dsh-tool-adapt: plugin card style')
834
942
 
835
- return disposeSlot
943
+ return () => { for (const dispose of disposeSlots) { try { dispose() } catch (_) {} } }
836
944
  }
837
945
 
838
946
  exports.apply = apply
839
- exports.inject = ['slots', 'settingsScope', 'remote', 'remote.settings']
947
+ // The rc.2 ledger key lives in one exported constant so a test (and anyone
948
+ // inspecting the bundle) can compare it with package.json#name plus the row id
949
+ // in cordis.patch.yml instead of trusting a second literal.
950
+ exports.ROW_CONFIG_KEY = ROW_CONFIG_KEY
951
+ // 'settingsScope' and 'configForms' are alternatives, not both-required:
952
+ // cordis resolves each inject name as its own gate, so activation succeeds on
953
+ // whichever settings transport the host mounts (0.1.5 vs 0.1.7-rc.1).
954
+ exports.inject = ['slots', 'remote', 'remote.settings']
840
955
  return module.exports
841
956
  },
842
957
  })
package/lib/index.js CHANGED
@@ -60,31 +60,122 @@ import {
60
60
  export { DEFAULT_CONFIG, SETTINGS_NS, validateConfig } from './config.js'
61
61
 
62
62
  export const name = 'dsh-tool-adapt'
63
+ // `settings` is deliberately NOT listed here: cordis treats every inject name as a
64
+ // REQUIRED gate, and the settings transport is version-dependent (see the
65
+ // `ctx.inject(['settings'], ...)` wait inside apply).
63
66
  export const inject = ['webServer', 'fs', 'systemPrompt']
64
67
 
65
- function createSettingsSchema() {
66
- return Schema.object({
68
+ // ── settings schema: one shape, two hosts ────────────────────────────────────
69
+ // ≤ 0.1.5 the schema is handed to `ctx.settings.register()` and plain fields are
70
+ // enough.
71
+ // ≥ 0.1.7 the same shape must be the entry's own `static Config`, and the
72
+ // settings service only exposes fields marked `.volatile()`: a schema
73
+ // without a volatile field produces no settings form at all.
74
+ //
75
+ // `Schema.prototype.volatile` exists only from schemastery 3.18.4 (the 0.1.7
76
+ // corridor); the 0.1.5 line resolves 3.18.2, where it is `undefined` and calling
77
+ // it throws. So it is applied capability-detected, and one expression serves both
78
+ // hosts. Volatility is marked on the individual LEAVES (and on `excludeModels` as
79
+ // a whole) — never on an enclosing object too, because a volatile field inside a
80
+ // volatile field throws at resolve time.
81
+ const volatile = (schema) => (typeof schema?.volatile === 'function' ? schema.volatile() : schema)
82
+
83
+ // A volatile field's parsed value is a cosmokit wrapper: a `get()` method plus a
84
+ // registered write symbol, and nothing else. Key on the symbol — not on `set`,
85
+ // which does NOT exist (verified against schemastery 3.18.4 + cosmokit 1.8.5).
86
+ const VOLATILE_WRITE = Symbol.for('cosmokit.volatile.write')
87
+ function readVolatile(value) {
88
+ if (value === null || typeof value !== 'object') return value
89
+ if (typeof value.get !== 'function') return value
90
+ if (!(VOLATILE_WRITE in value)) return value
91
+ return value.get()
92
+ }
93
+
94
+ function settingsFields() {
95
+ return {
67
96
  guard: Schema.object({
68
- enabled: Schema.boolean().default(DEFAULT_CONFIG.guard.enabled),
97
+ enabled: volatile(Schema.boolean().default(DEFAULT_CONFIG.guard.enabled)),
69
98
  }).default(cloneConfig(DEFAULT_CONFIG.guard)),
70
99
  l2: Schema.object({
71
- enabled: Schema.boolean().default(DEFAULT_CONFIG.l2.enabled),
72
- excludeModels: Schema.array(Schema.string().pattern(/^[A-Za-z0-9.*_-]{1,64}$/)).max(50).default(DEFAULT_CONFIG.l2.excludeModels.slice()),
73
- block: Schema.string().min(1).max(4000).default(DEFAULT_CONFIG.l2.block),
100
+ enabled: volatile(Schema.boolean().default(DEFAULT_CONFIG.l2.enabled)),
101
+ excludeModels: volatile(Schema.array(Schema.string().pattern(/^[A-Za-z0-9.*_-]{1,64}$/)).max(50).default(DEFAULT_CONFIG.l2.excludeModels.slice())),
102
+ block: volatile(Schema.string().min(1).max(4000).default(DEFAULT_CONFIG.l2.block)),
74
103
  }).default(cloneConfig(DEFAULT_CONFIG.l2)),
75
104
  l0: Schema.object({
76
- enabled: Schema.boolean().default(DEFAULT_CONFIG.l0.enabled),
77
- remindAfter: Schema.number().step(1).min(2).max(20).default(DEFAULT_CONFIG.l0.remindAfter),
78
- vetoAfter: Schema.number().step(1).min(0).max(20).default(DEFAULT_CONFIG.l0.vetoAfter),
79
- reminderText: Schema.string().min(1).max(2000).default(DEFAULT_CONFIG.l0.reminderText),
80
- vetoText: Schema.string().min(1).max(2000).default(DEFAULT_CONFIG.l0.vetoText),
105
+ enabled: volatile(Schema.boolean().default(DEFAULT_CONFIG.l0.enabled)),
106
+ remindAfter: volatile(Schema.number().step(1).min(2).max(20).default(DEFAULT_CONFIG.l0.remindAfter)),
107
+ vetoAfter: volatile(Schema.number().step(1).min(0).max(20).default(DEFAULT_CONFIG.l0.vetoAfter)),
108
+ reminderText: volatile(Schema.string().min(1).max(2000).default(DEFAULT_CONFIG.l0.reminderText)),
109
+ vetoText: volatile(Schema.string().min(1).max(2000).default(DEFAULT_CONFIG.l0.vetoText)),
81
110
  }).default(cloneConfig(DEFAULT_CONFIG.l0)),
82
111
  ui: Schema.object({
83
- pill: Schema.boolean().default(DEFAULT_CONFIG.ui.pill),
112
+ pill: volatile(Schema.boolean().default(DEFAULT_CONFIG.ui.pill)),
84
113
  }).default(cloneConfig(DEFAULT_CONFIG.ui)),
114
+ }
115
+ }
116
+
117
+ // ≤ 0.1.5 registration schema: the live settings section only.
118
+ function createSettingsSchema() {
119
+ return Schema.object(settingsFields())
120
+ }
121
+
122
+ // ≥ 0.1.7 entry Config: the live settings section PLUS the row field `configFile`,
123
+ // which is what the mounting row in cordis.patch.yml carries. The settings fields
124
+ // keep the identical names, nesting and defaults, so the client card's reads and
125
+ // writes of `guard.enabled`, `l2.*`, `l0.*` and `ui.pill` work on both hosts.
126
+ function createEntryConfig() {
127
+ return Schema.object({
128
+ configFile: Schema.string().default('plugins/tool-adapt.config.json'),
129
+ ...settingsFields(),
85
130
  })
86
131
  }
87
132
 
133
+ // Project the parsed entry Config onto the live settings shape, reading volatile
134
+ // fields on demand. Never cache the result: the declarative settings service
135
+ // writes volatile fields IN PLACE without remounting the entry, so the value must
136
+ // be re-read at every access.
137
+ function readDeclarativeConfig(raw) {
138
+ if (raw === null || typeof raw !== 'object') return undefined
139
+ const guard = readVolatile(raw.guard)
140
+ const l2 = readVolatile(raw.l2)
141
+ const l0 = readVolatile(raw.l0)
142
+ const ui = readVolatile(raw.ui)
143
+ return {
144
+ guard: { enabled: readVolatile(guard && guard.enabled) },
145
+ l2: {
146
+ enabled: readVolatile(l2 && l2.enabled),
147
+ excludeModels: readVolatile(l2 && l2.excludeModels),
148
+ block: readVolatile(l2 && l2.block),
149
+ },
150
+ l0: {
151
+ enabled: readVolatile(l0 && l0.enabled),
152
+ remindAfter: readVolatile(l0 && l0.remindAfter),
153
+ vetoAfter: readVolatile(l0 && l0.vetoAfter),
154
+ reminderText: readVolatile(l0 && l0.reminderText),
155
+ vetoText: readVolatile(l0 && l0.vetoText),
156
+ },
157
+ ui: { pill: readVolatile(ui && ui.pill) },
158
+ }
159
+ }
160
+
161
+ // The plugin's own entry Config (`static Config`): what the declarative (≥ 0.1.7)
162
+ // settings service reads as this entry's schema, keyed by the loader entry id
163
+ // 'tool-adapt' — the same key the client half resolves.
164
+ //
165
+ // It is reached two ways, and both must work:
166
+ // - through the plugin value cordis resolves. The loader's `unwrapExports`
167
+ // collapses a namespace carrying a `default` export down to that single value,
168
+ // so this module deliberately exports NO `default`: with none, the namespace
169
+ // itself is the plugin object, `resolve()` finds `apply`, and `plugin.Config`
170
+ // is read off that same namespace.
171
+ // - as a plain named export, so a host half loaded directly (the workspace's
172
+ // `scripts/settings-host-portability-check.mjs`) reads `module.Config`.
173
+ // `Config` is therefore the single shared schema instance attached to the exported
174
+ // `apply`. Function declarations are hoisted, so this is safe even though `apply`
175
+ // is defined further down, and the ≤ 0.1.5 line simply never reads it.
176
+ export const Config = createEntryConfig()
177
+ Object.assign(apply, { Config })
178
+
88
179
  async function readLegacyFile(ctx, configFile) {
89
180
  try {
90
181
  const target = await ctx.fs.resolve(configFile)
@@ -187,14 +278,71 @@ export function apply(ctx, config) {
187
278
  },
188
279
  }
189
280
 
190
- if (ctx.get('settings') === undefined) {
281
+ // Validate an already-projected live config; on any failure fall back to the
282
+ // shipped defaults, exactly like the legacy registration path.
283
+ function liveOrDefault(raw) {
284
+ const validated = validateConfig(raw)
285
+ return validated.ok ? validated.config : entry
286
+ }
287
+
288
+ // ── settings transport: two independent hosts ─────────────────────────────
289
+ //
290
+ // ≤ 0.1.5 `ctx.settings.register(namespace, schema, opts)` exists. This plugin
291
+ // owns the `tool-adapt` namespace, its plain schema is enough, and a
292
+ // legacy `settings.yaml` may still be migrated into it.
293
+ //
294
+ // ≥ 0.1.7 register() is gone. A settings namespace exists only as the plugin
295
+ // entry's own `Config` schema (the `static Config` attached to `apply`
296
+ // at module scope), keyed by the loader entry id — which is
297
+ // `tool-adapt`, the same key the client half resolves. Only
298
+ // `.volatile()` fields are exposed, so the entry Config marks the
299
+ // editable leaves volatile and no schema is registered here. The
300
+ // settings service writes those fields IN PLACE, without remounting the
301
+ // entry, so `source` must re-read the parsed `config` argument on every
302
+ // access rather than cache it. The legacy `settings.yaml` migration does
303
+ // not apply on this host: the entry Config is the storage.
304
+ //
305
+ // Both branches keep `source` non-throwing and fall back to the shipped
306
+ // defaults; with no settings service at all the legacy JSON file is the store.
307
+ const settingsApi = ctx.get('settings')
308
+ const declarative = settingsApi !== undefined
309
+ && typeof settingsApi.register !== 'function'
310
+ && typeof settingsApi.configure === 'function'
311
+
312
+ if (settingsApi === undefined) {
313
+ // No settings transport: the legacy JSON file stays authoritative.
191
314
  void loadLegacyFile(ctx, configFile, state)
192
315
  }
193
316
 
194
317
  ctx.inject(['settings'], (sctx) => {
318
+ if (declarative) {
319
+ try {
320
+ // Suppress the official UI's generated generic page for this entry: the
321
+ // plugin ships its own settings card. `auto: false` only affects that
322
+ // generated page — the namespace still reaches the client mirror, so the
323
+ // custom card keeps working. `configure` throws if called twice for one
324
+ // fiber, so the effect owns the call and disposes it.
325
+ sctx.effect(() => sctx.settings.configure({ auto: false }, ctx.fiber), 'dsh-tool-adapt: settings presentation')
326
+ source = () => liveOrDefault(readDeclarativeConfig(config))
327
+ state.settingsReady = true
328
+ sctx.effect(() => () => {
329
+ state.settingsReady = false
330
+ source = () => entry
331
+ }, 'dsh-tool-adapt: settings fallback')
332
+ } catch (err) {
333
+ state.fileError = String((err && err.message) || err)
334
+ source = () => entry
335
+ }
336
+ return
337
+ }
338
+
339
+ if (!settingsApi || typeof settingsApi.register !== 'function') {
340
+ source = () => entry
341
+ return
342
+ }
195
343
  try {
196
344
  const schema = createSettingsSchema()
197
- const scope = sctx.settings.register(SETTINGS_NS, schema, {
345
+ const scope = settingsApi.register(SETTINGS_NS, schema, {
198
346
  base: entry,
199
347
  applies: 'live',
200
348
  validate: (value) => {
@@ -202,11 +350,7 @@ export function apply(ctx, config) {
202
350
  if (!validated.ok) throw new Error((validated.errors || []).join('; '))
203
351
  },
204
352
  })
205
- source = () => {
206
- const resolved = scope.get()
207
- const validated = validateConfig(resolved)
208
- return validated.ok ? validated.config : entry
209
- }
353
+ source = () => liveOrDefault(scope.get())
210
354
  state.settingsReady = true
211
355
  sctx.effect(() => scope.watch(() => {}), 'dsh-tool-adapt: settings watch')
212
356
  sctx.effect(() => () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-tool-adapt",
3
- "version": "0.2.5",
3
+ "version": "0.2.9",
4
4
  "description": "EN: Compatibility and safety adaptation layer for non-DeepSeek models in DeepSeek Harness Web. ZH: 面向 DeepSeek Harness Web 非 DeepSeek 模型的兼容与安全适配层。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",