dsh-mcp-pill 0.2.2

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-mcp-pill contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,40 @@
1
+ # dsh-mcp-pill
2
+
3
+ ## English
4
+
5
+ **Current release: 0.2.2** — Remote settings are capability-detected and optional, so older DSH RC hosts continue to start the plugin.
6
+
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
+
9
+ ## 中文
10
+
11
+ 用于 DeepSeek Harness Web 的生命周期安全 MCP 连接状态胶囊。提供本机同源 RPC、官方设置卡片和输入框状态胶囊;优先使用 DSH 0.1.2+ 的 `remote.settings`,旧版 RC 自动回退到 connection API。
12
+
13
+ Global MCP connection status pill for the DSH web UI — official bundle form
14
+ (host RPC + `__ModuleLoader__` client, no tapIndex injection).
15
+
16
+ - `GET /api/mcp-pill/status` — JSON status of every configured MCP connection
17
+ plus `pill.enabled`, the hot host mirror of the visibility toggle.
18
+ - `POST /api/mcp-pill/set` — `{ id, enabled }` toggles a connection via the
19
+ patch file's `disabled` marker (loader HMR applies it).
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).
25
+ - The pill snaps to one of the chat input's four corners (drag to switch);
26
+ the anchor is remembered in `localStorage` (`dsh.mcpPill.anchor`).
27
+ - The pill mounts inside the composer seat (same stacking level as the input
28
+ box) at a normal `z-index`, so DSH web popups (modal / menu / toast) can
29
+ cover it instead of being hidden behind it.
30
+
31
+ ## Install
32
+
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`.
35
+
36
+ ## Config
37
+
38
+ The mounting row (in this package's `cordis.patch.yml`) passes
39
+ `config.patchFile` — the `cordis.patch.yml` holding the `dsh-mcp-client` rows,
40
+ resolved against the profile working directory (default `<cwd>/cordis.patch.yml`).
@@ -0,0 +1,10 @@
1
+ # dsh-mcp-pill bundle patch
2
+ #
3
+ # Mounts the host half (RPC) + client half (web bundle). The `patchFile` config
4
+ # tells the host which cordis.patch.yml holds the dsh-mcp-client rows; it is
5
+ # resolved against the profile working directory (dsh web runs from the profile).
6
+ - insert:
7
+ - id: mcp-pill
8
+ name: 'dsh-mcp-pill'
9
+ config:
10
+ patchFile: 'cordis.patch.yml'
package/lib/client.js ADDED
@@ -0,0 +1,900 @@
1
+ // dsh-mcp-pill — client half (official __ModuleLoader__ web bundle)
2
+ //
3
+ // Browser-only bundle: consumed by the DSH web loader (window.__ModuleLoader__).
4
+ // Not importable in Node. Renders the MCP status pill + panel; the pill snaps
5
+ // to one of the chat input's four corners (drag to switch), position is
6
+ // remembered in localStorage.
7
+ //
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.
12
+ //
13
+ // Lifecycle: every DOM node, observer, timer, rAF and listener created by
14
+ // startPill() is registered with the idempotent disposer returned through
15
+ // ctx.effect(), so stop / update / HMR release everything — including a drag
16
+ // that is still in flight — and a later apply re-mounts from scratch.
17
+
18
+ window.__ModuleLoader__.load({
19
+ id: 'dsh-mcp-pill',
20
+ factory: (require) => {
21
+ const module = { exports: {} }
22
+ const exports = module.exports
23
+ const React = require('react')
24
+ const e = React.createElement
25
+
26
+ const NS = 'mcp-pill'
27
+ const API = '/api/mcp-pill'
28
+ // Dynamic polling intervals (ms): active when any MCP enabled, idle when all disabled
29
+ const POLL_INTERVALS = Object.freeze({
30
+ ACTIVE: 3000, // When at least one MCP is enabled
31
+ IDLE: 10000, // When all MCPs are disabled
32
+ ERROR: 5000, // When last fetch failed
33
+ })
34
+ const ENSURE_MS = 2000
35
+ const ROOT_ID = 'dsh-mcp-pill-root'
36
+ const ANCHOR_KEY = 'dsh.mcpPill.anchor'
37
+ const ANCHORS = Object.freeze(['tl', 'tr', 'bl', 'br'])
38
+
39
+ // Mounted flag owned by the plugin lifecycle: set when the pill starts and
40
+ // reset by the idempotent disposer, so a later apply (update / HMR / re-run)
41
+ // mounts a fresh pill instead of being blocked by a stale flag.
42
+ let pillMounted = false
43
+ let pillDispose = null
44
+
45
+ // ── settings card (official-style, same chrome as the ADAPT card) ───────
46
+
47
+ const FIELDS = [
48
+ { path: ['pill', 'enabled'], kind: 'bool', label: '显示状态胶囊', hint: '默认关闭。开启后 MCP 连接状态胶囊显示在输入框旁;拖动胶囊可吸附四角。' },
49
+ ]
50
+
51
+ function isPlainObject(v) {
52
+ return v !== null && typeof v === 'object' && !Array.isArray(v)
53
+ }
54
+
55
+ function getAt(obj, path) {
56
+ let cur = obj
57
+ for (const key of path) {
58
+ if (!isPlainObject(cur) || !(key in cur)) return undefined
59
+ cur = cur[key]
60
+ }
61
+ return cur
62
+ }
63
+
64
+ function formatValue(field, value) {
65
+ if (field.kind === 'bool') return value ? 'true' : 'false'
66
+ return ''
67
+ }
68
+
69
+ function parseValue(field, text) {
70
+ if (field.kind === 'bool') return text === true || text === 'true'
71
+ return undefined
72
+ }
73
+
74
+ function fieldKey(path) {
75
+ return path.join('.')
76
+ }
77
+
78
+ // Official PluginCard chrome cannot be imported by an out-of-repo plugin
79
+ // (bundle purity). Recreate the same disclosure card so MCP Pill sits in
80
+ // the Plugins list as one expandable <li> beside Shell / Agent loop.
81
+ const CARD_CSS_ID = 'dsh-mcp-pill/plugin-card'
82
+ const CARD_CSS = [
83
+ '.dmpCard{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none;transition:border-color .16s,background .16s}',
84
+ '.dmpCard:hover{border-color:var(--dsw-alias-label-dimmed)}',
85
+ '.dmpCardOpen{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}',
86
+ '.dmpHeader{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}',
87
+ '.dmpHeader:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}',
88
+ '.dmpHeadText{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}',
89
+ '.dmpName{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}',
90
+ '.dmpDescription{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}',
91
+ '.dmpChevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}',
92
+ '.dmpChevronOpen{transform:rotate(180deg)}',
93
+ '.dmpBody{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}',
94
+ '.dmpReadOnly{color:var(--dsw-alias-label-tertiary);margin:12px 0 0;font-size:12px;line-height:1.5}',
95
+ '.dmpPending{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;flex:none;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}',
96
+ '.dmpFooter{border-top:1px solid var(--dsw-alias-border-l2);justify-content:flex-end;align-items:center;gap:8px;padding:12px 0 4px;display:flex}',
97
+ '.dmpFailed{min-width:0;color:var(--dsw-alias-label-error);flex:1;margin:0;font-size:12px;line-height:1.5}',
98
+ '.dmpDiscard,.dmpSave{appearance:none;font:inherit;cursor:pointer;border:1px solid #0000;border-radius:8px;padding:5px 14px;font-size:13px;line-height:1.5}',
99
+ '.dmpDiscard{border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);background:0 0}',
100
+ '.dmpDiscard:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)}',
101
+ '.dmpSave{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}',
102
+ '.dmpDiscard:disabled,.dmpSave:disabled{opacity:.4;cursor:default}',
103
+ '.dmpDiscard:focus-visible,.dmpSave:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}',
104
+ '.dmpField{flex-direction:column;gap:6px;padding:12px 0;display:flex}',
105
+ '.dmpField+.dmpField{border-top:1px solid var(--dsw-alias-border-l2)}',
106
+ '.dmpFieldHead{align-items:center;gap:8px;display:flex}',
107
+ '.dmpLabel{min-width:0;color:var(--dsw-alias-label-primary);flex:1;font-size:13px;font-weight:500;line-height:1.5}',
108
+ '.dmpBadges{align-items:center;gap:8px;display:inline-flex}',
109
+ '.dmpBadge{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}',
110
+ '.dmpReset{font:inherit;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;padding:0;font-size:12px;line-height:1.5}',
111
+ '.dmpReset:hover:not(:disabled){color:var(--dsw-alias-label-primary)}',
112
+ '.dmpHint{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:1.5}',
113
+ '.dmpSwitch{appearance:none;width:36px;height:20px;margin:0;border:1px solid var(--dsw-alias-border-l2);border-radius:999px;background:var(--dsw-alias-bg-layer-3);position:relative;cursor:pointer;flex:none}',
114
+ '.dmpSwitch::after{content:"";width:14px;height:14px;border-radius:50%;background:var(--dsw-alias-label-tertiary);position:absolute;top:2px;left:2px;transition:transform .16s,background .16s}',
115
+ '.dmpSwitch:checked{background:var(--dsw-alias-brand-primary);border-color:var(--dsw-alias-brand-primary)}',
116
+ '.dmpSwitch:checked::after{background:var(--dsw-alias-bg-layer-3);transform:translateX(16px)}',
117
+ '.dmpSwitch:disabled{opacity:.4;cursor:default}',
118
+ '.dmpSwitch:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}',
119
+ ].join('')
120
+
121
+ function ensureCardStyles() {
122
+ if (typeof document === 'undefined') return
123
+ if (document.querySelector('style[data-plugin-css=' + JSON.stringify(CARD_CSS_ID) + ']')) return
124
+ const tag = document.createElement('style')
125
+ tag.dataset.plugin = 'dsh-mcp-pill'
126
+ tag.dataset.pluginCss = CARD_CSS_ID
127
+ tag.textContent = CARD_CSS
128
+ document.head.appendChild(tag)
129
+ }
130
+
131
+ // Idempotent inverse of ensureCardStyles(): removed on stop / update /
132
+ // HMR so no style tag is left behind; a later render re-creates it.
133
+ function removeCardStyles() {
134
+ if (typeof document === 'undefined') return
135
+ const tag = document.querySelector('style[data-plugin-css=' + JSON.stringify(CARD_CSS_ID) + ']')
136
+ if (tag && tag.parentNode) tag.parentNode.removeChild(tag)
137
+ }
138
+
139
+ function Chevron(props) {
140
+ return e('svg', {
141
+ width: 14,
142
+ height: 14,
143
+ className: props.className,
144
+ viewBox: '0 0 14 14',
145
+ fill: 'none',
146
+ xmlns: 'http://www.w3.org/2000/svg',
147
+ 'aria-hidden': true,
148
+ }, e('path', {
149
+ d: 'M11.8486 5.5L11.4238 5.92383L8.69727 8.65137C8.44157 8.90706 8.21562 9.13382 8.01172 9.29785C7.79912 9.46883 7.55595 9.61756 7.25 9.66602C7.08435 9.69222 6.91565 9.69222 6.75 9.66602C6.44405 9.61756 6.20088 9.46883 5.98828 9.29785C5.78438 9.13382 5.55843 8.90706 5.30273 8.65137L2.57617 5.92383L2.15137 5.5L3 4.65137L3.42383 5.07617L6.15137 7.80273C6.42595 8.07732 6.59876 8.24849 6.74023 8.3623C6.87291 8.46904 6.92272 8.47813 6.9375 8.48047C6.97895 8.48703 7.02105 8.48703 7.0625 8.48047C7.07728 8.47813 7.12709 8.46904 7.25977 8.3623C7.40124 8.24849 7.57405 8.07732 7.84863 7.80273L10.5762 5.07617L11 4.65137L11.8486 5.5Z',
150
+ fill: 'currentColor',
151
+ }))
152
+ }
153
+
154
+ function FieldRow(props) {
155
+ const head = [
156
+ e('label', { key: 'lab', className: 'dmpLabel', htmlFor: props.id }, props.label),
157
+ ]
158
+ if (props.overridden) {
159
+ head.push(e('span', { key: 'badges', className: 'dmpBadges' },
160
+ e('span', { className: 'dmpBadge' }, '已覆盖'),
161
+ e('button', {
162
+ type: 'button',
163
+ className: 'dmpReset',
164
+ disabled: props.disabled,
165
+ onClick: props.onReset,
166
+ }, '恢复默认'),
167
+ ))
168
+ }
169
+ return e('div', { className: 'dmpField' },
170
+ e('div', { className: 'dmpFieldHead' }, head),
171
+ e('input', {
172
+ id: props.id,
173
+ className: 'dmpSwitch',
174
+ type: 'checkbox',
175
+ checked: props.text === 'true',
176
+ disabled: props.disabled,
177
+ onChange: (ev) => props.onEdit(ev.target.checked ? 'true' : 'false'),
178
+ }),
179
+ e('p', { className: 'dmpHint' }, props.hint || null),
180
+ )
181
+ }
182
+
183
+ function SettingsCard(props) {
184
+ ensureCardStyles()
185
+ const scope = props.scope
186
+ const api = props.api
187
+ const [tick, setTick] = React.useState(0)
188
+ const [open, setOpen] = React.useState(false)
189
+ const [staged, setStaged] = React.useState({})
190
+ const [saving, setSaving] = React.useState(false)
191
+ const [failed, setFailed] = React.useState(false)
192
+
193
+ React.useEffect(() => {
194
+ if (!scope || typeof scope.subscribe !== 'function') return undefined
195
+ return scope.subscribe(() => setTick((n) => n + 1))
196
+ }, [scope])
197
+
198
+ const snap = scope && typeof scope.getSnapshot === 'function'
199
+ ? scope.getSnapshot()
200
+ : { status: 'unavailable', value: undefined, base: undefined, user: undefined, revision: undefined, writable: false }
201
+
202
+ const available = snap.status === 'ready'
203
+ const writable = !!snap.writable
204
+ const value = snap.value || {}
205
+ const base = snap.base || {}
206
+ const user = snap.user || {}
207
+
208
+ const plan = []
209
+ for (const field of FIELDS) {
210
+ const key = fieldKey(field.path)
211
+ const draft = staged[key]
212
+ if (!draft) continue
213
+ if (draft.clear) {
214
+ if (getAt(user, field.path) !== undefined) plan.push({ op: 'unset', path: field.path })
215
+ continue
216
+ }
217
+ const parsed = parseValue(field, draft.text)
218
+ if (formatValue(field, getAt(value, field.path)) === formatValue(field, parsed)) continue
219
+ plan.push({ op: 'set', path: field.path, value: parsed })
220
+ }
221
+
222
+ const dirty = plan.length > 0
223
+ const blocked = !dirty || saving
224
+
225
+ function stage(field, next) {
226
+ setFailed(false)
227
+ setStaged((prev) => Object.assign({}, prev, { [fieldKey(field.path)]: next }))
228
+ }
229
+
230
+ function discard() {
231
+ if (!dirty && !failed) return
232
+ setStaged({})
233
+ setFailed(false)
234
+ }
235
+
236
+ async function save() {
237
+ if (!api || !api.settings || saving || !dirty || !writable) return
238
+ setSaving(true)
239
+ setFailed(false)
240
+ try {
241
+ const ops = plan.map((item) => (
242
+ item.op === 'unset'
243
+ ? { op: 'unset', path: item.path }
244
+ : { op: 'set', path: item.path, value: item.value }
245
+ ))
246
+ const payload = { ns: NS, ops }
247
+ if (snap.revision !== undefined) payload.expectedRevision = snap.revision
248
+ const response = await api.settings.mutate(payload)
249
+ const ok = !!(response && response.result && response.result.ok)
250
+ if (ok) setStaged({})
251
+ else setFailed(true)
252
+ } catch (_) {
253
+ setFailed(true)
254
+ }
255
+ setSaving(false)
256
+ }
257
+
258
+ void tick
259
+ if (!available) return null
260
+
261
+ const fields = FIELDS.map((field) => {
262
+ const key = fieldKey(field.path)
263
+ const draft = staged[key]
264
+ const current = getAt(value, field.path)
265
+ const stored = getAt(user, field.path) !== undefined
266
+ const overridden = draft
267
+ ? !draft.clear
268
+ : stored
269
+ const text = draft ? draft.text : formatValue(field, current)
270
+ return e(FieldRow, {
271
+ key,
272
+ id: 'plugin-config-mcp-pill-' + key.replace(/\./g, '-'),
273
+ kind: field.kind,
274
+ label: field.label,
275
+ hint: field.hint,
276
+ text,
277
+ overridden,
278
+ disabled: !writable || saving,
279
+ onEdit: (next) => stage(field, { text: next, clear: false }),
280
+ onReset: () => stage(field, { text: formatValue(field, getAt(base, field.path)), clear: true }),
281
+ })
282
+ })
283
+
284
+ const body = open ? e('div', { className: 'dmpBody' },
285
+ writable ? null : e('p', { className: 'dmpReadOnly', role: 'status' }, '本部署的设置为只读。'),
286
+ fields,
287
+ e('div', { className: 'dmpFooter' },
288
+ failed ? e('p', { className: 'dmpFailed', role: 'status' }, '本部署没有接受这些值,已保留供你修改。') : null,
289
+ e('button', {
290
+ type: 'button',
291
+ className: 'dmpDiscard',
292
+ disabled: !dirty || saving,
293
+ onClick: discard,
294
+ }, '放弃修改'),
295
+ e('button', {
296
+ type: 'button',
297
+ className: 'dmpSave',
298
+ disabled: blocked || !writable,
299
+ onClick: save,
300
+ }, saving ? '保存中…' : '保存'),
301
+ ),
302
+ ) : null
303
+
304
+ return e('li', { className: open ? 'dmpCard dmpCardOpen' : 'dmpCard' },
305
+ e('button', {
306
+ type: 'button',
307
+ className: 'dmpHeader',
308
+ 'aria-expanded': open,
309
+ 'aria-label': (open ? '收起设置' : '展开设置') + ': MCP Pill',
310
+ onClick: () => setOpen(!open),
311
+ },
312
+ e('span', { className: 'dmpHeadText' },
313
+ e('span', { className: 'dmpName' }, 'MCP Pill'),
314
+ e('span', { className: 'dmpDescription' }, 'MCP 连接状态胶囊。默认隐藏,开启后显示在输入框旁。'),
315
+ ),
316
+ dirty ? e('span', { className: 'dmpPending' }, '未保存') : null,
317
+ e(Chevron, { className: open ? 'dmpChevron dmpChevronOpen' : 'dmpChevron' }),
318
+ ),
319
+ body,
320
+ )
321
+ }
322
+
323
+ // ── composer pill ───────────────────────────────────────────────────────
324
+
325
+ function startPill(scope) {
326
+ if (typeof document === 'undefined' || !document.documentElement) return
327
+ if (pillMounted) {
328
+ // Previous mount leaked (disposal was skipped). Dispose it first, then
329
+ // mount again: every apply must end with exactly one live pill.
330
+ const previous = pillDispose
331
+ pillDispose = null
332
+ if (typeof previous === 'function') previous()
333
+ return startPill()
334
+ }
335
+ pillMounted = true
336
+
337
+ // Idempotent re-mount: drop any root a previous bundle left behind
338
+ // before creating a fresh one.
339
+ const stale = document.getElementById(ROOT_ID)
340
+ if (stale && stale.parentNode) stale.parentNode.removeChild(stale)
341
+
342
+ const cleanups = []
343
+ const track = (fn) => cleanups.push(fn)
344
+
345
+ let disposed = false
346
+ let root = null
347
+ let dragCleanup = null
348
+
349
+ // Mount target is ONLY the composer seat. There is deliberately no
350
+ // document.body fallback (and no composer-card-parent fallback): the pill
351
+ // must stay inside the composer stacking context so DSH overlays can
352
+ // cover it. While the seat has not appeared yet the root stays detached,
353
+ // so no off-page pill floats over the UI; the document observer below
354
+ // mounts it as soon as [data-composer-seat] exists.
355
+ function findSeat() {
356
+ return document.querySelector('[data-composer-seat]')
357
+ }
358
+ function ensureMounted() {
359
+ const seat = findSeat()
360
+ if (!seat) return false
361
+ if (root.parentNode !== seat || !root.isConnected) seat.appendChild(root)
362
+ return true
363
+ }
364
+
365
+ root = document.createElement('div')
366
+ root.id = ROOT_ID
367
+ // Hidden by default (pill.enabled defaults to false): the visibility
368
+ // gate below reveals it only once /status reports pill.enabled === true.
369
+ root.style.cssText = 'position:fixed;z-index:1;display:none;font-family:ui-sans-serif,system-ui,sans-serif;'
370
+
371
+ const shadow = root.attachShadow({ mode: 'open' })
372
+ shadow.innerHTML = '<style>' + [
373
+ ':host{all:initial}',
374
+ '.pill{display:flex;align-items:center;gap:6px;padding:6px 12px;border:1px solid var(--dsw-alias-border-l1,#444);border-radius:999px;background:var(--dsw-alias-bg-layer-1,#222);color:var(--dsw-alias-label-primary,#eee);font-size:12px;cursor:grab;box-shadow:0 2px 8px rgba(0,0,0,.25);user-select:none;touch-action:none}',
375
+ '.pill:active{cursor:grabbing}',
376
+ '.pill:hover{border-color:var(--dsw-alias-brand-primary,#4a9eff)}',
377
+ '.dot{width:8px;height:8px;border-radius:50%;display:inline-block}',
378
+ '.dot.ok{background:var(--dsw-alias-state-success-primary,#3fb950)}',
379
+ '.dot.busy{background:var(--dsw-alias-state-warn-primary,#d29922)}',
380
+ '.dot.off{background:var(--dsw-alias-label-secondary,#999)}',
381
+ '.panel{position:fixed;right:0;bottom:calc(100% + 8px);width:320px;max-height:70vh;overflow:auto;display:none;flex-direction:column;gap:8px;padding:12px;border:1px solid var(--dsw-alias-border-l1,#444);border-radius:12px;background:var(--dsw-alias-bg-layer-1,#222);color:var(--dsw-alias-label-primary,#eee);font-size:12px;box-shadow:0 6px 24px rgba(0,0,0,.35)}',
382
+ '.panel.open{display:flex}',
383
+ '.head{display:flex;align-items:center;gap:8px}',
384
+ '.head b{font-size:13px}',
385
+ '.head .count{margin-left:auto;color:var(--dsw-alias-label-secondary,#999);font-size:11px}',
386
+ '.err{color:var(--dsw-alias-state-error-primary,#f85149);font-size:11px;word-break:break-all}',
387
+ '.entry{border:1px solid var(--dsw-alias-border-l1,#444);border-radius:8px;padding:8px;display:flex;flex-direction:column;gap:4px}',
388
+ '.row{display:flex;align-items:center;gap:6px}',
389
+ '.srv{font-family:ui-monospace,Consolas,monospace;font-weight:600}',
390
+ '.meta{color:var(--dsw-alias-label-secondary,#999);font-size:11px}',
391
+ '.btn{margin-left:auto;padding:2px 10px;border:1px solid var(--dsw-alias-border-l2,#666);border-radius:999px;background:transparent;color:var(--dsw-alias-label-primary,#eee);font-size:11px;cursor:pointer}',
392
+ '.btn:hover:not(:disabled){border-color:var(--dsw-alias-brand-primary,#4a9eff);color:var(--dsw-alias-brand-primary,#4a9eff)}',
393
+ '.btn:disabled{opacity:.5;cursor:default}',
394
+ '.tools{display:flex;flex-wrap:wrap;gap:4px}',
395
+ '.tool{font-family:ui-monospace,Consolas,monospace;font-size:10px;padding:1px 6px;border-radius:6px;background:var(--dsw-alias-bg-layer-2,#333);color:var(--dsw-alias-label-secondary,#bbb)}',
396
+ '.empty{color:var(--dsw-alias-label-secondary,#999);font-size:11px;padding:2px 0}',
397
+ '.close{margin-left:auto;border:none;background:transparent;color:var(--dsw-alias-label-secondary,#999);cursor:pointer;font-size:13px;padding:0 2px}',
398
+ '.close:hover{color:var(--dsw-alias-label-primary,#eee)}'
399
+ ].join('') + '</style>'
400
+
401
+ const state = { entries: [], error: null, busyId: null, open: false }
402
+
403
+ // ── poll scheduling ────────────────────────────────────────────────────
404
+ // One interval is armed for the whole pill life. After every refresh it
405
+ // is re-armed with the interval that fits the latest entries/error
406
+ // state (slower while everything is disabled, faster again after a
407
+ // toggle or while errors persist), and skipped when the interval did
408
+ // not change so the cadence stays stable. Every status/toggle fetch is
409
+ // bounded by FETCH_TIMEOUT_MS so a hung request can neither stall the
410
+ // poll nor wedge a button; in-flight controllers live in `inFlight` and
411
+ // disposePill aborts them all.
412
+ const FETCH_TIMEOUT_MS = 10000
413
+ let pollTimer = null
414
+ let armedMs = null
415
+ const inFlight = new Set()
416
+
417
+ function nextPollMs() {
418
+ if (state.error) return POLL_INTERVALS.ERROR
419
+ return state.entries.some((e) => e.enabled)
420
+ ? POLL_INTERVALS.ACTIVE
421
+ : POLL_INTERVALS.IDLE
422
+ }
423
+
424
+ function armPoll(ms) {
425
+ if (disposed) return
426
+ if (ms === armedMs && pollTimer !== null) return // unchanged — keep cadence
427
+ if (pollTimer !== null) clearInterval(pollTimer)
428
+ armedMs = ms
429
+ pollTimer = setInterval(refresh, ms)
430
+ }
431
+
432
+ async function boundedFetch(url, init) {
433
+ const controller = new AbortController()
434
+ const timer = setTimeout(function () { controller.abort() }, FETCH_TIMEOUT_MS)
435
+ inFlight.add(controller)
436
+ try {
437
+ return await fetch(url, Object.assign({}, init, { signal: controller.signal }))
438
+ } finally {
439
+ clearTimeout(timer)
440
+ inFlight.delete(controller)
441
+ }
442
+ }
443
+
444
+ const pill = document.createElement('button')
445
+ pill.className = 'pill'
446
+ pill.title = 'MCP 连接状态(点击查看工具;拖动可吸附到输入框四角)'
447
+ const pillDot = document.createElement('span')
448
+ pillDot.className = 'dot off'
449
+ const pillText = document.createElement('span')
450
+ pillText.textContent = 'MCP'
451
+ pill.append(pillDot, pillText)
452
+
453
+ const cluster = document.createElement('div')
454
+ cluster.style.cssText = 'display:flex;align-items:center'
455
+ cluster.append(pill)
456
+
457
+ const panel = document.createElement('div')
458
+ panel.className = 'panel'
459
+ const head = document.createElement('div')
460
+ head.className = 'head'
461
+ const title = document.createElement('b')
462
+ title.textContent = 'MCP 连接'
463
+ const count = document.createElement('span')
464
+ count.className = 'count'
465
+ const closeBtn = document.createElement('button')
466
+ closeBtn.className = 'close'
467
+ closeBtn.textContent = '✕'
468
+ head.append(title, count, closeBtn)
469
+ const list = document.createElement('div')
470
+ list.style.cssText = 'display:flex;flex-direction:column;gap:8px'
471
+ panel.append(head, list)
472
+
473
+ shadow.append(cluster, panel)
474
+
475
+ // ── composer-anchored: snaps to one of the input's four corners ────
476
+ // [data-composer-card] is the input card, [data-composer-seat] its
477
+ // dock. Drag to the quadrant you want and it snaps to that corner;
478
+ // the pill only exists while the seat does, so it never floats on
479
+ // the body when the composer is gone.
480
+
481
+ let anchor = 'br'
482
+ try {
483
+ const saved = localStorage.getItem(ANCHOR_KEY)
484
+ if (saved === 'left') anchor = 'bl' // legacy value
485
+ else if (saved === 'right') anchor = 'br' // legacy value
486
+ else if (ANCHORS.indexOf(saved) !== -1) anchor = saved
487
+ } catch (_) {}
488
+
489
+ root.style.right = '16px'
490
+ root.style.bottom = '16px'
491
+
492
+ let lastX = null
493
+ let lastY = null
494
+ let dragging = false
495
+
496
+ function findComposer() {
497
+ return document.querySelector('[data-composer-card]') || findSeat() || null
498
+ }
499
+
500
+ function place() {
501
+ if (dragging || !root.isConnected) return // mid-drag or not mounted yet
502
+ const seat = findComposer()
503
+ if (!seat) return
504
+ const r = seat.getBoundingClientRect()
505
+ const pr = cluster.getBoundingClientRect()
506
+ const gap = 8
507
+ const leftSide = anchor === 'tl' || anchor === 'bl'
508
+ const topSide = anchor === 'tl' || anchor === 'tr'
509
+ let x = leftSide ? r.left - pr.width - gap : r.right + gap
510
+ let y = topSide ? r.top - pr.height - gap : r.bottom + gap
511
+ x = Math.max(4, Math.min(window.innerWidth - pr.width - 4, x))
512
+ y = Math.max(4, Math.min(window.innerHeight - pr.height - 4, y))
513
+ if (x === lastX && y === lastY) return
514
+ lastX = x
515
+ lastY = y
516
+ root.style.left = x + 'px'
517
+ root.style.top = y + 'px'
518
+ root.style.right = 'auto'
519
+ root.style.bottom = 'auto'
520
+ if (panel.classList.contains('open')) placePanel()
521
+ }
522
+
523
+ let rafId = 0
524
+ let rafPending = false
525
+ function schedulePlace() {
526
+ if (disposed || rafPending) return
527
+ rafPending = true
528
+ rafId = requestAnimationFrame(function () {
529
+ rafPending = false
530
+ rafId = 0
531
+ place()
532
+ })
533
+ }
534
+
535
+ // Wait for [data-composer-seat]: observe the whole document so the pill
536
+ // mounts whenever the seat first appears and follows later re-inserts.
537
+ const domObserver = new MutationObserver(function () {
538
+ if (ensureMounted()) schedulePlace()
539
+ })
540
+ domObserver.observe(document.documentElement, { childList: true, subtree: true })
541
+ track(function () { domObserver.disconnect() })
542
+
543
+ // Follow the composer: size changes (multiline input), window resizes,
544
+ // and any scroll container moving it.
545
+ window.addEventListener('scroll', schedulePlace, { capture: true, passive: true })
546
+ track(function () { window.removeEventListener('scroll', schedulePlace, { capture: true }) })
547
+
548
+ window.addEventListener('resize', schedulePlace)
549
+ track(function () { window.removeEventListener('resize', schedulePlace) })
550
+
551
+ const seatRo = new ResizeObserver(schedulePlace)
552
+ let seatObserved = null
553
+ function watchSeat() {
554
+ const seat = findComposer()
555
+ if (seat && seat !== seatObserved) {
556
+ if (seatObserved) seatRo.unobserve(seatObserved)
557
+ seatRo.observe(seat)
558
+ seatObserved = seat
559
+ }
560
+ }
561
+ watchSeat()
562
+ track(function () { seatRo.disconnect() })
563
+
564
+ const seatTimer = setInterval(function () {
565
+ ensureMounted() // React may have re-rendered the composer seat away
566
+ watchSeat()
567
+ place()
568
+ }, ENSURE_MS)
569
+ track(function () { clearInterval(seatTimer) })
570
+
571
+ // Drag to switch corners: while dragging the pill follows the pointer;
572
+ // on release it snaps to the corner matching the quadrant the pill
573
+ // center landed in (left/right × top/bottom) and remembers it.
574
+ let lastDragAt = 0
575
+ function onPointerDown(ev) {
576
+ if (ev.button !== 0 || disposed) return
577
+ ev.preventDefault()
578
+ dragging = true
579
+ panel.classList.remove('open')
580
+ const startX = ev.clientX
581
+ const startY = ev.clientY
582
+ const startLeft = root.getBoundingClientRect().left
583
+ const startTop = root.getBoundingClientRect().top
584
+ let moved = false
585
+ const prevSelect = document.body.style.userSelect
586
+ document.body.style.userSelect = 'none'
587
+ function onMove(mv) {
588
+ const dx = mv.clientX - startX
589
+ const dy = mv.clientY - startY
590
+ if (Math.abs(dx) + Math.abs(dy) > 3) moved = true
591
+ root.style.left = (startLeft + dx) + 'px'
592
+ root.style.top = (startTop + dy) + 'px'
593
+ root.style.right = 'auto'
594
+ root.style.bottom = 'auto'
595
+ }
596
+ function onUp() {
597
+ dragCleanup = null
598
+ document.removeEventListener('pointermove', onMove)
599
+ document.removeEventListener('pointerup', onUp)
600
+ document.body.style.userSelect = prevSelect
601
+ dragging = false
602
+ if (!moved) return
603
+ lastDragAt = Date.now()
604
+ const rect = root.getBoundingClientRect()
605
+ const cx = rect.left + rect.width / 2
606
+ const cy = rect.top + rect.height / 2
607
+ const seat = findComposer()
608
+ if (seat) {
609
+ const r = seat.getBoundingClientRect()
610
+ const leftSide = cx < r.left + r.width / 2
611
+ const topSide = cy < r.top + r.height / 2
612
+ anchor = (topSide ? 't' : 'b') + (leftSide ? 'l' : 'r')
613
+ try { localStorage.setItem(ANCHOR_KEY, anchor) } catch (_) {}
614
+ }
615
+ lastX = null
616
+ lastY = null
617
+ place()
618
+ }
619
+ // Disposal during an active drag must still release the document-level
620
+ // listeners and restore the body selection style.
621
+ dragCleanup = function () {
622
+ document.removeEventListener('pointermove', onMove)
623
+ document.removeEventListener('pointerup', onUp)
624
+ document.body.style.userSelect = prevSelect
625
+ dragging = false
626
+ }
627
+ document.addEventListener('pointermove', onMove)
628
+ document.addEventListener('pointerup', onUp)
629
+ }
630
+ cluster.addEventListener('pointerdown', onPointerDown)
631
+ track(function () { cluster.removeEventListener('pointerdown', onPointerDown) })
632
+
633
+ function placePanel() {
634
+ // The panel is positioned in VIEWPORT coordinates so it follows the
635
+ // pill wherever it sits.
636
+ panel.style.position = 'fixed'
637
+ const r = cluster.getBoundingClientRect()
638
+ const pw = panel.offsetWidth || 320
639
+ const ph = panel.offsetHeight || 320
640
+ let left = r.left
641
+ const maxLeft = Math.max(4, window.innerWidth - pw - 4)
642
+ if (left > maxLeft) left = maxLeft
643
+ if (left < 4) left = 4
644
+ const below = window.innerHeight - r.bottom - 8 >= ph || r.top < ph + 8
645
+ if (below) {
646
+ panel.style.left = left + 'px'
647
+ panel.style.top = (r.bottom + 8) + 'px'
648
+ panel.style.right = 'auto'
649
+ panel.style.bottom = 'auto'
650
+ } else {
651
+ panel.style.left = left + 'px'
652
+ panel.style.top = 'auto'
653
+ panel.style.right = 'auto'
654
+ panel.style.bottom = (window.innerHeight - r.top + 8) + 'px'
655
+ }
656
+ }
657
+
658
+ function statusDot(entry) {
659
+ return entry.connected ? 'ok' : entry.enabled ? 'busy' : 'off'
660
+ }
661
+ function statusText(entry) {
662
+ if (!entry.enabled) return '已断开'
663
+ return entry.connected ? (entry.toolCount + ' 工具') : '已掉线'
664
+ }
665
+
666
+ function render() {
667
+ const connected = state.entries.filter((e) => e.connected).length
668
+ pillDot.className = 'dot ' + (connected > 0 ? 'ok' : state.entries.some((e) => e.enabled) ? 'busy' : 'off')
669
+ pillText.textContent = 'MCP ' + connected + '/' + state.entries.length
670
+ count.textContent = state.entries.length + ' 个'
671
+ list.textContent = ''
672
+ if (state.error) {
673
+ const err = document.createElement('div')
674
+ err.className = 'err'
675
+ err.textContent = state.error
676
+ list.append(err)
677
+ }
678
+ if (!state.entries.length) {
679
+ const empty = document.createElement('div')
680
+ empty.className = 'empty'
681
+ empty.textContent = '未配置 MCP 服务器(cordis.patch.yml)'
682
+ list.append(empty)
683
+ return
684
+ }
685
+ for (const e of state.entries) {
686
+ const entry = document.createElement('div')
687
+ entry.className = 'entry'
688
+
689
+ const row = document.createElement('div')
690
+ row.className = 'row'
691
+ const dot = document.createElement('span')
692
+ dot.className = 'dot ' + statusDot(e)
693
+ const srv = document.createElement('span')
694
+ srv.className = 'srv'
695
+ srv.textContent = e.serverName
696
+ const meta = document.createElement('span')
697
+ meta.className = 'meta'
698
+ meta.textContent = (e.transport || '') + ' · ' + statusText(e)
699
+ const btn = document.createElement('button')
700
+ btn.className = 'btn'
701
+ btn.textContent = e.enabled ? (e.connected ? '断开' : '重连') : '连接'
702
+ btn.disabled = state.busyId === e.id
703
+ btn.addEventListener('click', () => toggle(e, e.enabled && !e.connected))
704
+ row.append(dot, srv, meta, btn)
705
+ entry.append(row)
706
+
707
+ if (e.connected && e.tools && e.tools.length) {
708
+ const tools = document.createElement('div')
709
+ tools.className = 'tools'
710
+ for (const t of e.tools) {
711
+ const chip = document.createElement('span')
712
+ chip.className = 'tool'
713
+ chip.textContent = t
714
+ chip.title = t
715
+ tools.append(chip)
716
+ }
717
+ entry.append(tools)
718
+ }
719
+ list.append(entry)
720
+ }
721
+ }
722
+
723
+ async function refresh() {
724
+ try {
725
+ const res = await boundedFetch(API + '/status', { cache: 'no-store' })
726
+ const data = await res.json()
727
+ if (data && data.ok) {
728
+ state.entries = data.entries || []
729
+ state.error = null
730
+ noteStatusData(data)
731
+ } else {
732
+ state.error = (data && data.error) || '状态获取失败'
733
+ }
734
+ } catch (err) {
735
+ state.error = (err && err.name === 'AbortError')
736
+ ? '状态获取超时'
737
+ : String((err && err.message) || err)
738
+ }
739
+ render()
740
+ // Re-arm with the interval matching the latest state. armPoll is a
741
+ // no-op once disposed, so an in-flight refresh cannot revive the poll.
742
+ armPoll(nextPollMs())
743
+ }
744
+
745
+ async function toggle(entry, restart) {
746
+ state.busyId = entry.id
747
+ state.error = null
748
+ render()
749
+ try {
750
+ const body = restart
751
+ ? { id: entry.id, enabled: true, restart: true }
752
+ : { id: entry.id, enabled: !entry.enabled }
753
+ const res = await boundedFetch(API + '/set', {
754
+ method: 'POST',
755
+ headers: { 'Content-Type': 'application/json' },
756
+ body: JSON.stringify(body),
757
+ })
758
+ const data = await res.json()
759
+ if (!(data && data.ok)) state.error = (data && data.error) || '操作失败'
760
+ } catch (err) {
761
+ state.error = (err && err.name === 'AbortError')
762
+ ? '操作超时'
763
+ : String((err && err.message) || err)
764
+ }
765
+ state.busyId = null
766
+ await refresh()
767
+ }
768
+
769
+ function onPillClick() {
770
+ if (Date.now() - lastDragAt < 350) return // a drag just happened — not a click
771
+ state.open = !state.open
772
+ panel.classList.toggle('open', state.open)
773
+ if (state.open) {
774
+ placePanel()
775
+ refresh()
776
+ }
777
+ }
778
+ function onCloseClick() {
779
+ state.open = false
780
+ panel.classList.remove('open')
781
+ }
782
+ pill.addEventListener('click', onPillClick)
783
+ track(function () { pill.removeEventListener('click', onPillClick) })
784
+ closeBtn.addEventListener('click', onCloseClick)
785
+ track(function () { closeBtn.removeEventListener('click', onCloseClick) })
786
+
787
+ refresh()
788
+
789
+ // Arm the poll once and register a single tracked cleanup: the closure
790
+ // reads the live pollTimer binding, so dispose always clears the timer
791
+ // that is currently armed, no matter how often it was re-armed.
792
+ armPoll(POLL_INTERVALS.ACTIVE)
793
+ track(function () {
794
+ if (pollTimer !== null) clearInterval(pollTimer)
795
+ pollTimer = null
796
+ armedMs = null
797
+ })
798
+
799
+ // ── visibility gate: 设置 → 插件 → MCP Pill「显示状态胶囊」 ────────────
800
+ // Default OFF: the root is created display:none and only the polled
801
+ // /status payload (hot host mirror of pill.enabled) may reveal it.
802
+ // Placed after every binding above is initialized because
803
+ // applyVisibility() can call schedulePlace().
804
+ let pillVisible = false
805
+
806
+ function applyVisibility() {
807
+ if (!root) return
808
+ root.style.display = pillVisible ? '' : 'none'
809
+ if (!pillVisible) {
810
+ if (panel.classList.contains('open')) panel.classList.remove('open')
811
+ } else {
812
+ schedulePlace() // cluster rect was 0 while hidden — reposition now
813
+ }
814
+ }
815
+
816
+ function noteStatusData(data) {
817
+ const show = !!(data && data.pill && data.pill.enabled === true)
818
+ if (show === pillVisible) return
819
+ pillVisible = show
820
+ applyVisibility()
821
+ }
822
+
823
+ if (scope && typeof scope.subscribe === 'function') {
824
+ // A settings save reaches the host mirror immediately; re-fetch right
825
+ // away so the pill flips without waiting for the next poll. The
826
+ // dynamic poll below remains the safety net (and covers other tabs).
827
+ const unsubscribe = scope.subscribe(function () { refresh() })
828
+ track(function () { unsubscribe() })
829
+ }
830
+
831
+ // Mount immediately when the seat already exists; otherwise the document
832
+ // observer mounts the pill as soon as [data-composer-seat] appears.
833
+ ensureMounted()
834
+ place()
835
+
836
+ function disposePill() {
837
+ if (disposed) return
838
+ disposed = true
839
+ pillMounted = false
840
+ for (const controller of inFlight) {
841
+ try { controller.abort() } catch (_) {}
842
+ }
843
+ inFlight.clear()
844
+ if (rafPending && rafId !== 0) cancelAnimationFrame(rafId)
845
+ rafPending = false
846
+ rafId = 0
847
+ if (dragCleanup) {
848
+ const cleanupDrag = dragCleanup
849
+ dragCleanup = null
850
+ cleanupDrag()
851
+ }
852
+ for (let i = cleanups.length - 1; i >= 0; i--) {
853
+ try { cleanups[i]() } catch (_) {}
854
+ }
855
+ cleanups.length = 0
856
+ if (root && root.parentNode) root.parentNode.removeChild(root)
857
+ root = null
858
+ pillDispose = null
859
+ }
860
+ pillDispose = disposePill
861
+ return disposePill
862
+ }
863
+
864
+ function apply(ctx) {
865
+ if (typeof document === 'undefined') return
866
+
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
+ // Compatibility layer: DSH 0.1.2 exposes fine-grained remote settings;
871
+ // older RC builds keep the legacy connection API.
872
+ const remote = ctx.get('remote')
873
+ const api = remote && remote.settings
874
+ ? { settings: { mutate: (payload) => remote.settings.mutate(payload.ns, payload.ops, payload.expectedRevision).then((result) => ({ result })) } }
875
+ : (ctx.get('connection') && ctx.get('connection').api)
876
+ const disposeSlot = ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({
877
+ name: 'settings.plugin.item',
878
+ key: NS,
879
+ label: 'MCP Pill',
880
+ }, function McpPillCard() {
881
+ return e(SettingsCard, { scope, api })
882
+ }))
883
+
884
+ // Pill: fully lifecycle-owned as before, but created hidden — the gate
885
+ // inside startPill reveals it only while /status reports pill.enabled
886
+ // === true (default off).
887
+ ctx.effect(() => startPill(scope), 'dsh-mcp-pill: composer pill')
888
+
889
+ // Plugin-card style tag: created lazily by SettingsCard renders and
890
+ // removed here so no style is left behind after stop / update.
891
+ ctx.effect(() => () => removeCardStyles(), 'dsh-mcp-pill: plugin card style')
892
+
893
+ return disposeSlot
894
+ }
895
+
896
+ exports.apply = apply
897
+ exports.inject = ['slots', 'settingsScope', 'connection']
898
+ return module.exports
899
+ },
900
+ })
package/lib/config.js ADDED
@@ -0,0 +1,54 @@
1
+ // Shared config contract for dsh-mcp-pill.
2
+ // Nested runtime shape stays the same as the historical Settings namespace.
3
+ // Official Settings Card writes the same nested section through path mutate.
4
+
5
+ export const SETTINGS_NS = 'mcp-pill'
6
+
7
+ export const DEFAULT_SETTINGS = {
8
+ pill: {
9
+ enabled: false,
10
+ },
11
+ }
12
+
13
+ export function isPlainObject(v) {
14
+ return v !== null && typeof v === 'object' && !Array.isArray(v)
15
+ }
16
+
17
+ export function cloneSettings(config) {
18
+ return JSON.parse(JSON.stringify(config || DEFAULT_SETTINGS))
19
+ }
20
+
21
+ export function validateSettings(raw) {
22
+ if (!isPlainObject(raw)) return { ok: false, errors: ['settings must be a JSON object'] }
23
+
24
+ const errors = []
25
+ const allowedKeys = ['pill']
26
+ for (const key of Object.keys(raw)) {
27
+ if (!allowedKeys.includes(key)) {
28
+ errors.push('unknown top-level key: ' + key)
29
+ }
30
+ }
31
+
32
+ const d = DEFAULT_SETTINGS
33
+ const pillRaw = isPlainObject(raw.pill) ? raw.pill : {}
34
+ if (raw.pill !== undefined && !isPlainObject(raw.pill)) {
35
+ errors.push('pill must be an object')
36
+ }
37
+ for (const key of Object.keys(pillRaw)) {
38
+ if (key !== 'enabled') errors.push('unknown pill key: ' + key)
39
+ }
40
+
41
+ const pill = {
42
+ enabled: checkBool(errors, 'pill.enabled', pillRaw.enabled, d.pill.enabled),
43
+ }
44
+
45
+ if (errors.length > 0) return { ok: false, errors }
46
+ return { ok: true, config: { pill } }
47
+ }
48
+
49
+ function checkBool(errors, path, value, fallback) {
50
+ if (value === undefined) return fallback
51
+ if (typeof value === 'boolean') return value
52
+ errors.push(path + ' must be a boolean')
53
+ return fallback
54
+ }
package/lib/index.js ADDED
@@ -0,0 +1,285 @@
1
+ // dsh-mcp-pill — host half (official bundle form)
2
+ //
3
+ // Serves the JSON RPC for the client half:
4
+ // GET /api/mcp-pill/status -> JSON status of every configured MCP connection
5
+ // plus `pill.enabled` (the visibility toggle)
6
+ // POST /api/mcp-pill/set -> { id, enabled } toggles a connection via the
7
+ // patch file's `disabled` marker (loader HMR applies it)
8
+ //
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.
13
+ //
14
+ // The client half (lib/client.js) is a __ModuleLoader__ web bundle — no
15
+ // tapIndex, no page-level <script> injection.
16
+ //
17
+ // Config (from the mounting row):
18
+ // patchFile -> the cordis.patch.yml to parse MCP entries from.
19
+ // Default: <profile>/cordis.patch.yml.
20
+ //
21
+ // Resolution: the web process's cwd is NOT the profile directory — it is the
22
+ // shell it was launched from (often the home dir). The authoritative anchor
23
+ // is ctx.baseUrl (the profile dir, set by the boot include), so relative
24
+ // patchFile values resolve against it first, falling back to process.cwd()
25
+ // for standalone boots that load this plugin outside a profile.
26
+
27
+ import path from 'node:path'
28
+ import { fileURLToPath } from 'node:url'
29
+ import Schema from '@deepseek-ai/schemastery'
30
+ import {
31
+ SETTINGS_NS,
32
+ DEFAULT_SETTINGS,
33
+ isPlainObject,
34
+ cloneSettings,
35
+ validateSettings,
36
+ } from './config.js'
37
+
38
+ export const name = 'dsh-mcp-pill'
39
+ export const inject = ['webServer', 'fs', 'tools']
40
+
41
+ const MCP_NAME = '@deepseek-ai/dsh-mcp-client'
42
+
43
+ function resolvePatchFile(ctx, config) {
44
+ const rel = (config && config.patchFile) ? String(config.patchFile) : 'cordis.patch.yml'
45
+ if (path.isAbsolute(rel)) return rel
46
+ try {
47
+ if (ctx && ctx.baseUrl) return fileURLToPath(new URL(rel, ctx.baseUrl))
48
+ } catch (_) { /* not a URL — fall through to cwd */ }
49
+ return path.resolve(process.cwd(), rel)
50
+ }
51
+
52
+ function parseEntries(text) {
53
+ const entries = []
54
+ const topBlocks = text.split(/\n(?=- )/)
55
+ for (const top of topBlocks) {
56
+ const rows = top.split(/\n(?= {4}- )/)
57
+ for (const row of rows) {
58
+ if (!row.includes(MCP_NAME)) continue
59
+ const idMatch = row.match(/(?:^|\n)\s*- id:\s*([^\s]+)/)
60
+ const serverName = (row.match(/serverName:\s*([^\s]+)/) || [])[1]
61
+ const transport = (row.match(/transport:\s*([^\s]+)/) || [])[1]
62
+ const disabled = /disabled:\s*true/.test(row)
63
+ if (idMatch) entries.push({ id: idMatch[1], serverName, transport, disabled })
64
+ }
65
+ }
66
+ return entries
67
+ }
68
+
69
+ function setEntryDisabled(text, id, disabled) {
70
+ if (typeof id !== 'string' || !/^[A-Za-z0-9_-]+$/.test(id)) {
71
+ return { ok: false, error: '非法条目 id' }
72
+ }
73
+ // 全行锚定:- id: <id> 独占一行,避免前缀碰撞(如 id=mcp 误中 mcp-deveco)
74
+ const re = new RegExp('(^|\\n)\\s*- id:\\s*' + id + '\\s*(\\n|$)')
75
+ const m = re.exec(text)
76
+ if (!m) return { ok: false, error: 'patch 文件中未找到条目 ' + id }
77
+ const start = m.index + m[0].indexOf('- id:')
78
+ const lineStart = text.lastIndexOf('\n', start) + 1
79
+ const lineEnd = text.indexOf('\n', start)
80
+ const rest = text.slice(lineEnd + 1)
81
+ const nextRow = rest.search(/\n {4}- |\n- /)
82
+ const blockEnd = nextRow < 0 ? text.length : lineEnd + 1 + nextRow
83
+ const block = text.slice(lineStart, blockEnd)
84
+ if (!block.includes(MCP_NAME)) return { ok: false, error: '条目 ' + id + ' 不是 dsh-mcp-client 条目' }
85
+ if (disabled) {
86
+ if (/disabled:\s*true/.test(block)) return { ok: true, changed: false }
87
+ const insertAt = text.indexOf('\n', start) + 1
88
+ return { ok: true, changed: true, next: text.slice(0, insertAt) + ' disabled: true\n' + text.slice(insertAt) }
89
+ }
90
+ const m2 = block.match(/\n[ \t]*disabled:\s*true/)
91
+ if (!m2) return { ok: true, changed: false }
92
+ const at = lineStart + m2.index
93
+ return { ok: true, changed: true, next: text.slice(0, at) + text.slice(at + m2[0].length) }
94
+ }
95
+
96
+ export function apply(ctx, config) {
97
+ const ws = ctx.webServer
98
+ if (!ws) return
99
+
100
+ const patchFile = resolvePatchFile(ctx, config)
101
+
102
+ // Visibility toggle state, mirrored from the official settings service.
103
+ // Defaults to hidden; the client follows /status, so no direct coupling.
104
+ const pillState = { enabled: false }
105
+
106
+ function readPillEnabled(scope) {
107
+ try {
108
+ const value = scope.get()
109
+ return !!(isPlainObject(value) && isPlainObject(value.pill) && value.pill.enabled === true)
110
+ } catch (_) {
111
+ return false
112
+ }
113
+ }
114
+
115
+ ctx.inject(['settings'], (sctx) => {
116
+ 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(() => {
135
+ pillState.enabled = readPillEnabled(scope)
136
+ }), 'dsh-mcp-pill: settings watch')
137
+ sctx.effect(() => () => {
138
+ pillState.enabled = false
139
+ }, 'dsh-mcp-pill: settings fallback')
140
+ } 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.
143
+ }
144
+ })
145
+
146
+ function json(res, code, data) {
147
+ const body = JSON.stringify(data)
148
+ res.writeHead(code, {
149
+ 'Content-Type': 'application/json; charset=utf-8',
150
+ 'Cache-Control': 'no-store',
151
+ })
152
+ res.end(body)
153
+ }
154
+
155
+ async function readBody(req) {
156
+ const chunks = []
157
+ let total = 0
158
+ for await (const chunk of req) {
159
+ total += chunk.length
160
+ if (total > 8192) throw Object.assign(new Error('request body too large'), { status: 413 })
161
+ chunks.push(chunk)
162
+ }
163
+ return Buffer.concat(chunks).toString('utf8')
164
+ }
165
+
166
+ // Loopback-only web server: allow same-origin browser POSTs (Origin matches
167
+ // our own Host) and local tooling without an Origin header; reject foreign
168
+ // Origins outright as CSRF protection.
169
+ function originAllowed(req) {
170
+ const origin = req.headers.origin
171
+ if (!origin) return true // non-browser local caller (curl / MCP tooling)
172
+ const host = req.headers.host || ''
173
+ const base = /^https?:\/\/([^/]+)/i.exec(origin)
174
+ if (!base) return false
175
+ return base[1] === host
176
+ }
177
+
178
+ // DNS-rebinding fence: the web server only binds loopback, so a legitimate
179
+ // request's Host header must name the loopback host. A rebinding attack
180
+ // resolves an attacker domain to 127.0.0.1 and sends Host: attacker.com.
181
+ function hostAllowed(req) {
182
+ let host = (req.headers.host || '').split(':')[0].toLowerCase()
183
+ host = host.replace(/^\[|\]$/g, '')
184
+ return host === '127.0.0.1' || host === 'localhost' || host === '::1'
185
+ }
186
+
187
+ async function status() {
188
+ let target, text
189
+ try {
190
+ target = await ctx.fs.resolve(patchFile)
191
+ text = await ctx.fs.readText(target)
192
+ } catch (err) {
193
+ const msg = String((err && err.message) || err)
194
+ if (/not found|ENOENT|no such file/i.test(msg)) {
195
+ // The patch file does not exist — nothing to report, not an error.
196
+ return { ok: true, patchFile, warning: msg, pill: { enabled: pillState.enabled }, entries: [] }
197
+ }
198
+ throw err
199
+ }
200
+ const entries = parseEntries(text)
201
+ const schemas = ctx.tools.schemas()
202
+ const byServer = {}
203
+ for (const s of schemas) {
204
+ const m = /^mcp__([A-Za-z0-9_-]+)__/.exec(s.name || '')
205
+ if (m) {
206
+ if (!byServer[m[1]]) byServer[m[1]] = []
207
+ byServer[m[1]].push(s.name)
208
+ }
209
+ }
210
+ return {
211
+ ok: true,
212
+ patchFile,
213
+ pill: { enabled: pillState.enabled },
214
+ entries: entries.map((e) => ({
215
+ id: e.id,
216
+ serverName: e.serverName || e.id,
217
+ transport: e.transport || 'unknown',
218
+ enabled: !e.disabled,
219
+ connected: !e.disabled && (byServer[e.serverName] || []).length > 0,
220
+ toolCount: (byServer[e.serverName] || []).length,
221
+ tools: (byServer[e.serverName] || []).slice(),
222
+ })),
223
+ }
224
+ }
225
+
226
+ ctx.effect(() => ws.register({
227
+ kind: 'exact',
228
+ path: '/api/mcp-pill/status',
229
+ handler: async (req, res) => {
230
+ try {
231
+ json(res, 200, await status())
232
+ } catch (err) {
233
+ json(res, 500, { ok: false, error: String((err && err.message) || err) })
234
+ }
235
+ },
236
+ }), 'dsh-mcp-pill: status route')
237
+
238
+ ctx.effect(() => ws.register({
239
+ kind: 'exact',
240
+ path: '/api/mcp-pill/set',
241
+ handler: async (req, res) => {
242
+ if (req.method !== 'POST') return json(res, 405, { ok: false, error: 'POST required' })
243
+ if (!hostAllowed(req)) return json(res, 403, { ok: false, error: 'host not allowed' })
244
+ if (!originAllowed(req)) return json(res, 403, { ok: false, error: 'origin not allowed' })
245
+ try {
246
+ let body
247
+ try {
248
+ body = JSON.parse((await readBody(req)) || '{}')
249
+ } catch (err) {
250
+ if (err && err.status === 413) return json(res, 413, { ok: false, error: 'request body too large' })
251
+ return json(res, 400, { ok: false, error: 'invalid JSON body' })
252
+ }
253
+ const id = body && body.id
254
+ const enabled = !!(body && body.enabled)
255
+ const restart = !!(body && body.restart)
256
+ if (!id) return json(res, 400, { ok: false, error: '缺少 id 参数' })
257
+ const target = await ctx.fs.resolve(patchFile)
258
+ const text = await ctx.fs.readText(target)
259
+ if (restart) {
260
+ // restart: 断开并立即重新挂载该连接(先置 disabled,再恢复)
261
+ const off = setEntryDisabled(text, id, true)
262
+ if (!off.ok) return json(res, 404, off)
263
+ const offText = off.changed ? off.next : text
264
+ if (off.changed) await ctx.fs.writeText(target, offText)
265
+ await new Promise((r2) => setTimeout(r2, 150))
266
+ const on = setEntryDisabled(offText, id, false)
267
+ if (!on.ok) return json(res, 404, on)
268
+ if (on.changed) await ctx.fs.writeText(target, on.next)
269
+ return json(res, 200, { ok: true, id, enabled: true, changed: true, restarted: true })
270
+ }
271
+ const r = setEntryDisabled(text, id, !enabled)
272
+ if (!r.ok) return json(res, 404, r)
273
+ if (r.changed) await ctx.fs.writeText(target, r.next)
274
+ json(res, 200, { ok: true, id, enabled, changed: !!r.changed })
275
+ } catch (err) {
276
+ json(res, 500, { ok: false, error: String((err && err.message) || err) })
277
+ }
278
+ },
279
+ }), 'dsh-mcp-pill: set route')
280
+ }
281
+
282
+ // Re-export the config contract for backward compatibility (the same public
283
+ // names as before the config.js split). isPlainObject/cloneSettings stay
284
+ // module-private, mirroring dsh-tool-adapt's narrower index surface.
285
+ export { SETTINGS_NS, DEFAULT_SETTINGS, validateSettings }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "dsh-mcp-pill",
3
+ "version": "0.2.2",
4
+ "description": "EN: Lifecycle-safe MCP status pill and Settings card for DeepSeek Harness Web. ZH: 面向 DeepSeek Harness Web 的生命周期安全 MCP 状态胶囊与设置卡片。",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./client": "./lib/client.js",
10
+ "./config": "./lib/config.js",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "files": [
14
+ "lib/",
15
+ "cordis.patch.yml",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "scripts": {
20
+ "test": "node --test"
21
+ },
22
+ "dependencies": {
23
+ "@deepseek-ai/schemastery": "^3.18.1"
24
+ },
25
+ "dsh": {
26
+ "bundle": {
27
+ "patch": "./cordis.patch.yml"
28
+ },
29
+ "client": {
30
+ "platform": "web",
31
+ "inject": ["@deepseek-ai/dsh-api-remotes"]
32
+ }
33
+ },
34
+ "repository": { "type": "git", "url": "git+https://github.com/YrracOwl/dsh-mcp-pill.git" },
35
+ "homepage": "https://github.com/YrracOwl/dsh-mcp-pill#readme",
36
+ "bugs": { "url": "https://github.com/YrracOwl/dsh-mcp-pill/issues" },
37
+ "license": "MIT",
38
+ "engines": {
39
+ "node": ">=18"
40
+ }
41
+ }