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 +24 -7
- package/lib/client.js +150 -19
- package/lib/index.js +140 -30
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## English
|
|
4
4
|
|
|
5
|
-
**Current release: 0.2.
|
|
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
|
-
(`
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
34
|
-
|
|
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
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
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
|
-
|
|
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
|
|
1014
|
+
return () => { for (const dispose of disposeSlots) { try { dispose() } catch (_) {} } }
|
|
895
1015
|
}
|
|
896
1016
|
|
|
897
1017
|
exports.apply = apply
|
|
898
|
-
|
|
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
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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
|
-
|
|
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
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
//
|
|
142
|
-
//
|
|
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:
|
|
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:
|
|
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