dsh-mcp-pill 0.2.4 → 0.2.8

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.6** — 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 keeps working across the rename.
6
6
 
7
7
  A lifecycle-safe MCP connection status pill for DeepSeek Harness Web. It exposes loopback-fenced status/toggle RPC, an official Settings card, and a composer-seat pill that stays hidden until enabled. DSH 0.1.2+ fine-grained `remote.settings` is preferred; older RC hosts use the legacy connection API.
8
8
 
@@ -18,10 +18,16 @@ Global MCP connection status pill for the DSH web UI — official bundle form
18
18
  - `POST /api/mcp-pill/set` — `{ id, enabled }` toggles a connection via the
19
19
  patch file's `disabled` marker (loader HMR applies it).
20
20
  - The pill is hidden by DEFAULT. An official-style expandable Settings Card
21
- (`settings.plugin.item` / key `mcp-pill`) owns one switch,
22
- 「显示状态胶囊」(`pill.enabled`, default `false`); while it is off the pill
23
- never mounts visibly, and toggling it takes effect within one status poll
24
- (instantly after a save in the same tab).
21
+ owns one switch, 「显示状态胶囊」(`pill.enabled`, default `false`); while it is
22
+ off the pill never mounts visibly, and toggling it takes effect within one
23
+ status poll (instantly after a save in the same tab).
24
+ - That card registers on BOTH settings seats, because DSH moved it: the keyed
25
+ row seat `plugins.row.config` (key `dsh-mcp-pill#mcp-pill`) on
26
+ DSH ≥ 0.1.7-rc.2, and the legacy item seat `settings.plugin.item`
27
+ (key `mcp-pill`) on ≤ 0.1.5. Each seat is declared by one host only, so the two
28
+ registrations coexist with no version sniffing and the card always has a home;
29
+ values keep flowing through the single settings transport
30
+ (`settingsScope` on ≤ 0.1.5, `configForms` on ≥ 0.1.7).
25
31
  - The pill snaps to one of the chat input's four corners (drag to switch);
26
32
  the anchor is remembered in `localStorage` (`dsh.mcpPill.anchor`).
27
33
  - The pill mounts inside the composer seat (same stacking level as the input
@@ -30,8 +36,19 @@ Global MCP connection status pill for the DSH web UI — official bundle form
30
36
 
31
37
  ## Install
32
38
 
33
- Add to the profile's `package.json` dependencies (`link:` for local dev) and
34
- to `dsh.profile.bundles`, then `pnpm install` and restart `dsh web`.
39
+ ```powershell
40
+ dsh plugin --profile web add dsh-mcp-pill
41
+ ```
42
+
43
+ Restart the existing DSH Web process afterwards: the Host scans the browser plugin roster at startup, so the pill and its Settings card appear only after that restart. Then open the 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-mcp-pill` bundle and use the `mcp-pill` row's configure control — and turn on「显示状态胶囊」; the pill is hidden by default.
44
+
45
+ Local development, from this package directory:
46
+
47
+ ```powershell
48
+ dsh plugin --profile web add .
49
+ ```
50
+
51
+ Either form records the package in the profile's `dsh.profile.bundles`, which is what mounts the Host half and serves the client bundle.
35
52
 
36
53
  ## Config
37
54
 
package/lib/client.js CHANGED
@@ -6,9 +6,12 @@
6
6
  // remembered in localStorage.
7
7
  //
8
8
  // The pill is hidden by DEFAULT: an official-style expandable Settings Card
9
- // under settings.plugin.item / key mcp-pill owns the「显示状态胶囊」switch
10
- // (pill.enabled, default false), and the polled /api/mcp-pill/status payload
11
- // (`pill.enabled`, hot host mirror) decides whether the pill shows at all.
9
+ // owns the「显示状态胶囊」switch (pill.enabled, default false). The same card is
10
+ // registered on BOTH settings seats, because DSH moved it when 0.1.7-rc.2
11
+ // removed the old one: `settings.plugin.item` / key `mcp-pill` on ≤ 0.1.5, and
12
+ // the keyed row seat `plugins.row.config` / key ROW_CONFIG_KEY on ≥ 0.1.7-rc.2.
13
+ // The polled /api/mcp-pill/status payload (`pill.enabled`, hot host mirror)
14
+ // decides whether the pill shows at all.
12
15
  //
13
16
  // Lifecycle: every DOM node, observer, timer, rAF and listener created by
14
17
  // startPill() is registered with the idempotent disposer returned through
@@ -25,6 +28,54 @@ window.__ModuleLoader__.load({
25
28
 
26
29
  const NS = 'mcp-pill'
27
30
  const API = '/api/mcp-pill'
31
+
32
+ // ── the rc.2 settings seat key ───────────────────────────────────────────
33
+ //
34
+ // DSH 0.1.7-rc.2 REMOVED the slot `settings.plugin.item` — the seat this
35
+ // card registers on for ≤ 0.1.5. On the corridor a bundle ROW's
36
+ // configuration seat is the keyed slot `plugins.row.config`, and the
37
+ // official plugin-manager shows a row's configure control only when the
38
+ // EXACT key below sits on that slot's registration ledger:
39
+ //
40
+ // rowConfigKey(pkg.name, row.rowId) → `${pkg.name}#${row.rowId}`
41
+ // has: (row) => ledger.rows.has(rowConfigKey(pkg.name, row.rowId))
42
+ //
43
+ // So the key IS the contract: the package name from package.json plus the
44
+ // row id this package's own cordis.patch.yml declares. It lives in one
45
+ // constant, is exported for inspection, and test/client-lifecycle.test.mjs
46
+ // re-derives both halves from those two files and fails on any drift.
47
+ const ROW_CONFIG_KEY = 'dsh-mcp-pill#mcp-pill'
48
+
49
+ // ── settings-scope portability (DSH 0.1.5 ↔ 0.1.7-rc.1) ──────────────────
50
+ //
51
+ // The two supported hosts expose the same scope CONTRACT under different
52
+ // service names, so the adapter lives here instead of in every call site:
53
+ //
54
+ // ≤ 0.1.5 settingsScope.bind({ namespace }) → SettingsScope<T>
55
+ // ≥ 0.1.7 configForms.get(entryId) → ConfigForm<T>
56
+ //
57
+ // Both return getSnapshot()/subscribe()/set()/unset()/mutate() over the same
58
+ // { status, value, base, user, revision, writable, mode } snapshot, so the
59
+ // resolved object is used unchanged by the card, the pill and the tests.
60
+ //
61
+ // NEITHER name may appear in `exports.inject`. cordis treats every inject
62
+ // name as a REQUIRED gate — `Fiber._refresh()` marks the fiber INACTIVE when
63
+ // any single name has no provider — so declaring both would leave this
64
+ // plugin permanently pending on every host, and declaring either one would
65
+ // block the other host. The optional transport is therefore awaited inside
66
+ // apply with `ctx.inject([...], cb)`, which is a NON-GATING wait: apply runs
67
+ // regardless, and the callback fires whenever the service arrives (including
68
+ // late registration).
69
+ //
70
+ // For this plugin the settings namespace and the profile entry id are both
71
+ // 'mcp-pill', so one key resolves on either host.
72
+ function resolveSettingsScopeFrom(ctx, namespace) {
73
+ const binder = ctx.get('settingsScope')
74
+ if (binder && typeof binder.bind === 'function') return binder.bind({ namespace })
75
+ const forms = ctx.get('configForms')
76
+ if (forms && typeof forms.get === 'function') return forms.get(namespace)
77
+ return undefined
78
+ }
28
79
  // Dynamic polling intervals (ms): active when any MCP enabled, idle when all disabled
29
80
  const POLL_INTERVALS = Object.freeze({
30
81
  ACTIVE: 3000, // When at least one MCP is enabled
@@ -88,6 +139,8 @@ window.__ModuleLoader__.load({
88
139
  '.dmpHeadText{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}',
89
140
  '.dmpName{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}',
90
141
  '.dmpDescription{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}',
142
+ // rc.2 `view === 'summary'` seat: one line of text, no controls.
143
+ '.dmpRowSummary{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}',
91
144
  '.dmpChevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}',
92
145
  '.dmpChevronOpen{transform:rotate(180deg)}',
93
146
  '.dmpBody{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}',
@@ -322,7 +375,12 @@ window.__ModuleLoader__.load({
322
375
 
323
376
  // ── composer pill ───────────────────────────────────────────────────────
324
377
 
325
- function startPill(scope) {
378
+ // `getScope` is a GETTER, not the scope itself: the settings transport is
379
+ // awaited with a non-gating ctx.inject callback, so the pill may start
380
+ // before (or entirely without) a scope. Reading it lazily keeps the pill
381
+ // working either way and lets the settings subscription attach if/when the
382
+ // transport appears — without ever blocking this plugin's activation.
383
+ function startPill(getScope) {
326
384
  if (typeof document === 'undefined' || !document.documentElement) return
327
385
  if (pillMounted) {
328
386
  // Previous mount leaked (disposal was skipped). Dispose it first, then
@@ -330,7 +388,7 @@ window.__ModuleLoader__.load({
330
388
  const previous = pillDispose
331
389
  pillDispose = null
332
390
  if (typeof previous === 'function') previous()
333
- return startPill()
391
+ return startPill(getScope)
334
392
  }
335
393
  pillMounted = true
336
394
 
@@ -820,6 +878,7 @@ window.__ModuleLoader__.load({
820
878
  applyVisibility()
821
879
  }
822
880
 
881
+ const scope = getScope()
823
882
  if (scope && typeof scope.subscribe === 'function') {
824
883
  // A settings save reaches the host mirror immediately; re-fetch right
825
884
  // away so the pill flips without waiting for the next poll. The
@@ -864,38 +923,110 @@ window.__ModuleLoader__.load({
864
923
  function apply(ctx) {
865
924
  if (typeof document === 'undefined') return
866
925
 
867
- // Settings card first: pill mounting waits for the composer seat
868
- // asynchronously and must never delay or break the Settings Slot.
869
- const scope = ctx.settingsScope.bind({ namespace: NS })
870
926
  // DSH 0.1.2+ exposes fine-grained remote settings through
871
927
  // @deepseek-ai/dsh-api-remotes (ctx.remote). The legacy RC connection API
872
928
  // is no longer available, so there is no fallback branch.
929
+ // Defined BEFORE the settings wait: the inject callback can fire while
930
+ // apply is still running, and the card closure reads both of these.
873
931
  const remote = ctx.get('remote')
874
932
  const api = remote && remote.settings
875
933
  ? { settings: { mutate: (payload) => remote.settings.mutate(payload.ns, payload.ops, payload.expectedRevision).then((result) => ({ result })) } }
876
934
  : undefined
877
- const disposeSlot = ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({
878
- name: 'settings.plugin.item',
879
- key: NS,
880
- label: 'MCP Pill',
881
- }, function McpPillCard() {
882
- return e(SettingsCard, { scope, api })
883
- }))
935
+ let scope
936
+ const disposeSlots = []
937
+
938
+ // The Settings card MUST be registered from inside a context that has the
939
+ // settings transport, i.e. from the `ctx.inject([...], cb)` callback and on
940
+ // the CHILD context the callback receives. Registering
941
+ // `settings.plugin.item` from the bare apply context puts the entry where
942
+ // the Plugins tab's ledger never sees it: the tab asks
943
+ // `ctx.slots.entries('settings.plugin.item')` from ITS context and the
944
+ // entry list came back empty, so the card never rendered even though
945
+ // `register` returned normally. (dshmarket, an out-of-repo bundle that
946
+ // does render its card, uses exactly this nested shape.)
947
+ // Both transport names are waited for WITHOUT gating activation, and
948
+ // whichever one this host mounts resolves; see resolveSettingsScopeFrom.
949
+ const registerCard = (sctx) => {
950
+ if (scope !== undefined) return
951
+ scope = resolveSettingsScopeFrom(sctx, NS)
952
+ if (scope === undefined) return
953
+ // Register on the SCOPED context, and read `scope` through a getter so a
954
+ // late/lazy value is still seen by the card.
955
+ disposeSlots.push(sctx.slots.inject('settings.plugin.item', () => sctx.slots.register({
956
+ name: 'settings.plugin.item',
957
+ key: NS,
958
+ label: 'MCP Pill',
959
+ }, function McpPillCard() {
960
+ return e(SettingsCard, { scope, api })
961
+ })))
962
+ }
963
+ // `ctx.inject(['settingsScope'], cb)` fires only when settingsScope exists;
964
+ // the second wait covers hosts that rename the transport, and its guard
965
+ // keeps the card from registering twice when both names exist.
966
+ ctx.inject(['settingsScope'], registerCard)
967
+ ctx.inject(['configForms'], (sctx) => { if (scope === undefined) registerCard(sctx) })
968
+
969
+ // ── rc.2 seat: the keyed slot `plugins.row.config` ─────────────────────
970
+ //
971
+ // 0.1.7-rc.2 removed `settings.plugin.item`; a bundle row's configuration
972
+ // seat is `plugins.row.config`, declared by the official plugin-manager
973
+ // page, and the row's configure control exists only while an occupant is
974
+ // registered under the exact ledger key ROW_CONFIG_KEY. The old seat is
975
+ // still declared (and still used) on ≤ 0.1.5, so both registrations sit
976
+ // side by side with no version sniffing: each fires only where its own
977
+ // slot is declared, and `slots.inject` disposes the entry when the
978
+ // declaration collapses.
979
+ //
980
+ // The host renders this ONE component in two views:
981
+ // view === 'summary' → the row's one-liner: text only, no controls
982
+ // view === 'page' → the configuration page, i.e. the existing card
983
+ // The optional, host-owned form prop of that contract is deliberately NOT
984
+ // consumed: values keep flowing through the single transport resolved by
985
+ // resolveSettingsScopeFrom (settingsScope ≤ 0.1.5 / configForms ≥ 0.1.7),
986
+ // so this plugin has exactly one read path and one write path.
987
+ //
988
+ // `slots` is awaited NON-GATINGLY, and the callback returns the
989
+ // registration disposer so the entry is owned by the registering fiber and
990
+ // released with the rest of disposeSlots on stop / update / HMR.
991
+ const registerRowConfig = (sctx) => {
992
+ disposeSlots.push(sctx.slots.inject('plugins.row.config', () => sctx.slots.register({
993
+ name: 'plugins.row.config',
994
+ key: ROW_CONFIG_KEY,
995
+ }, function McpPillRowConfig(props) {
996
+ if (props && props.view === 'summary') {
997
+ return e('span', { className: 'dmpRowSummary' }, '显示状态胶囊开关(默认关闭)')
998
+ }
999
+ return e(SettingsCard, { scope, api })
1000
+ })))
1001
+ }
1002
+ ctx.inject(['slots'], registerRowConfig)
884
1003
 
885
1004
  // Pill: fully lifecycle-owned as before, but created hidden — the gate
886
1005
  // inside startPill reveals it only while /status reports pill.enabled
887
- // === true (default off).
888
- ctx.effect(() => startPill(scope), 'dsh-mcp-pill: composer pill')
1006
+ // === true (default off). The getter is re-read on every refresh, so the
1007
+ // settings subscription attaches as soon as the transport arrives.
1008
+ ctx.effect(() => startPill(() => scope), 'dsh-mcp-pill: composer pill')
889
1009
 
890
1010
  // Plugin-card style tag: created lazily by SettingsCard renders and
891
1011
  // removed here so no style is left behind after stop / update.
892
1012
  ctx.effect(() => () => removeCardStyles(), 'dsh-mcp-pill: plugin card style')
893
1013
 
894
- return disposeSlot
1014
+ return () => { for (const dispose of disposeSlots) { try { dispose() } catch (_) {} } }
895
1015
  }
896
1016
 
897
1017
  exports.apply = apply
898
- exports.inject = ['slots', 'settingsScope', 'remote', 'remote.settings']
1018
+ // The rc.2 ledger key lives in one exported constant so a test (and anyone
1019
+ // inspecting the bundle) can compare it with package.json#name plus the row
1020
+ // id in cordis.patch.yml instead of trusting a second literal.
1021
+ exports.ROW_CONFIG_KEY = ROW_CONFIG_KEY
1022
+ // NEVER list 'settingsScope' or 'configForms' here: every inject name is a
1023
+ // REQUIRED gate (Fiber._refresh() deactivates the fiber when one has no
1024
+ // provider), so declaring the optional settings transport would leave this
1025
+ // plugin permanently pending — the exact failure that broke Web boot.
1026
+ // 'slots' is not a version-dependent service: every Web host provides it,
1027
+ // and it is what orders the slot registrations above after the registry
1028
+ // exists.
1029
+ exports.inject = ['slots', 'remote', 'remote.settings']
899
1030
  return module.exports
900
1031
  },
901
1032
  })
package/lib/index.js CHANGED
@@ -7,9 +7,11 @@
7
7
  // patch file's `disabled` marker (loader HMR applies it)
8
8
  //
9
9
  // The visibility of the pill itself is owned by the official settings service:
10
- // this half registers the `mcp-pill` namespace ({ pill: { enabled: false } })
11
- // and mirrors the resolved value into every /status response, so the client
12
- // half can follow it with its existing poll loop. Default is OFF.
10
+ // on ≤ 0.1.5 this half registers the `mcp-pill` namespace
11
+ // ({ pill: { enabled: false } }); on ≥ 0.1.7 the namespace is this entry's own
12
+ // `Config` (same shape, with the leaf marked volatile). Either way the effective
13
+ // value is read on demand and mirrored into every /status response, so the
14
+ // client half can follow it with its existing poll loop. Default is OFF.
13
15
  //
14
16
  // The client half (lib/client.js) is a __ModuleLoader__ web bundle — no
15
17
  // tapIndex, no page-level <script> injection.
@@ -40,6 +42,48 @@ export const inject = ['webServer', 'fs', 'tools']
40
42
 
41
43
  const MCP_NAME = '@deepseek-ai/dsh-mcp-client'
42
44
 
45
+ // ── settings portability helpers ────────────────────────────────────────────
46
+ //
47
+ // `Schema.prototype.volatile` exists only from @deepseek-ai/schemastery 3.18.4
48
+ // (the DSH ≥ 0.1.7-rc.1 corridor). The 0.1.5 line resolves 3.18.1/3.18.2, where
49
+ // that method is undefined and calling it throws, so the two hosts cannot share
50
+ // one literal schema expression: the marker is applied through this probe and
51
+ // degrades to the plain schema where the method is absent.
52
+ const volatile = (schema) => (typeof schema?.volatile === 'function' ? schema.volatile() : schema)
53
+
54
+ // A volatile field's parsed value is a cosmokit cell, not the value itself: it
55
+ // exposes `get()` plus the registered write symbol below and NOTHING else — no
56
+ // `set`. Keying on that symbol (rather than on a `set` method that never exists,
57
+ // or on importing cosmokit, which would add a dependency for nothing) is what
58
+ // makes the read yield the real value. The settings service writes such a field
59
+ // in place without remounting the entry, so the value must be read on demand.
60
+ const VOLATILE_WRITE = Symbol.for('cosmokit.volatile.write')
61
+
62
+ function readVolatile(value) {
63
+ if (value === null || typeof value !== 'object') return value
64
+ if (typeof value.get !== 'function') return value
65
+ if (!(VOLATILE_WRITE in value)) return value
66
+ return value.get()
67
+ }
68
+
69
+ // ── the loader entry's own Config (DSH ≥ 0.1.7-rc.1) ────────────────────────
70
+ //
71
+ // The declarative host has no `ctx.settings.register(namespace, schema, opts)`:
72
+ // a settings namespace exists only as the ACTIVE entry's own Config schema,
73
+ // keyed by the loader entry id (`mcp-pill` — see cordis.patch.yml), and only
74
+ // fields marked volatile produce an editable form (an entry whose schema
75
+ // contains none gets no form at all). The nesting and defaults are kept
76
+ // identical to the legacy `mcp-pill` namespace so the client half reads the same
77
+ // `pill.enabled` on both hosts; `patchFile` stays the row's own field. A
78
+ // volatile field must sit at a fixed object path and never enclose another
79
+ // volatile field, so only the leaf is marked.
80
+ const Config = Schema.object({
81
+ patchFile: Schema.string().default('cordis.patch.yml'),
82
+ pill: Schema.object({
83
+ enabled: volatile(Schema.boolean().default(DEFAULT_SETTINGS.pill.enabled)),
84
+ }).default(cloneSettings(DEFAULT_SETTINGS.pill)),
85
+ })
86
+
43
87
  function resolvePatchFile(ctx, config) {
44
88
  const rel = (config && config.patchFile) ? String(config.patchFile) : 'cordis.patch.yml'
45
89
  if (path.isAbsolute(rel)) return rel
@@ -103,6 +147,10 @@ export function apply(ctx, config) {
103
147
  // Defaults to hidden; the client follows /status, so no direct coupling.
104
148
  const pillState = { enabled: false }
105
149
 
150
+ // True once the settings service is known to be the declarative host, where
151
+ // the effective value lives in this entry's parsed Config instead of a scope.
152
+ let declarativeSettings = false
153
+
106
154
  function readPillEnabled(scope) {
107
155
  try {
108
156
  const value = scope.get()
@@ -112,34 +160,89 @@ export function apply(ctx, config) {
112
160
  }
113
161
  }
114
162
 
115
- ctx.inject(['settings'], (sctx) => {
163
+ // Declarative host: the pill toggle is this entry's parsed Config, and the
164
+ // settings service rewrites a volatile field IN PLACE without remounting the
165
+ // entry — so the value must be read on demand from `config` and never cached
166
+ // at apply time. Unwrapping both levels is harmless when neither is a cell.
167
+ function readEntryPillEnabled() {
116
168
  try {
117
- const schema = Schema.object({
118
- pill: Schema.object({
119
- enabled: Schema.boolean().default(DEFAULT_SETTINGS.pill.enabled),
120
- }).default(cloneSettings(DEFAULT_SETTINGS.pill)),
121
- })
122
- const scope = sctx.settings.register(SETTINGS_NS, schema, {
123
- base: cloneSettings(DEFAULT_SETTINGS),
124
- applies: 'live',
125
- // The official settings service treats a throw as rejection and
126
- // discards the return value, so translate the { ok, errors } contract
127
- // into the throw contract here.
128
- validate: (value) => {
129
- const validated = validateSettings(value)
130
- if (!validated.ok) throw new Error((validated.errors || []).join('; '))
131
- },
132
- })
133
- pillState.enabled = readPillEnabled(scope)
134
- sctx.effect(() => scope.watch(() => {
169
+ const pill = readVolatile(config && config.pill)
170
+ const enabled = readVolatile(pill && pill.enabled)
171
+ return enabled === true
172
+ } catch (_) {
173
+ return false
174
+ }
175
+ }
176
+
177
+ // Single source of truth for the routes: whichever host owns the value, an
178
+ // in-place settings write is observed on the next /status read.
179
+ function pillEnabled() {
180
+ return declarativeSettings ? readEntryPillEnabled() : pillState.enabled
181
+ }
182
+
183
+ ctx.inject(['settings'], (sctx) => {
184
+ // ── settings registration: dual-host ────────────────────────────────────
185
+ //
186
+ // ≤ 0.1.5 ctx.settings.register(namespace, schema, options) exists and this
187
+ // plugin owns its `mcp-pill` namespace exactly as before, mirroring
188
+ // the resolved `pill.enabled` into pillState for /status.
189
+ // ≥ 0.1.7 register() is GONE. The namespace is not registered by the plugin
190
+ // at all: the settings service derives one form per ACTIVE profile
191
+ // entry from that entry's Config schema (keyed by the entry id) and
192
+ // exposes only fields marked volatile. The effective value therefore
193
+ // arrives through this entry's parsed `config`, which is read on
194
+ // demand by pillEnabled() so an in-place volatile write is seen
195
+ // without a remount. configure({ auto: false }) suppresses the
196
+ // official generic form page — the namespace still appears in the
197
+ // client mirror, so the plugin's own settings card keeps working.
198
+ //
199
+ // Nothing throws on either host, and with no settings service at all the
200
+ // pill stays hidden (default off).
201
+ const settingsApi = sctx.settings
202
+ if (!settingsApi) return
203
+
204
+ if (typeof settingsApi.register === 'function') {
205
+ try {
206
+ const schema = Schema.object({
207
+ pill: Schema.object({
208
+ enabled: Schema.boolean().default(DEFAULT_SETTINGS.pill.enabled),
209
+ }).default(cloneSettings(DEFAULT_SETTINGS.pill)),
210
+ })
211
+ const scope = settingsApi.register(SETTINGS_NS, schema, {
212
+ base: cloneSettings(DEFAULT_SETTINGS),
213
+ applies: 'live',
214
+ // The official settings service treats a throw as rejection and
215
+ // discards the return value, so translate the { ok, errors } contract
216
+ // into the throw contract here.
217
+ validate: (value) => {
218
+ const validated = validateSettings(value)
219
+ if (!validated.ok) throw new Error((validated.errors || []).join('; '))
220
+ },
221
+ })
135
222
  pillState.enabled = readPillEnabled(scope)
136
- }), 'dsh-mcp-pill: settings watch')
137
- sctx.effect(() => () => {
138
- pillState.enabled = false
139
- }, 'dsh-mcp-pill: settings fallback')
223
+ sctx.effect(() => scope.watch(() => {
224
+ pillState.enabled = readPillEnabled(scope)
225
+ }), 'dsh-mcp-pill: settings watch')
226
+ sctx.effect(() => () => {
227
+ pillState.enabled = false
228
+ }, 'dsh-mcp-pill: settings fallback')
229
+ } catch (_) {
230
+ // Settings stay optional: without the service the pill remains hidden
231
+ // (default off) and the MCP rows keep working through the patch file.
232
+ }
233
+ return
234
+ }
235
+
236
+ // Declarative host: no register() at all, so the namespace is this entry's
237
+ // Config and every read goes through readEntryPillEnabled().
238
+ declarativeSettings = true
239
+ if (typeof settingsApi.configure !== 'function') return
240
+ try {
241
+ sctx.effect(() => settingsApi.configure({ auto: false }, ctx.fiber), 'dsh-mcp-pill: settings presentation')
140
242
  } catch (_) {
141
- // Settings stay optional: without the service the pill remains hidden
142
- // (default off) and the MCP rows keep working through the patch file.
243
+ // configure() throws when called twice for one fiber. The namespace is
244
+ // still derived from Config, so failing to suppress the generated page is
245
+ // never fatal.
143
246
  }
144
247
  })
145
248
 
@@ -193,7 +296,7 @@ export function apply(ctx, config) {
193
296
  const msg = String((err && err.message) || err)
194
297
  if (/not found|ENOENT|no such file/i.test(msg)) {
195
298
  // The patch file does not exist — nothing to report, not an error.
196
- return { ok: true, patchFile, warning: msg, pill: { enabled: pillState.enabled }, entries: [] }
299
+ return { ok: true, patchFile, warning: msg, pill: { enabled: pillEnabled() }, entries: [] }
197
300
  }
198
301
  throw err
199
302
  }
@@ -210,7 +313,7 @@ export function apply(ctx, config) {
210
313
  return {
211
314
  ok: true,
212
315
  patchFile,
213
- pill: { enabled: pillState.enabled },
316
+ pill: { enabled: pillEnabled() },
214
317
  entries: entries.map((e) => ({
215
318
  id: e.id,
216
319
  serverName: e.serverName || e.id,
@@ -279,6 +382,13 @@ export function apply(ctx, config) {
279
382
  }), 'dsh-mcp-pill: set route')
280
383
  }
281
384
 
385
+ // The plugin object handed to the loader. `Config` is read by cordis as
386
+ // `runtime.Config` (see Registry.plugin) — for an object plugin this property is
387
+ // the equivalent of a class's `static Config`, and it is what the declarative
388
+ // host's settings service uses as this entry's namespace. The named exports
389
+ // above stay for direct importers; the loader unwraps `default` first.
390
+ export default { name, inject, Config, apply }
391
+
282
392
  // Re-export the config contract for backward compatibility (the same public
283
393
  // names as before the config.js split). isPlainObject/cloneSettings stay
284
394
  // module-private, mirroring dsh-tool-adapt's narrower index surface.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mcp-pill",
3
- "version": "0.2.4",
3
+ "version": "0.2.8",
4
4
  "description": "EN: Lifecycle-safe MCP status pill and Settings card for DeepSeek Harness Web. ZH: 面向 DeepSeek Harness Web 的生命周期安全 MCP 状态胶囊与设置卡片。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",