dsh-plugin-admin 0.3.0 → 0.3.1

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/lib/index.js CHANGED
@@ -1,2128 +1,2144 @@
1
- /**
2
- * dsh-plugin-admin host half. Zero dsh imports on purpose: everything rides
3
- * the live Cordis Context (services by key) and plain-data typert registration.
4
- *
5
- * Remote surfaces served by the /api RPC gateway:
6
- *
7
- * 1. Namespace `pluginAdmin`:
8
- * - list() → the profile's bundle layers with version/source/removable
9
- * - install(spec) → `pnpm add <spec>` in the profile directory, then
10
- * reconcile the package.json `dsh.profile.bundles` layer list
11
- * - remove(name) → `pnpm remove <name>` + the same reconcile
12
- *
13
- * 2. Namespace `sessionAdmin`:
14
- * - list() → all persisted sessions with archived/live flags
15
- * - archive(sessionId) → mark session as archived in workspace registry
16
- * - unarchive(sessionId) → remove from the registry's archived set
17
- * - deleteSession(sessionId) → rm the session log directory, detach workspace
18
- * accounting, and clear any archived-set entry
19
- */
20
-
21
- import { spawn, spawnSync } from 'node:child_process'
22
- import { existsSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
23
- import { rm } from 'node:fs/promises'
24
- import { createRequire } from 'node:module'
25
- import { basename, dirname, join } from 'node:path'
26
- import { homedir } from 'node:os'
27
- import { fileURLToPath } from 'node:url'
28
- import { setTimeout as sleep } from 'node:timers/promises'
29
-
30
- /** Services required before this plugin mounts. */
31
- export const inject = ['typert', 'workspaceRegistry', 'sessionPersistence']
32
-
33
- const PLUGIN_SERVICE_KEY = 'pluginAdmin'
34
- const PLUGIN_NAMESPACE = 'pluginAdmin'
35
- const SESSION_SERVICE_KEY = 'sessionAdmin'
36
- const SESSION_NAMESPACE = 'sessionAdmin'
37
- const FS_SERVICE_KEY = 'fsAdmin'
38
- const FS_NAMESPACE = 'fsAdmin'
39
- const MCP_SERVICE_KEY = 'mcpAdmin'
40
- const MCP_NAMESPACE = 'mcpAdmin'
41
- const PACKAGE = 'dsh-plugin-admin'
42
- const MODULE_DIR = dirname(fileURLToPath(import.meta.url))
43
- const PNPM_TIMEOUT_MS = 5 * 60_000
44
-
45
- // Remote update check knobs: query the npm registry (the same registry npm
46
- // uses — env override, .npmrc, or the official default) for the `latest`
47
- // dist-tag of each registry-installed bundle and compare with the local
48
- // version. Bounded concurrency, strict timeout, and a short-lived cache so
49
- // the panel never hammers the registry on every refresh.
50
- const UPDATE_CHECK_TIMEOUT_MS = 8_000
51
- const UPDATE_CHECK_CONCURRENCY = 4
52
- const UPDATE_CHECK_CACHE_TTL_MS = 5 * 60_000
53
- const NPM_REGISTRY_DEFAULT = 'https://registry.npmjs.org'
54
-
55
- // MCP connectivity probe knobs: the probe speaks the same newline-delimited
56
- // JSON-RPC (stdio) / Streamable HTTP protocol the dsh-mcp-client plugin uses,
57
- // but with a strict budget so a wedged server can never hang the panel.
58
- const MCP_PROBE_TIMEOUT_MS = 10_000
59
- const MCP_PROBE_HTTP_TIMEOUT_MS = 8_000
60
- const MCP_PROBE_MAX_RESPONSE_BYTES = 256 * 1024
61
-
62
- // The MCP client plugin whose config instances this admin manages.
63
- const MCP_PLUGIN_NAME = '@deepseek-ai/dsh-mcp-client'
64
- // Profile patch file holding the MCP server entries (top-level plugin instances).
65
- const PROFILE_PATCH_FILENAME = 'cordis.patch.yml'
66
-
67
- // Session-summary cache: re-reading every session's full event log on each
68
- // list() call is O(history volume). The persistence service exposes a cheap
69
- // per-session `revision` token (via inspect), so we cache the derived
70
- // { title, summary, messageCount } against it and only re-inspect when the
71
- // revision moves. The cache lives for the plugin's lifetime.
72
- const SESSION_SUMMARY_CACHE_TTL_MS = 60_000
73
- // Bound concurrent log reads while listing: dozens of large sessions should
74
- // never fan out into unbounded Promise.all I/O.
75
- const SESSION_LIST_CONCURRENCY = 4
76
- // Guard against pathological single sessions: only this many events are
77
- // examined per session before the loop bails (messageCount may undercount).
78
- const SESSION_EVENT_SCAN_CAP = 20_000
79
-
80
- // sessionId -> { revision, title, summary, messageCount, at }. Keyed by the
81
- // persistence revision token so unchanged sessions skip re-reading their
82
- // whole event log on every panel refresh.
83
- const sessionSummaryCache = new Map()
84
-
85
- /* ========================================================================== */
86
- /* Plugin Admin Logic */
87
- /* ========================================================================== */
88
-
89
- /**
90
- * Resolve the profile directory from the config-tree anchor: the loader's
91
- * baseUrl is the cordis.yml anchor; the profile's package.json sits beside
92
- * it (or the anchor already is the directory).
93
- * @param baseUrl - the loader config-tree anchor.
94
- * @returns the profile directory holding package.json.
95
- * @throws {Error} when package.json cannot be located beside the anchor.
96
- */
97
- function profileDirOf(baseUrl) {
98
- const anchor = typeof baseUrl === 'string' && baseUrl.startsWith('file:')
99
- ? fileURLToPath(baseUrl)
100
- : String(baseUrl)
101
- if (existsSync(join(anchor, 'package.json'))) return anchor
102
- const parent = dirname(anchor)
103
- if (existsSync(join(parent, 'package.json'))) return parent
104
- throw new Error(`plugin-admin: no profile package.json beside config anchor ${String(baseUrl)}`)
105
- }
106
-
107
- /**
108
- * @param profileDir - the profile directory.
109
- * @returns a require anchored at the profile's package.json.
110
- */
111
- function requireOf(profileDir) {
112
- return createRequire(join(profileDir, 'package.json'))
113
- }
114
-
115
- /**
116
- * @param require - profile-anchored require.
117
- * @param name - dependency package name.
118
- * @returns the parsed manifest, or undefined when unresolvable.
119
- */
120
- function readManifest(require, name) {
121
- try {
122
- return JSON.parse(readFileSync(require.resolve(`${name}/package.json`), 'utf8'))
123
- } catch {
124
- return undefined
125
- }
126
- }
127
-
128
- /**
129
- * Whether a package declares a bundle patch (i.e. is a profile layer).
130
- * @param require - profile-anchored require.
131
- * @param name - dependency package name.
132
- */
133
- function declaresBundle(require, name) {
134
- const manifest = readManifest(require, name)
135
- return manifest !== undefined
136
- && typeof manifest.dsh === 'object' && manifest.dsh !== null
137
- && manifest.dsh.bundle !== undefined
138
- && manifest.dsh.bundle.patch !== undefined
139
- }
140
-
141
- /**
142
- * The local source path a dependency spec installs from, when it is a local
143
- * install (`link:<dir>` / `file:<dir|tarball>` or a bare absolute path).
144
- * Registry ranges, dist-tags, and remote URLs resolve to null.
145
- *
146
- * The absolute-path branch is anchored so only drive-letter (C:\...), UNC
147
- * (\\\\host\\share), and rooted POSIX (/) paths count as local; a plain
148
- * `//` inside a URL (https://...) is deliberately not matched — remote git
149
- * and tarball dependencies are installs, not local sources.
150
- * @param spec - the raw dependency range from the profile manifest.
151
- * @returns the local path string, or null for registry/remote installs.
152
- */
153
- function localSpecPath(spec) {
154
- if (typeof spec !== 'string') return null
155
- const linked = /^(?:link|file):(.+)$/.exec(spec)
156
- if (linked !== null) return linked[1]
157
- if (/(?:^[a-zA-Z]:[\\/])|(?:^[\\/]{2})|(?:^\/)/.test(spec)) return spec
158
- return null
159
- }
160
-
161
- /**
162
- * Resolve the npm registry the user actually installs from: the npm_config
163
- * env override first, then a `registry=` line in the nearest .npmrc (user or
164
- * profile), falling back to the official registry. Mirrors npm's own
165
- * resolution well enough for update checks; a mismatch only means the check
166
- * queries a different mirror, which is acceptable.
167
- * @param profileDir - profile directory (checked for a local .npmrc).
168
- * @returns the registry base URL (no trailing slash).
169
- */
170
- function resolveNpmRegistry(profileDir) {
171
- if (typeof process.env.npm_config_registry === 'string' && process.env.npm_config_registry.trim() !== '') {
172
- return process.env.npm_config_registry.trim().replace(/\/+$/, '')
173
- }
174
- const candidates = [
175
- join(profileDir, '.npmrc'),
176
- join(homedir(), '.npmrc'),
177
- ]
178
- for (const file of candidates) {
179
- try {
180
- const text = readFileSync(file, 'utf8')
181
- const match = /^\s*registry\s*=\s*(\S+)\s*$/m.exec(text)
182
- if (match) return match[1].replace(/\/+$/, '')
183
- } catch {
184
- // file absent — try the next candidate
185
- }
186
- }
187
- return NPM_REGISTRY_DEFAULT
188
- }
189
-
190
- /**
191
- * Query the npm registry for the `latest` dist-tag version of one package.
192
- * Strict timeout; returns null on any failure so a dead registry never
193
- * breaks the panel.
194
- * @param registry - registry base URL.
195
- * @param name - package name (scoped names are URL-encoded).
196
- * @returns the latest version string, or null when unknown/unreachable.
197
- */
198
- async function fetchLatestVersion(registry, name) {
199
- try {
200
- const url = `${registry}/${name.split('/').map(encodeURIComponent).join('/')}/latest`
201
- const controller = new AbortController()
202
- const timer = setTimeout(() => controller.abort(), UPDATE_CHECK_TIMEOUT_MS)
203
- let response
204
- try {
205
- response = await fetch(url, { signal: controller.signal, headers: { Accept: 'application/json' } })
206
- } finally {
207
- clearTimeout(timer)
208
- }
209
- if (!response.ok) return null
210
- const data = await response.json()
211
- if (data && typeof data.version === 'string') return data.version
212
- return null
213
- } catch {
214
- return null
215
- }
216
- }
217
-
218
- /**
219
- * Remote update check for one plugin entry: only registry-installed bundles
220
- * (dependency-managed and not a local path) are queried. The result never
221
- * throws — network failures surface as `error` on the entry.
222
- * @param registry - registry base URL.
223
- * @param plugin - plugin list entry ({ name, version, dependency, localPath }).
224
- * @returns { name, version, latest, updateAvailable, error? }.
225
- */
226
- async function checkPluginUpdate(registry, plugin) {
227
- if (!plugin.dependency || plugin.localPath !== null) {
228
- return { name: plugin.name, version: plugin.version, latest: null, updateAvailable: false }
229
- }
230
- const latest = await fetchLatestVersion(registry, plugin.name)
231
- if (latest === null) {
232
- return { name: plugin.name, version: plugin.version, latest: null, updateAvailable: false, error: '无法查询远程版本(网络或 registry 不可达)' }
233
- }
234
- return {
235
- name: plugin.name,
236
- version: plugin.version,
237
- latest,
238
- updateAvailable: plugin.version !== latest,
239
- }
240
- }
241
-
242
- /** Run bounded-concurrency async work over a list, preserving order. */
243
- async function mapConcurrent(items, limit, worker) {
244
- const results = new Array(items.length)
245
- let cursor = 0
246
- const runners = new Array(Math.min(limit, items.length)).fill(0).map(async () => {
247
- while (true) {
248
- const index = cursor++
249
- if (index >= items.length) return
250
- results[index] = await worker(items[index], index)
251
- }
252
- })
253
- await Promise.all(runners)
254
- return results
255
- }
256
-
257
- /**
258
- * Write the bundle layer list back into the profile manifest.
259
- * @param profileDir - the profile directory.
260
- * @param bundles - the complete next bundle list.
261
- */
262
- function writeManifest(profileDir, pkg) {
263
- const manifestPath = join(profileDir, 'package.json')
264
- const tempPath = manifestPath + '.dsh-admin.tmp'
265
- writeFileSync(tempPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8')
266
- renameSync(tempPath, manifestPath)
267
- }
268
-
269
- /**
270
- * Atomically replace the profile manifest: write the next content to a
271
- * sibling temp file and rename over the original. A crash mid-write then
272
- * leaves either the old or the new package.json — never a truncated JSON
273
- * that would take the whole profile down at next dsh start.
274
- * @param profileDir - the profile directory.
275
- * @param pkg - the complete next manifest object.
276
- */
277
- function writeBundles(profileDir, bundles) {
278
- const manifestPath = join(profileDir, 'package.json')
279
- const pkg = JSON.parse(readFileSync(manifestPath, 'utf8'))
280
- pkg.dsh = { ...pkg.dsh, profile: { ...pkg.dsh?.profile, bundles } }
281
- writeManifest(profileDir, pkg)
282
- }
283
-
284
- /**
285
- * Synchronize `dsh.profile.bundles` with the dependency state: bundle-
286
- * declaring dependencies join (dependency order), dependency-managed
287
- * entries that stopped being bundles leave, in-box entries stay.
288
- * @param profileDir - the profile directory.
289
- * @returns whether the manifest changed.
290
- */
291
- function reconcileBundles(profileDir) {
292
- const require = requireOf(profileDir)
293
- const manifestPath = join(profileDir, 'package.json')
294
- const pkg = JSON.parse(readFileSync(manifestPath, 'utf8'))
295
- const dependencies = Object.keys(pkg.dependencies ?? {})
296
- const bundles = pkg.dsh?.profile?.bundles ?? []
297
- let changed = false
298
- for (const name of dependencies) {
299
- if (declaresBundle(require, name) && !bundles.includes(name)) {
300
- bundles.push(name)
301
- changed = true
302
- }
303
- }
304
- for (const name of [...bundles]) {
305
- if (dependencies.includes(name) && !declaresBundle(require, name)) {
306
- bundles.splice(bundles.indexOf(name), 1)
307
- changed = true
308
- }
309
- }
310
- if (changed) {
311
- pkg.dsh = { ...pkg.dsh, profile: { ...pkg.dsh?.profile, bundles } }
312
- writeManifest(profileDir, pkg)
313
- }
314
- return changed
315
- }
316
-
317
- /**
318
- * Terminate a process and its whole descendant tree.
319
- * @param pid - process id to kill.
320
- */
321
- function killProcessTree(pid) {
322
- if (typeof pid !== 'number' || !Number.isFinite(pid) || pid <= 0) return
323
- if (process.platform === 'win32') {
324
- // taskkill /T walks the tree; /F forces. With shell:true the direct
325
- // child is cmd.exe, and its children (pnpm + node) would otherwise
326
- // survive a bare kill().
327
- try {
328
- spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], { windowsHide: true })
329
- } catch {
330
- // fall through to the direct kill below
331
- }
332
- }
333
- try {
334
- process.kill(pid)
335
- } catch {
336
- // already gone
337
- }
338
- }
339
-
340
- /**
341
- * Run one pnpm invocation in the profile directory (async; never blocks host loop).
342
- * @param profileDir - working directory for pnpm.
343
- * @param args - pnpm arguments.
344
- * @returns the command's combined output tail on success.
345
- * @throws {Error} carrying the output tail when pnpm exits non-zero.
346
- */
347
- function runPnpm(profileDir, args) {
348
- return new Promise((resolve, reject) => {
349
- const child = spawn('pnpm', args, {
350
- cwd: profileDir,
351
- shell: process.platform === 'win32',
352
- env: process.env,
353
- })
354
- let output = ''
355
- const record = (chunk) => {
356
- output += chunk
357
- if (output.length > 16_384) output = output.slice(-8_192)
358
- }
359
- child.stdout?.on('data', record)
360
- child.stderr?.on('data', record)
361
- const timer = setTimeout(() => {
362
- killProcessTree(child.pid)
363
- reject(new Error(`pnpm timed out after ${String(PNPM_TIMEOUT_MS / 1000)}s: ${output}`))
364
- }, PNPM_TIMEOUT_MS)
365
- child.on('error', (error) => {
366
- clearTimeout(timer)
367
- reject(error.code === 'ENOENT'
368
- ? new Error('pnpm not found on PATH — install pnpm to manage profile plugins')
369
- : error)
370
- })
371
- child.on('close', (code) => {
372
- clearTimeout(timer)
373
- if (code === 0) resolve(output.trim())
374
- else reject(new Error(`pnpm ${args.join(' ')} exited with code ${String(code)}:\n${output.trim()}`))
375
- })
376
- })
377
- }
378
-
379
-
380
- /**
381
- * Characters permitted in a pnpm install/remove operand. Single-token
382
- * operands only: package names, scoped names (@scope/name), version
383
- * suffixes (^1.2.3, ~1.2.3, name@*), git URLs (git+https://...#ref), and
384
- * drive-letter/UNC/POSIX paths. Every cmd.exe separator, redirect, and
385
- * expansion character is excluded by construction — including <, >, =, |,
386
- * &, %, !, quotes, backticks, parens, braces, commas, and whitespace (the
387
- * >/< semver range forms like >=1.0.0 are multi-token and would already be
388
- * split by the shell, so dropping them loses nothing real). On Windows the
389
- * spawn below uses shell:true (pnpm ships as a .cmd shim), so the operand
390
- * is one token of the joined command line — metacharacters here would be
391
- * the difference between pnpm and a second command.
392
- */
393
- const PNPM_OPERAND_ALLOWED = /^[A-Za-z0-9@\/_.:\\^~*=+#-]+$/
394
-
395
- /**
396
- * Validate one pnpm operand and return it trimmed. Refuses leading dashes
397
- * (an operand must never masquerade as a pnpm flag) and any character
398
- * outside the allowlist (a whole class of shell metacharacters is rejected
399
- * at once instead of a hand-maintained blocklist of separators).
400
- * @param field - human label for the error message (e.g. 'install spec').
401
- * @param value - raw operand from the RPC boundary.
402
- * @returns the trimmed, validated operand.
403
- * @throws {Error} when the operand is a flag or carries shell metacharacters.
404
- */
405
- function assertPnpmOperand(field, value) {
406
- const operand = value.trim()
407
- if (/^-/.test(operand)) {
408
- throw new Error('plugin-admin: ' + field + ' \'' + operand.slice(0, 48) + '\' looks like a CLI flag')
409
- }
410
- if (!PNPM_OPERAND_ALLOWED.test(operand)) {
411
- throw new Error('plugin-admin: ' + field + ' carries shell metacharacters — only package specs, version ranges, and local paths are accepted')
412
- }
413
- return operand
414
- }
415
-
416
- /* Exported for host-check.mjs: the shell-allowlist validator and the local
417
- * source-path classifier are pure functions, so the self-check drives them
418
- * directly without touching pnpm or the real profile manifest. The update
419
- * checker is exported too — its registry fetch is injected, so the host-check
420
- * can drive it against a local HTTP stub without touching the real npm. */
421
- export { localSpecPath, assertPnpmOperand, resolveNpmRegistry, fetchLatestVersion, checkPluginUpdate }
422
-
423
- /* ========================================================================== */
424
- /* MCP Connectivity Probe */
425
- /* ========================================================================== */
426
-
427
- /**
428
- * Run a best-effort connectivity probe against one MCP server configuration.
429
- *
430
- * stdio: spawn the configured command (the same way the mcp-client plugin's
431
- * StdioClientTransport does — default env + explicit env, cwd applied, no
432
- * shell), then speak newline-delimited JSON-RPC: `initialize`, followed by
433
- * `notifications/initialized`, then `tools/list` (so the tool count and
434
- * serverInfo come from the real handshake). The process is killed with its
435
- * whole descendant tree when the probe finishes or times out, and the stderr
436
- * tail is captured for a diagnosable failure message.
437
- *
438
- * streamable-http: POST an `initialize` request with the Accept header the
439
- * MCP SDK uses, wait for the JSON response (or the first SSE event carrying
440
- * the matching id), then `ping` and `tools/list`. The probe reports the
441
- * server's declared identity and capability summary.
442
- *
443
- * Never throws: it returns { ok, ... } so the UI can render per-entry results
444
- * without try/catch around every call site.
445
- *
446
- * @param cfg - normalized MCP entry config ({ transport, serverName, ... }).
447
- * @returns probe result: { ok: true, serverInfo, toolCount, transport, ms } or
448
- * { ok: false, error, transport, ms, stderr? }.
449
- */
450
- /**
451
- * Recursively strip `undefined` values (typert's JSON boundary rejects them —
452
- * a field present with `undefined` fails "business result failed boundary
453
- * validation"). Arrays keep their length; object keys with undefined values
454
- * are removed.
455
- * @param value - any JSON-ish value.
456
- * @returns a copy with every undefined leaf removed.
457
- */
458
- function jsonSafe(value) {
459
- if (value === undefined) return undefined
460
- if (Array.isArray(value)) return value.map(jsonSafe)
461
- if (value !== null && typeof value === 'object') {
462
- const out = {}
463
- for (const key of Object.keys(value)) {
464
- const next = jsonSafe(value[key])
465
- if (next !== undefined) out[key] = next
466
- }
467
- return out
468
- }
469
- return value
470
- }
471
-
472
- /**
473
- * Run a best-effort connectivity probe against one MCP server configuration.
474
- *
475
- * stdio: spawn the configured command (the same way the mcp-client plugin's
476
- * StdioClientTransport does — default env + explicit env, cwd applied, no
477
- * shell), then speak newline-delimited JSON-RPC: `initialize`, followed by
478
- * `notifications/initialized`, then `tools/list` (so the tool count and
479
- * serverInfo come from the real handshake). The process is killed with its
480
- * whole descendant tree when the probe finishes or times out, and the stderr
481
- * tail is captured for a diagnosable failure message.
482
- *
483
- * streamable-http: POST an `initialize` request with the Accept header the
484
- * MCP SDK uses, wait for the JSON response (or the first SSE event carrying
485
- * the matching id), then `ping` and `tools/list`. The probe reports the
486
- * server's declared identity and capability summary.
487
- *
488
- * Never throws: it returns { ok, ... } so the UI can render per-entry results
489
- * without try/catch around every call site.
490
- *
491
- * @param cfg - normalized MCP entry config ({ transport, serverName, ... }).
492
- * @returns probe result: { ok: true, serverInfo, toolCount, transport, ms } or
493
- * { ok: false, error, transport, ms, stderr? }.
494
- */
495
- async function probeMcpServer(cfg) {
496
- const startedAt = Date.now()
497
- const ms = () => Date.now() - startedAt
498
- let outcome
499
- try {
500
- if (cfg.transport === 'stdio') {
501
- const probe = await probeMcpStdio(cfg)
502
- outcome = { ok: probe.ok, transport: 'stdio', ms: ms(), ...probe }
503
- } else if (cfg.transport === 'streamable-http') {
504
- const probe = await probeMcpHttp(cfg)
505
- outcome = { ok: probe.ok, transport: 'streamable-http', ms: ms(), ...probe }
506
- } else {
507
- outcome = { ok: false, transport: String(cfg.transport), ms: ms(), error: 'unknown transport' }
508
- }
509
- } catch (error) {
510
- outcome = { ok: false, transport: String(cfg.transport), ms: ms(), error: error instanceof Error ? error.message : String(error) }
511
- }
512
- // The typert gateway boundary rejects undefined-valued fields.
513
- return jsonSafe(outcome)
514
- }
515
-
516
- /**
517
- * Spawn an MCP stdio server command the way the real dsh-mcp-client plugin
518
- * does: the MCP SDK's StdioClientTransport uses cross-spawn, which on Windows
519
- * wraps non-`.exe` commands (`.cmd`/`.bat` shims like npx, npm, pnpm) in
520
- * `cmd.exe /d /s /c` so they resolve through PATHEXT. Node's raw spawn with
521
- * `shell:false` would fail those with ENOENT. This helper mirrors that
522
- * behavior so the probe tests what dsh actually launches.
523
- * @param command - executable name (possibly a .cmd shim).
524
- * @param args - argument list.
525
- * @param options - spawn options (cwd/env/stdio).
526
- * @returns the spawned ChildProcess.
527
- */
528
- function spawnMcpCommand(command, args, options) {
529
- if (process.platform === 'win32' && !/\.(exe|com|bat|cmd)$/i.test(command)) {
530
- // Same shape cross-spawn produces: cmd.exe /d /s /c "<escaped command and args>".
531
- const shellCommand = [command, ...args].map(escapeCmdArg).join(' ')
532
- return spawn(process.env.comspec || 'cmd.exe', ['/d', '/s', '/c', '"' + shellCommand + '"'], {
533
- ...options,
534
- shell: false,
535
- windowsVerbatimArguments: true,
536
- windowsHide: true,
537
- })
538
- }
539
- return spawn(command, args, { ...options, shell: false, windowsHide: process.platform === 'win32' })
540
- }
541
-
542
- /** Escape one token for a Windows cmd.exe /c command line (cross-spawn style). */
543
- function escapeCmdArg(arg) {
544
- const text = String(arg)
545
- // Only wrap when the token carries whitespace or cmd metacharacters.
546
- if (/^[A-Za-z0-9_\-./:\\@^~=+#]+$/.test(text)) return text
547
- return '"' + text.replace(/"/g, '\\"') + '"'
548
- }
549
-
550
- /**
551
- * Split an inline command line into argv tokens, honoring double quotes so
552
- * paths with spaces ("C:\Program Files\...") stay one token. Used to turn a
553
- * user's `command: "npx -y fetcher-mcp"` into [npx, -y, fetcher-mcp].
554
- * @param line - the raw command string.
555
- * @returns array of tokens (never empty for a non-blank line).
556
- */
557
- function splitCommandLine(line) {
558
- const tokens = []
559
- let current = ''
560
- let inQuotes = false
561
- for (let i = 0; i < line.length; i++) {
562
- const ch = line[i]
563
- if (ch === '"') {
564
- inQuotes = !inQuotes
565
- continue
566
- }
567
- if (ch === ' ' || ch === '\t') {
568
- if (inQuotes) {
569
- current += ch
570
- } else if (current !== '') {
571
- tokens.push(current)
572
- current = ''
573
- }
574
- continue
575
- }
576
- current += ch
577
- }
578
- if (current !== '') tokens.push(current)
579
- return tokens
580
- }
581
-
582
- /**
583
- * MCP stdio probe: spawn + newline-delimited JSON-RPC handshake.
584
- * @param cfg - stdio config.
585
- * @returns { ok, serverInfo?, toolCount?, error?, stderr? }.
586
- */
587
- function probeMcpStdio(cfg) {
588
- return new Promise((resolve) => {
589
- let settled = false
590
- let child
591
- let stdout = ''
592
- let stderr = ''
593
- let buffer = ''
594
- let nextId = 1
595
- const pending = new Map()
596
-
597
- const finish = (outcome) => {
598
- if (settled) return
599
- settled = true
600
- if (child && child.pid) killProcessTree(child.pid)
601
- resolve(outcome)
602
- }
603
- const fail = (error, extra = {}) => finish({ ok: false, error, stderr: stderr.trim().slice(-2_000) || undefined, ...extra })
604
-
605
- // Kill the probe if the server never answers.
606
- const timer = setTimeout(() => {
607
- fail(`probe timed out after ${MCP_PROBE_TIMEOUT_MS}ms — no JSON-RPC response`, { stderr: stderr.trim().slice(-2_000) || undefined })
608
- }, MCP_PROBE_TIMEOUT_MS)
609
-
610
- const send = (method, params) => {
611
- const id = nextId++
612
- const message = JSON.stringify({ jsonrpc: '2.0', id, method, params })
613
- pending.set(id, method)
614
- if (child.stdin && !child.stdin.write(message + '\n')) {
615
- child.stdin.once('drain', () => {})
616
- }
617
- return id
618
- }
619
- const onLine = (line) => {
620
- let message
621
- try {
622
- message = JSON.parse(line)
623
- } catch {
624
- return // ignore non-JSON lines (some servers log to stdout)
625
- }
626
- if (message && message.id !== undefined && pending.has(message.id)) {
627
- if (message.error) {
628
- fail(`server rejected ${pending.get(message.id)}: ${message.error.message || JSON.stringify(message.error)}`)
629
- return
630
- }
631
- const method = pending.get(message.id)
632
- pending.delete(message.id)
633
- if (method === 'initialize') {
634
- const info = message.result?.serverInfo
635
- if (info) {
636
- child.stdoutInfo = info
637
- child.toolCount = Array.isArray(message.result.tools) ? message.result.tools.length : undefined
638
- }
639
- // After initialize the client must send notifications/initialized.
640
- child.stdin.write(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n')
641
- // Then ask for the tool list (the real dsh-mcp-client does this too).
642
- send('tools/list')
643
- return
644
- }
645
- if (method === 'tools/list') {
646
- const tools = Array.isArray(message.result?.tools) ? message.result.tools : []
647
- const outcome = {
648
- ok: true,
649
- serverInfo: child.stdoutInfo || undefined,
650
- toolCount: tools.length,
651
- tools: tools
652
- .map(tool => (tool && typeof tool === 'object' && typeof tool.name === 'string') ? tool.name : null)
653
- .filter(name => name !== null),
654
- }
655
- if (child.probeInlineWarning) outcome.warning = child.probeInlineWarning
656
- finish(outcome)
657
- }
658
- }
659
- }
660
-
661
- try {
662
- // The dsh-mcp-client plugin treats `command` as the executable name and
663
- // `args` as the argument list. Users often write the whole invocation
664
- // inline ("npx -y fetcher-mcp"); split it so the probe tests the same
665
- // thing they meant. When args ARE configured they win (that's the
666
- // plugin-faithful shape). The probe still flags the divergence so the
667
- // UI can warn that dsh itself would fail to launch this config.
668
- const inlineCommand = Array.isArray(cfg.args) && cfg.args.length > 0
669
- ? [cfg.command, ...cfg.args]
670
- : splitCommandLine(cfg.command)
671
- const command = inlineCommand[0]
672
- const args = inlineCommand.slice(1)
673
- const wasInline = Array.isArray(cfg.args) && cfg.args.length > 0 ? false : inlineCommand.length > 1
674
- child = spawnMcpCommand(command, args, {
675
- cwd: cfg.cwd || undefined,
676
- env: { ...process.env, ...(cfg.env || {}) },
677
- stdio: ['pipe', 'pipe', 'pipe'],
678
- })
679
- if (wasInline) child.probeInlineWarning = `command 含整行调用(${cfg.command})。探测已自动拆分执行成功,但 dsh 实际要求 command 仅为可执行名、参数放 args(如 command: npx + args: [-y, fetcher-mcp]),否则 dsh 启动该 MCP 服务器会失败——请在编辑表单中把命令拆分到 args 后保存。`
680
- } catch (error) {
681
- clearTimeout(timer)
682
- finish({ ok: false, error: `failed to spawn '${cfg.command}': ${error.message}` })
683
- return
684
- }
685
-
686
- child.stdout?.on('data', (chunk) => {
687
- stdout += chunk
688
- if (stdout.length > MCP_PROBE_MAX_RESPONSE_BYTES) {
689
- fail('server response exceeded ' + MCP_PROBE_MAX_RESPONSE_BYTES + ' bytes')
690
- return
691
- }
692
- buffer += chunk
693
- let index
694
- while ((index = buffer.indexOf('\n')) !== -1) {
695
- const line = buffer.slice(0, index)
696
- buffer = buffer.slice(index + 1)
697
- if (line.trim() !== '') onLine(line)
698
- }
699
- })
700
- child.stderr?.on('data', (chunk) => {
701
- stderr += chunk
702
- if (stderr.length > MCP_PROBE_MAX_RESPONSE_BYTES) stderr = stderr.slice(-MCP_PROBE_MAX_RESPONSE_BYTES)
703
- })
704
- child.on('error', (error) => {
705
- clearTimeout(timer)
706
- fail(error.code === 'ENOENT'
707
- ? `command not found: ${cfg.command.trim().split(/\s+/)[0]}`
708
- : `failed to start '${cfg.command}': ${error.message}`)
709
- })
710
- child.on('close', (code) => {
711
- if (!settled) {
712
- clearTimeout(timer)
713
- const tail = stderr.trim().slice(-2_000)
714
- fail(`server process exited with code ${String(code)}${tail ? ': ' + tail : ''}`)
715
- }
716
- })
717
-
718
- // Kick off the handshake once the process is up. If spawn already failed
719
- // the 'error' event settles first.
720
- child.on('spawn', () => {
721
- send('initialize', {
722
- protocolVersion: '2025-11-25',
723
- capabilities: {},
724
- clientInfo: { name: 'dsh-plugin-admin', version: '0.0.1' },
725
- })
726
- })
727
- })
728
- }
729
-
730
- /**
731
- * MCP streamable-http probe: POST initialize over HTTP and wait for a
732
- * response. Handles both plain-JSON and SSE (text/event-stream) responses.
733
- * @param cfg - streamable-http config.
734
- * @returns { ok, serverInfo?, toolCount?, error? }.
735
- */
736
- function probeMcpHttp(cfg) {
737
- return new Promise((resolve) => {
738
- let settled = false
739
- const finish = (outcome) => {
740
- if (settled) return
741
- settled = true
742
- resolve(outcome)
743
- }
744
- const fail = (error) => finish({ ok: false, error })
745
- const timer = setTimeout(() => {
746
- fail(`probe timed out after ${MCP_PROBE_HTTP_TIMEOUT_MS}ms — no HTTP response from ${cfg.url}`)
747
- }, MCP_PROBE_HTTP_TIMEOUT_MS)
748
-
749
- const doProbe = async () => {
750
- try {
751
- const init = {
752
- method: 'POST',
753
- headers: {
754
- 'Content-Type': 'application/json',
755
- Accept: 'application/json, text/event-stream',
756
- ...(cfg.headers || {}),
757
- },
758
- body: JSON.stringify({
759
- jsonrpc: '2.0',
760
- id: 1,
761
- method: 'initialize',
762
- params: {
763
- protocolVersion: '2025-11-25',
764
- capabilities: {},
765
- clientInfo: { name: 'dsh-plugin-admin', version: '0.0.1' },
766
- },
767
- }),
768
- }
769
- const controller = new AbortController()
770
- const abortTimer = setTimeout(() => controller.abort(), MCP_PROBE_HTTP_TIMEOUT_MS)
771
- let response
772
- try {
773
- response = await fetch(cfg.url, { ...init, signal: controller.signal })
774
- } finally {
775
- clearTimeout(abortTimer)
776
- }
777
- if (!response.ok) {
778
- fail(`HTTP ${response.status} ${response.statusText} from ${cfg.url}`)
779
- return
780
- }
781
- const contentType = (response.headers.get('content-type') || '').toLowerCase()
782
- let serverInfo
783
- let toolCount
784
- let matched = false
785
- if (contentType.includes('text/event-stream')) {
786
- const reader = response.body.getReader()
787
- const decoder = new TextDecoder()
788
- let acc = ''
789
- let dataLine = ''
790
- while (true) {
791
- const { done, value } = await reader.read()
792
- if (done) break
793
- acc += decoder.decode(value, { stream: true })
794
- if (acc.length > MCP_PROBE_MAX_RESPONSE_BYTES) {
795
- fail('server response exceeded ' + MCP_PROBE_MAX_RESPONSE_BYTES + ' bytes')
796
- return
797
- }
798
- // SSE frames: "data: {...}\n\n"
799
- const frames = acc.split('\n\n')
800
- acc = frames.pop()
801
- for (const frame of frames) {
802
- for (const line of frame.split('\n')) {
803
- if (line.startsWith('data:')) dataLine = line.slice(5).trim()
804
- }
805
- if (dataLine === '') continue
806
- try {
807
- const message = JSON.parse(dataLine)
808
- dataLine = ''
809
- if (message.id === 1) {
810
- matched = true
811
- serverInfo = message.result?.serverInfo
812
- if (Array.isArray(message.result?.tools)) toolCount = message.result.tools.length
813
- }
814
- } catch {
815
- // ignore malformed SSE data frames
816
- }
817
- }
818
- }
819
- if (!matched) {
820
- fail('no initialize response in the SSE stream from ' + cfg.url)
821
- return
822
- }
823
- } else {
824
- const text = await response.text()
825
- if (text.length > MCP_PROBE_MAX_RESPONSE_BYTES) {
826
- fail('server response exceeded ' + MCP_PROBE_MAX_RESPONSE_BYTES + ' bytes')
827
- return
828
- }
829
- let message
830
- try {
831
- message = JSON.parse(text)
832
- } catch {
833
- fail('server returned non-JSON response from ' + cfg.url)
834
- return
835
- }
836
- if (message.id !== 1) {
837
- fail('server returned a response without the initialize id from ' + cfg.url)
838
- return
839
- }
840
- if (message.error) {
841
- fail('server rejected initialize: ' + (message.error.message || JSON.stringify(message.error)))
842
- return
843
- }
844
- serverInfo = message.result?.serverInfo
845
- if (Array.isArray(message.result?.tools)) toolCount = message.result.tools.length
846
- }
847
- // Optional follow-up ping to prove the session stays usable, then
848
- // ask for the tool list so the UI can show what the server offers.
849
- let pingOk = true
850
- let tools = []
851
- try {
852
- const ping = await fetch(cfg.url, {
853
- ...init,
854
- signal: AbortSignal.timeout(MCP_PROBE_HTTP_TIMEOUT_MS),
855
- body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'ping', params: {} }),
856
- })
857
- if (!ping.ok) pingOk = false
858
- if (pingOk) {
859
- tools = await httpToolNames(cfg.url, init)
860
- }
861
- } catch {
862
- pingOk = false
863
- }
864
- finish({
865
- ok: true,
866
- serverInfo,
867
- toolCount: tools.length,
868
- tools,
869
- pingOk,
870
- })
871
- } catch (error) {
872
- fail(error instanceof Error && error.name === 'AbortError'
873
- ? `connection to ${cfg.url} timed out`
874
- : `HTTP request to ${cfg.url} failed: ${error instanceof Error ? error.message : String(error)}`)
875
- } finally {
876
- clearTimeout(timer)
877
- }
878
- }
879
- void doProbe()
880
- })
881
- }
882
-
883
- /**
884
- * Ask a streamable-http MCP server for its tool names (tools/list), handling
885
- * both plain-JSON and SSE responses. Best-effort: any failure returns [].
886
- * @param url - MCP endpoint URL.
887
- * @param init - base request init (headers).
888
- * @returns the list of tool names.
889
- */
890
- async function httpToolNames(url, init) {
891
- try {
892
- const response = await fetch(url, {
893
- ...init,
894
- signal: AbortSignal.timeout(MCP_PROBE_HTTP_TIMEOUT_MS),
895
- body: JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'tools/list', params: {} }),
896
- })
897
- if (!response.ok) return []
898
- const contentType = (response.headers.get('content-type') || '').toLowerCase()
899
- let tools = []
900
- if (contentType.includes('text/event-stream')) {
901
- const reader = response.body.getReader()
902
- const decoder = new TextDecoder()
903
- let acc = ''
904
- let dataLine = ''
905
- while (true) {
906
- const { done, value } = await reader.read()
907
- if (done) break
908
- acc += decoder.decode(value, { stream: true })
909
- const frames = acc.split('\n\n')
910
- acc = frames.pop()
911
- for (const frame of frames) {
912
- for (const line of frame.split('\n')) {
913
- if (line.startsWith('data:')) dataLine = line.slice(5).trim()
914
- }
915
- if (dataLine === '') continue
916
- try {
917
- const message = JSON.parse(dataLine)
918
- dataLine = ''
919
- if (message.id === 3 && Array.isArray(message.result?.tools)) {
920
- tools = message.result.tools
921
- }
922
- } catch {
923
- // ignore malformed SSE data frames
924
- }
925
- }
926
- }
927
- } else {
928
- const text = await response.text()
929
- const message = JSON.parse(text)
930
- if (Array.isArray(message.result?.tools)) tools = message.result.tools
931
- }
932
- return tools
933
- .map(tool => (tool && typeof tool === 'object' && typeof tool.name === 'string') ? tool.name : null)
934
- .filter(name => name !== null)
935
- } catch {
936
- return []
937
- }
938
- }
939
-
940
- /* ========================================================================== */
941
- /* Session Admin Logic */
942
- /* ========================================================================== */
943
-
944
- /**
945
- * Check the workspace registry's soft-private write path (requireState /
946
- * setState / enqueueOperation) and the archived-set state shape. These are
947
- * dsh internals rather than a public API — the check exists so a dsh version
948
- * change fails loudly at startup instead of silently breaking archive state
949
- * later. Called from apply() and re-checked on every write.
950
- * @param registry - live workspace registry service.
951
- * @returns the current domain state carrying `archivedSessionIds`.
952
- * @throws {Error} naming the exact missing members when incompatible.
953
- */
954
- function registryStateFor(registry) {
955
- const missing = []
956
- if (typeof registry.requireState !== 'function') missing.push('requireState')
957
- if (typeof registry.setState !== 'function') missing.push('setState')
958
- if (typeof registry.enqueueOperation !== 'function') missing.push('enqueueOperation')
959
- if (missing.length > 0) {
960
- throw new Error(`session-admin: workspace registry missing archived-set write path members [${missing.join(', ')}] — dsh version changed?`)
961
- }
962
- const state = registry.requireState()
963
- if (state === null || typeof state !== 'object' || !Array.isArray(state.archivedSessionIds)) {
964
- throw new Error('session-admin: workspace registry state shape incompatible (archivedSessionIds array expected)')
965
- }
966
- return state
967
- }
968
-
969
- /**
970
- * Remove one session id from the registry's archived set through the
971
- * registry's own serialized write chain.
972
- * @param ctx - plugin context carrying workspaceRegistry.
973
- * @param sessionId - session to unarchive.
974
- */
975
- async function removeFromArchivedSet(ctx, sessionId) {
976
- const registry = ctx.workspaceRegistry
977
- const state = registryStateFor(registry)
978
- if (!state.archivedSessionIds.includes(sessionId)) return
979
- await registry.enqueueOperation(async () => {
980
- const current = registryStateFor(registry)
981
- await registry.setState({
982
- ...current,
983
- archivedSessionIds: current.archivedSessionIds.filter(id => id !== sessionId),
984
- })
985
- })
986
- }
987
-
988
- /**
989
- * @param ctx - plugin context.
990
- * @param sessionId - candidate id.
991
- * @returns whether the session is live (an attached agent session).
992
- */
993
- function sessionIsLive(ctx, sessionId) {
994
- const sessions = ctx.get('sessions')
995
- return sessions !== undefined && typeof sessions.get === 'function'
996
- && sessions.get(sessionId) !== undefined
997
- }
998
-
999
- /**
1000
- * Capture the AgentHandle dsh's agent factory returns when it creates or
1001
- * resumes a live agent. The handle's `dispose()` is dsh's ONLY complete
1002
- * teardown path for a live agent+session: it stops the loop, waits for
1003
- * quiescence, unregisters the agent, removes the session from the in-memory
1004
- * SessionStore (emitting `session/disposed`), which lets the persistence
1005
- * backend flush buffered events and release its write path — so the session's
1006
- * log can then be removed WITHOUT being resurrected by a later flush.
1007
- *
1008
- * dsh deliberately hands the handle only to the creator ("CAPABILITY: among
1009
- * consumers, only the holder can tear this agent down"), so the Web host
1010
- * (dsh-host-apiproxy) discards it after resume. This plugin wraps the PUBLIC
1011
- * `ctx.agents` service methods transparently — calling through to the originals
1012
- * and returning their exact results — and keeps a private id -> handle map so
1013
- * the admin panel can later dispose an online session by id.
1014
- *
1015
- * The wrapper is best-effort by design: if the agents service is absent, the
1016
- * factory shape changes, or a session was created before this plugin mounted,
1017
- * online-session close simply degrades to the existing "restart to delete"
1018
- * behavior — never a crash.
1019
- *
1020
- * @param ctx - plugin context.
1021
- * @returns an object with `get(sessionId)` (the captured handle, or undefined)
1022
- * and `wrapped` (whether the agents service is present to wrap).
1023
- */
1024
- function installAgentHandleCapture(ctx) {
1025
- const handles = new Map()
1026
- const agents = ctx.get('agents')
1027
- if (agents === undefined || typeof agents !== 'object' || agents === null) {
1028
- return { get: () => undefined, wrapped: false }
1029
- }
1030
-
1031
- const wrap = (service, methodName) => {
1032
- const original = service[methodName]
1033
- if (typeof original !== 'function') return
1034
- service[methodName] = function (...args) {
1035
- const result = original.apply(this, args)
1036
- if (result !== null && typeof result === 'object' && typeof result.then === 'function') {
1037
- return result.then((handle) => {
1038
- if (handle !== null && typeof handle === 'object'
1039
- && typeof handle.dispose === 'function'
1040
- && typeof handle.agent?.id === 'string') {
1041
- handles.set(handle.agent.id, handle)
1042
- }
1043
- return handle
1044
- })
1045
- }
1046
- if (result !== null && typeof result === 'object'
1047
- && typeof result.dispose === 'function'
1048
- && typeof result.agent?.id === 'string') {
1049
- handles.set(result.agent.id, result)
1050
- }
1051
- return result
1052
- }
1053
- }
1054
-
1055
- // create() / resume() are the two public factories that produce AgentHandle
1056
- // values. The loop's config-driven agents call resume() through the same
1057
- // service, so those are captured too.
1058
- wrap(agents, 'create')
1059
- wrap(agents, 'resume')
1060
-
1061
- return {
1062
- wrapped: true,
1063
- get: (sessionId) => handles.get(sessionId),
1064
- delete: (sessionId) => handles.delete(sessionId),
1065
- size: () => handles.size,
1066
- }
1067
- }
1068
-
1069
- /* ========================================================================== */
1070
- /* Plugin Main Apply */
1071
- /* ========================================================================== */
1072
-
1073
- /**
1074
- * Mount the unified plugin & session admin remote services and their typert descriptors.
1075
- * @param ctx - plugin context carrying typert, workspaceRegistry, sessionPersistence.
1076
- */
1077
- export function apply(ctx) {
1078
- const profileDir = profileDirOf(ctx.baseUrl)
1079
- // Probe the registry write path at mount time so a dsh version change
1080
- // fails the plugin mount loudly instead of breaking archive state on the
1081
- // first session-admin call.
1082
- registryStateFor(ctx.workspaceRegistry)
1083
- // Capture the AgentHandles dsh produces for live agents (see
1084
- // installAgentHandleCapture) so online sessions can be torn down through
1085
- // dsh's official dispose chain before their logs are removed.
1086
- const handleCapture = installAgentHandleCapture(ctx)
1087
- let operationTail = Promise.resolve()
1088
-
1089
- function enqueue(operation) {
1090
- const run = operationTail.then(operation, operation)
1091
- operationTail = run.catch(() => {})
1092
- return run
1093
- }
1094
-
1095
- function listLayers() {
1096
- const require = requireOf(profileDir)
1097
- const pkg = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8'))
1098
- const dependencies = new Map(Object.entries(pkg.dependencies ?? {}))
1099
- const bundles = pkg.dsh?.profile?.bundles ?? []
1100
- const plugins = bundles.map((name) => {
1101
- const manifest = readManifest(require, name)
1102
- return {
1103
- name,
1104
- version: manifest?.version ?? null,
1105
- dependency: dependencies.has(name),
1106
- removable: dependencies.has(name),
1107
- localPath: localSpecPath(dependencies.get(name)),
1108
- }
1109
- })
1110
- return { profileDir, plugins }
1111
- }
1112
-
1113
- /* ---------------------- Plugin Admin Remote Service ---------------------- */
1114
- // Remote-update cache: name -> { at, latest } so repeated panel refreshes
1115
- // within the TTL do not re-hit the registry.
1116
- const updateCache = new Map()
1117
-
1118
- const pluginService = {
1119
- async list() {
1120
- return listLayers()
1121
- },
1122
-
1123
- /**
1124
- * Check every registry-installed bundle for a newer version on the npm
1125
- * registry (same registry npm/pnpm use). In-box bundles and local-path
1126
- * installs are skipped. Never throws: each entry reports its own
1127
- * `updateAvailable`/`latest`/`error`. Results are cached briefly.
1128
- * @returns { updates, checkedAt } where updates is per-plugin status.
1129
- */
1130
- async checkUpdates() {
1131
- const now = Date.now()
1132
- const { plugins } = listLayers()
1133
- const registry = resolveNpmRegistry(profileDir)
1134
- const cached = []
1135
- const todo = []
1136
- for (const plugin of plugins) {
1137
- if (!plugin.dependency || plugin.localPath !== null) continue
1138
- const hit = updateCache.get(plugin.name)
1139
- if (hit !== undefined && now - hit.at < UPDATE_CHECK_CACHE_TTL_MS) {
1140
- cached.push({ ...hit.result })
1141
- } else {
1142
- todo.push(plugin)
1143
- }
1144
- }
1145
- const fresh = await mapConcurrent(todo, UPDATE_CHECK_CONCURRENCY, async (plugin) => {
1146
- const result = await checkPluginUpdate(registry, plugin)
1147
- if (result.latest !== null) {
1148
- updateCache.set(plugin.name, { at: now, result: { name: result.name, latest: result.latest } })
1149
- }
1150
- return result
1151
- })
1152
- const updates = [...cached, ...fresh]
1153
- return { updates, checkedAt: now }
1154
- },
1155
-
1156
- async install(spec) {
1157
- if (typeof spec !== 'string' || spec.trim() === '') {
1158
- throw new Error('plugin-admin: install requires a spec string')
1159
- }
1160
- const operand = assertPnpmOperand('install spec', spec)
1161
- return enqueue(async () => {
1162
- const output = await runPnpm(profileDir, ['add', operand])
1163
- reconcileBundles(profileDir)
1164
- return { output, ...listLayers() }
1165
- })
1166
- },
1167
-
1168
- async remove(name) {
1169
- if (typeof name !== 'string' || name.trim() === '') {
1170
- throw new Error('plugin-admin: remove requires a package name')
1171
- }
1172
- const operand = assertPnpmOperand('package name', name)
1173
- const dependencies = new Set(Object.keys(
1174
- JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')).dependencies ?? {},
1175
- ))
1176
- if (!dependencies.has(operand)) {
1177
- throw new Error(`plugin-admin: '${operand}' is not a dependency-managed plugin (in-box bundles are not removable here)`)
1178
- }
1179
- return enqueue(async () => {
1180
- const output = await runPnpm(profileDir, ['remove', operand])
1181
- const pkg = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8'))
1182
- const bundles = pkg.dsh?.profile?.bundles ?? []
1183
- // Filter by the validated operand (trimmed) — the raw RPC `name` with
1184
- // surrounding whitespace would miss the entry and leave behind a
1185
- // phantom bundle that reconcileBundles cannot heal (it only prunes
1186
- // entries that are still dependencies).
1187
- const at = bundles.indexOf(operand)
1188
- if (at !== -1) writeBundles(profileDir, bundles.filter(entry => entry !== operand))
1189
- reconcileBundles(profileDir)
1190
- return { output, ...listLayers() }
1191
- })
1192
- },
1193
- }
1194
-
1195
- const pluginBinding = Object.freeze({ service: pluginService, serviceKey: PLUGIN_SERVICE_KEY, namespace: PLUGIN_NAMESPACE })
1196
- Object.defineProperty(pluginService, 'typertRemote', { value: pluginBinding, enumerable: false })
1197
- ctx.provide(PLUGIN_SERVICE_KEY, pluginService)
1198
-
1199
- function sessionBaseName(path) {
1200
- if (!path) return ''
1201
- const parts = path.replace(/\\/g, '/').split('/')
1202
- const last = parts[parts.length - 1]
1203
- return last === '' ? (parts[parts.length - 2] || path) : last
1204
- }
1205
-
1206
- /**
1207
- * Extract { title, summary, messageCount } from a session's events. The
1208
- * scan is capped at SESSION_EVENT_SCAN_CAP events so a pathological log
1209
- * cannot monopolize the host loop; the message count may undercount past
1210
- * the cap, which is an acceptable trade for the panel.
1211
- * @param events - session events (live or persisted).
1212
- * @returns derived title, summary, and message count.
1213
- */
1214
- function deriveSessionSummary(events) {
1215
- let title = ''
1216
- let summary = ''
1217
- let messageCount = 0
1218
- if (!events || events.length === 0) return { title, summary, messageCount }
1219
- const cap = Math.min(events.length, SESSION_EVENT_SCAN_CAP)
1220
- for (let i = 0; i < cap; i++) {
1221
- const ev = events[i]
1222
- if (ev.type === 'session/title' && ev.data && typeof ev.data.title === 'string' && ev.data.title.trim()) {
1223
- title = ev.data.title.trim()
1224
- }
1225
- if (ev.type === 'user/message') {
1226
- messageCount++
1227
- if (!summary) {
1228
- const content = ev.data?.content
1229
- if (Array.isArray(content)) {
1230
- summary = content
1231
- .filter(b => b && b.type === 'text' && typeof b.text === 'string')
1232
- .map(b => b.text.trim())
1233
- .filter(Boolean)
1234
- .join(' ')
1235
- } else if (typeof ev.data?.text === 'string') {
1236
- summary = ev.data.text.trim()
1237
- }
1238
- }
1239
- }
1240
- }
1241
- // Derive a fallback title from the first line of the summary when no
1242
- // explicit session/title event exists.
1243
- if (!title && summary) {
1244
- const firstLine = summary.split('\n')[0].trim()
1245
- title = firstLine.length > 45 ? firstLine.slice(0, 45) + '...' : firstLine
1246
- }
1247
- return { title, summary, messageCount }
1248
- }
1249
-
1250
- /**
1251
- * Run an async mapper over an array with at most `limit` concurrent
1252
- * executions. Replaces the unbounded Promise.all fan-out in list().
1253
- * @param items - values to map.
1254
- * @param limit - max concurrency.
1255
- * @param mapper - async (item, index) => result.
1256
- * @returns results in input order.
1257
- */
1258
- async function mapLimit(items, limit, mapper) {
1259
- const results = new Array(items.length)
1260
- let cursor = 0
1261
- async function worker() {
1262
- while (cursor < items.length) {
1263
- const index = cursor++
1264
- results[index] = await mapper(items[index], index)
1265
- }
1266
- }
1267
- const workers = []
1268
- const count = Math.max(1, Math.min(limit, items.length))
1269
- for (let i = 0; i < count; i++) workers.push(worker())
1270
- await Promise.all(workers)
1271
- return results
1272
- }
1273
-
1274
- /* --------------------- Session Admin Remote Service --------------------- */
1275
- /**
1276
- * Remove a session's durable artifacts: log directory, workspace
1277
- * accounting, projection-cache record, archived-set entry, and the
1278
- * derived-summary cache. Shared by deleteSession (non-live sessions) and
1279
- * closeSession (after an online session has been torn down).
1280
- *
1281
- * The live-guard is a concurrency safety net for the deleteSession path: a
1282
- * session must not lose its log while it is (or just became) live, because
1283
- * the in-memory session would resurrect the file on the next flush.
1284
- * closeSession passes skipLiveGuard=true it has already torn the live
1285
- * session down through the official dispose chain, so the guard would only
1286
- * see the session's (now stale) live marker and wrongly refuse.
1287
- * @param sessionId - the session to remove.
1288
- * @param skipLiveGuard - whether to skip the "session became live" check.
1289
- */
1290
- async function removeSessionArtifacts(sessionId, skipLiveGuard = false) {
1291
- // Resolve the accounting workspace BEFORE removing the log: the entity
1292
- // getter projects against the registry's live header index, so after the
1293
- // rm (or any concurrent re-index) the id may stop resolving. detachSession
1294
- // prunes every record member missing from that index, so it must never be
1295
- // fired at unrelated workspaces one stale index entry would strip their
1296
- // whole durable session list (sessions then fall into ungrouped).
1297
- const accounting = ctx.workspaceRegistry.list()
1298
- .find(workspace => workspace.sessionIds.includes(sessionId))
1299
- // 1. Remove durable log artifacts
1300
- const headers = await ctx.sessionPersistence.list()
1301
- const header = headers.find(candidate => candidate.id === sessionId)
1302
- if (header !== undefined) {
1303
- const location = ctx.sessionPersistence.locate(header)
1304
- if (location !== undefined) {
1305
- if (location.kind !== 'jsonl') {
1306
- throw new Error(`session-admin: persistence backend '${location.kind}' artifacts are not handled by this plugin`)
1307
- }
1308
- const targetDir = dirname(location.path)
1309
- // The rm below owns the whole directory. If the persistence layout
1310
- // ever put two sessions in one directory, a recursive delete here
1311
- // would take the neighbor's log with it fail closed instead of
1312
- // wiping co-tenant data.
1313
- for (const candidate of headers) {
1314
- if (candidate.id === sessionId) continue
1315
- let other
1316
- try {
1317
- other = ctx.sessionPersistence.locate(candidate)
1318
- } catch {
1319
- other = undefined
1320
- }
1321
- if (other?.kind !== 'jsonl') continue
1322
- if (dirname(other.path) === targetDir
1323
- || dirname(other.path).replace(/\\/g, '/').toLowerCase()
1324
- === targetDir.replace(/\\/g, '/').toLowerCase()) {
1325
- throw new Error(`session-admin: refusing to delete '${sessionId}' — session '${candidate.id}' logs live in the same directory ('${targetDir}'); remove it manually`)
1326
- }
1327
- }
1328
- // Guard against a concurrent resume racing the rm: deleting the log
1329
- // of a session that just became live again would resurrect on flush.
1330
- // Skipped on the closeSession path, which has already torn the live
1331
- // session down through the official dispose chain.
1332
- if (!skipLiveGuard && sessionIsLive(ctx, sessionId)) {
1333
- throw new Error(`session '${sessionId}' became live — close it before deleting`)
1334
- }
1335
- await rm(targetDir, { recursive: true, force: true })
1336
- }
1337
- }
1338
- // 2. Detach workspace accounting — targeted, never a batch sweep
1339
- if (accounting !== undefined) {
1340
- await accounting.detachSession(sessionId)
1341
- }
1342
- // 3. Drop the session's projection-cache record so client-side session
1343
- // projections (sidebar tree) stop showing the deleted session right
1344
- // away instead of lingering in "未分组" until the next reload. The
1345
- // storage domain is already open by dsh-session-projection-cache.
1346
- try {
1347
- const projDomain = ctx.get ? ctx.get('storageDomain')?.get('session_projcache') : undefined
1348
- if (projDomain !== undefined && typeof projDomain.table === 'function') {
1349
- const sessionsTable = projDomain.table('sessions')
1350
- if (sessionsTable !== undefined && typeof sessionsTable.delete === 'function') {
1351
- await sessionsTable.delete(sessionId)
1352
- }
1353
- }
1354
- } catch (error) {
1355
- // Non-fatal: worst case the sidebar refreshes it away on reload.
1356
- }
1357
- // 4. Clear archived-set entry
1358
- await removeFromArchivedSet(ctx, sessionId)
1359
- // 5. Evict the derived-summary cache entry so the map never grows
1360
- // with deleted sessions (and a reused id never serves stale data).
1361
- sessionSummaryCache.delete(sessionId)
1362
- }
1363
-
1364
- const sessionService = {
1365
- async list() {
1366
- const headers = await ctx.sessionPersistence.list()
1367
- const archivedIds = ctx.workspaceRegistry.archivedSessionIds
1368
- const sessionsService = ctx.get('sessions')
1369
- const workspaces = ctx.workspaceRegistry.list()
1370
- // Mirror the sidebar's grouping: every session's accounting workspace
1371
- // comes from the registry's filtered sessionIds projection, so the
1372
- // admin view and the sidebar never disagree about membership. The host
1373
- // Workspace entity exposes its id as `id` (WorkspaceView's
1374
- // `workspaceId` is the wire-side rename done by apiproxy) — map it
1375
- // explicitly to keep the boundary JSON-safe (undefined values trip
1376
- // typert's assertJsonValue).
1377
- const sessionToWorkspace = new Map()
1378
- for (const ws of workspaces) {
1379
- for (const sid of ws.sessionIds) {
1380
- sessionToWorkspace.set(sid, { workspaceId: ws.id ?? null, title: ws.title ?? null })
1381
- }
1382
- }
1383
-
1384
- // Cheap revision tokens (listSnapshots) let us skip re-reading whole
1385
- // event logs for sessions whose summary is already cached and fresh.
1386
- const snapshots = typeof ctx.sessionPersistence.listSnapshots === 'function'
1387
- ? await ctx.sessionPersistence.listSnapshots()
1388
- : []
1389
- const revisionBySession = new Map()
1390
- for (const snapshot of snapshots) {
1391
- const id = snapshot?.header?.id ?? snapshot?.header ?? null
1392
- if (typeof id === 'string' && snapshot?.revision !== undefined) {
1393
- revisionBySession.set(id, snapshot.revision)
1394
- }
1395
- }
1396
-
1397
- const sessions = await mapLimit(headers, SESSION_LIST_CONCURRENCY, async (header) => {
1398
- const live = sessionIsLive(ctx, header.id)
1399
-
1400
- // 1. Summary cache: reuse unless the revision moved (or the entry
1401
- // is older than the TTL when no revision is available).
1402
- const revision = revisionBySession.get(header.id)
1403
- const cached = sessionSummaryCache.get(header.id)
1404
- const now = Date.now()
1405
- let derived = null
1406
- if (cached !== undefined && !cached.summaryError
1407
- && (revision !== undefined ? cached.revision === revision : now - cached.at <= SESSION_SUMMARY_CACHE_TTL_MS)) {
1408
- derived = cached
1409
- }
1410
-
1411
- if (derived === null) {
1412
- // 2. Live session events are in memory — cheapest read.
1413
- const liveSession = sessionsService?.get?.(header.id)
1414
- let events = liveSession ? liveSession.events : null
1415
-
1416
- if (!events && ctx.sessionPersistence !== undefined) {
1417
- try {
1418
- const inspection = await ctx.sessionPersistence.inspect(header.id)
1419
- events = inspection?.events ?? []
1420
- // inspect may carry a fresher revision than the snapshot list.
1421
- if (revision === undefined && inspection?.revision !== undefined) {
1422
- revisionBySession.set(header.id, inspection.revision)
1423
- }
1424
- } catch (error) {
1425
- events = []
1426
- derived = {
1427
- title: '', summary: '', messageCount: 0,
1428
- summaryError: error instanceof Error && error.message ? error.message : String(error),
1429
- }
1430
- }
1431
- }
1432
-
1433
- if (derived === null) derived = deriveSessionSummary(events)
1434
- derived.revision = revisionBySession.get(header.id) ?? null
1435
- derived.at = now
1436
- sessionSummaryCache.set(header.id, derived)
1437
- }
1438
-
1439
- // Prefer the projection-cache title (the same displayTitle the
1440
- // sidebar shows) so deleting by title from the sidebar menu matches
1441
- // the same session on the host side. cachedSnapshot works from the
1442
- // stored header — no live session needed — so ended sessions get
1443
- // their real title too instead of a cwd-basename fallback.
1444
- let projTitle = null
1445
- try {
1446
- const projCache = ctx.get ? ctx.get('sessionProjectionCache') : undefined
1447
- if (projCache !== undefined && typeof projCache.cachedSnapshot === 'function') {
1448
- const snap = projCache.cachedSnapshot(header)
1449
- if (snap && snap.values && typeof snap.values.title === 'string' && snap.values.title !== '') {
1450
- projTitle = snap.values.title
1451
- }
1452
- }
1453
- } catch (error) {
1454
- projTitle = null
1455
- }
1456
-
1457
- const ws = sessionToWorkspace.get(header.id)
1458
- return {
1459
- id: header.id,
1460
- cwd: header.cwd ?? null,
1461
- createdAt: header.createdAt,
1462
- parentSession: header.parentSession ?? null,
1463
- archived: archivedIds.includes(header.id),
1464
- live,
1465
- title: projTitle || derived.title || (header.cwd ? sessionBaseName(header.cwd) : '未命名会话'),
1466
- summary: derived.summary ? (derived.summary.length > 180 ? derived.summary.slice(0, 180) + '...' : derived.summary) : '',
1467
- summaryError: derived.summaryError || null,
1468
- messageCount: derived.messageCount,
1469
- workspaceId: ws?.workspaceId ?? null,
1470
- workspaceTitle: ws?.title ?? null,
1471
- }
1472
- })
1473
-
1474
- sessions.sort((left, right) => right.createdAt - left.createdAt)
1475
- return {
1476
- sessions,
1477
- workspaces: workspaces.map(ws => ({
1478
- workspaceId: ws.id ?? null,
1479
- title: ws.title ?? null,
1480
- path: ws.path ?? null,
1481
- })),
1482
- }
1483
- },
1484
-
1485
- async archive(sessionId) {
1486
- if (typeof sessionId !== 'string' || sessionId === '') {
1487
- throw new Error('session-admin: archive requires a sessionId string')
1488
- }
1489
- const headers = await ctx.sessionPersistence.list()
1490
- if (!headers.some(header => header.id === sessionId)) {
1491
- throw new Error(`session-admin: session '${sessionId}' does not exist`)
1492
- }
1493
- await ctx.workspaceRegistry.archiveSession(sessionId)
1494
- return { archived: sessionId }
1495
- },
1496
-
1497
- async unarchive(sessionId) {
1498
- if (typeof sessionId !== 'string' || sessionId === '') {
1499
- throw new Error('session-admin: unarchive requires a sessionId string')
1500
- }
1501
- await removeFromArchivedSet(ctx, sessionId)
1502
- return { unarchived: sessionId }
1503
- },
1504
-
1505
- /**
1506
- * Delete a non-live (ended) session's durable artifacts. Online sessions
1507
- * must use closeSession instead disposing the live agent first so the
1508
- * log cannot resurrect.
1509
- * @param sessionId - the session to delete.
1510
- */
1511
- async deleteSession(sessionId) {
1512
- if (typeof sessionId !== 'string' || sessionId === '') {
1513
- throw new Error('session-admin: deleteSession requires a sessionId string')
1514
- }
1515
- if (sessionIsLive(ctx, sessionId)) {
1516
- throw new Error(`session '${sessionId}' is live — close it before deleting`)
1517
- }
1518
- await removeSessionArtifacts(sessionId)
1519
- return { deleted: sessionId }
1520
- },
1521
-
1522
- /**
1523
- * Delete an ONLINE session without restarting dsh. If the session is
1524
- * live in the in-memory store, its captured AgentHandle is disposed first
1525
- * dsh's official teardown chain stops the agent loop, waits for
1526
- * quiescence, unregisters the agent, removes the session from the store
1527
- * (emitting `session/disposed`), and lets the persistence backend flush
1528
- * buffered events and release its write path so removing the log file
1529
- * afterwards cannot resurrect it. Non-live sessions simply skip the
1530
- * dispose step.
1531
- *
1532
- * The dispose is a real agent shutdown: a running conversation in that
1533
- * session is stopped. Callers must surface this before invoking.
1534
- *
1535
- * @param sessionId - the session to close and delete.
1536
- * @returns { deleted: sessionId }.
1537
- */
1538
- async closeSession(sessionId) {
1539
- if (typeof sessionId !== 'string' || sessionId === '') {
1540
- throw new Error('session-admin: closeSession requires a sessionId string')
1541
- }
1542
- if (sessionIsLive(ctx, sessionId)) {
1543
- const handle = handleCapture.get(sessionId)
1544
- if (handle === undefined) {
1545
- throw new Error(`session '${sessionId}' is live but its agent handle was not captured (created before this plugin mounted?) — restart dsh, then delete`)
1546
- }
1547
- await handle.dispose()
1548
- handleCapture.delete(sessionId)
1549
- }
1550
- await removeSessionArtifacts(sessionId, true)
1551
- return { deleted: sessionId }
1552
- },
1553
- }
1554
-
1555
- const sessionBinding = Object.freeze({ service: sessionService, serviceKey: SESSION_SERVICE_KEY, namespace: SESSION_NAMESPACE })
1556
- Object.defineProperty(sessionService, 'typertRemote', { value: sessionBinding, enumerable: false })
1557
- ctx.provide(SESSION_SERVICE_KEY, sessionService)
1558
-
1559
- /* ----------------------- FS Admin Remote Service ------------------------ */
1560
- /**
1561
- * Bring the Explorer window showing `path` to the foreground.
1562
- * explorer.exe spawned from a background service has no foreground rights
1563
- * (Windows foreground-lock), so the new window opens behind the active one.
1564
- * This helper runs a PowerShell script that polls for the window and raises
1565
- * it with the classic ALT-key + SetForegroundWindow trick: a simulated ALT
1566
- * keypress makes the system treat the caller as having user input, which
1567
- * grants SetForegroundWindow permission. Fail-soft and never blocks the
1568
- * host loop.
1569
- * @param path - the directory (or file) the Explorer window shows.
1570
- */
1571
- function bringExplorerWindowToFront(path) {
1572
- try {
1573
- const helperPath = join(MODULE_DIR, 'bring-explorer.ps1')
1574
- // Windows caveats: the helper must spawn WITHOUT detached and with
1575
- // real stdout/stderr pipes detached or 'ignore' stdio leaves the
1576
- // child with invalid handles and PowerShell silently fails to start
1577
- // in a non-interactive context. unref() still lets dsh exit freely.
1578
- const child = spawn('powershell.exe', [
1579
- '-NoProfile', '-ExecutionPolicy', 'Bypass',
1580
- '-File', helperPath, '-Path', path,
1581
- ], { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
1582
- child.stdout?.resume()
1583
- child.stderr?.resume()
1584
- child.on('error', () => { /* non-fatal */ })
1585
- child.unref()
1586
- } catch {
1587
- /* non-fatal */
1588
- }
1589
- }
1590
-
1591
-
1592
- /**
1593
- * Reveal a filesystem path in the platform file manager.
1594
- * @param path - absolute path to reveal (workspace directory or file).
1595
- * @returns the revealed path.
1596
- */
1597
- const fsService = {
1598
- async reveal(path) {
1599
- if (typeof path !== 'string' || path.trim() === '') {
1600
- throw new Error('fs-admin: reveal requires a path string')
1601
- }
1602
- const trimmed = path.trim()
1603
- // Best-effort: spawn a reveal command without blocking the host loop.
1604
- // Windows: a directory opens its own Explorer window; a file gets
1605
- // /select to reveal it inside its parent folder. macOS: open -R;
1606
- // Linux: xdg-open (no portable 'select' verb).
1607
- let args
1608
- if (process.platform === 'win32') {
1609
- let isDir = false
1610
- try { isDir = statSync(trimmed).isDirectory() } catch { isDir = false }
1611
- args = isDir ? [trimmed] : ['/select,' + trimmed]
1612
- } else if (process.platform === 'darwin') {
1613
- args = ['-R', trimmed]
1614
- } else {
1615
- // Linux: xdg-open on a directory opens it in the file manager; a
1616
- // file has no portable 'select' verb, so its parent is opened.
1617
- let isDir = false
1618
- try { isDir = statSync(trimmed).isDirectory() } catch { isDir = false }
1619
- args = [isDir ? trimmed : dirname(trimmed)]
1620
- }
1621
- const cmd = process.platform === 'win32'
1622
- ? spawn('explorer.exe', args, { detached: true, stdio: 'ignore' })
1623
- : process.platform === 'darwin'
1624
- ? spawn('open', args, { detached: true, stdio: 'ignore' })
1625
- : spawn('xdg-open', args, { detached: true, stdio: 'ignore' })
1626
- cmd.on('error', () => { /* non-fatal: the user can open the path manually */ })
1627
- cmd.unref()
1628
-
1629
- // Windows: bring the newly opened Explorer window to the foreground.
1630
- // explorer.exe spawns from a background service have no foreground
1631
- // rights, so the window opens behind the current one. A PowerShell
1632
- // helper polls for the window and uses the classic ALT-key +
1633
- // SetForegroundWindow trick (simulated user input) to raise it.
1634
- if (process.platform === 'win32') {
1635
- try { bringExplorerWindowToFront(trimmed) } catch { /* best-effort */ }
1636
- }
1637
- return { path: trimmed }
1638
- },
1639
- }
1640
-
1641
- const fsBinding = Object.freeze({ service: fsService, serviceKey: FS_SERVICE_KEY, namespace: FS_NAMESPACE })
1642
- Object.defineProperty(fsService, 'typertRemote', { value: fsBinding, enumerable: false })
1643
- ctx.provide(FS_SERVICE_KEY, fsService)
1644
-
1645
- /* ----------------------- MCP Admin Remote Service ----------------------- */
1646
- /**
1647
- * Parse top-level plugin-instance entries from a cordis patch file. Only
1648
- * entries naming the MCP client plugin are returned; every other entry is
1649
- * left untouched. The editor is line-based (zero YAML dependency) and
1650
- * tracks entries by their `- id:` / `name:` top-level rows.
1651
- * @param profileDir - the profile directory.
1652
- * @returns the parsed MCP entries and the raw patch lines.
1653
- */
1654
- function readPatchLines(profileDir) {
1655
- const patchPath = join(profileDir, PROFILE_PATCH_FILENAME)
1656
- const text = existsSync(patchPath) ? readFileSync(patchPath, 'utf8') : ''
1657
- return { text, lines: text.split(/\r?\n/), patchPath }
1658
- }
1659
-
1660
- /**
1661
- * Locate the top-level entry blocks in the patch file. A block starts at a
1662
- * line matching /^- / (top-level list item) and continues through the next
1663
- * top-level item or the file end.
1664
- * @param lines - patch file lines.
1665
- * @returns array of { index, endIndex } block spans.
1666
- */
1667
- function topLevelBlocks(lines) {
1668
- const blocks = []
1669
- let start = -1
1670
- for (let i = 0; i < lines.length; i++) {
1671
- if (/^- /.test(lines[i])) {
1672
- if (start !== -1) blocks.push({ index: start, endIndex: i })
1673
- start = i
1674
- }
1675
- }
1676
- if (start !== -1) blocks.push({ index: start, endIndex: lines.length })
1677
- return blocks
1678
- }
1679
-
1680
- /**
1681
- * Whether a block is an MCP client entry: its lines contain a `name:` row
1682
- * whose value is the MCP plugin name.
1683
- * @param lines - patch file lines.
1684
- * @param block - block span.
1685
- */
1686
- function isMcpBlock(lines, block) {
1687
- for (let i = block.index; i < block.endIndex; i++) {
1688
- const line = lines[i]
1689
- if (/^\s*name:\s*['"]?@deepseek-ai\/dsh-mcp-client['"]?\s*$/.test(line)) return true
1690
- }
1691
- return false
1692
- }
1693
-
1694
- /**
1695
- * Extract the entry id (`- id: <id>`) and serverName from a block.
1696
- * @param lines - patch file lines.
1697
- * @param block - block span.
1698
- * @returns { id, serverName } or null when the id is absent.
1699
- */
1700
- function blockIdentity(lines, block) {
1701
- let id = null
1702
- let serverName = null
1703
- for (let i = block.index; i < block.endIndex; i++) {
1704
- const line = lines[i]
1705
- const idMatch = /^-\s*id:\s*['"]?([^'"\s]+)['"]?\s*$/.exec(line)
1706
- if (idMatch) id = idMatch[1]
1707
- const nameMatch = /^\s*serverName:\s*['"]?([^'"\s]+)['"]?\s*$/.exec(line)
1708
- if (nameMatch) serverName = nameMatch[1]
1709
- }
1710
- return { id, serverName }
1711
- }
1712
-
1713
- function yamlScalar(value) {
1714
- const text = value.trim()
1715
- try {
1716
- return JSON.parse(text)
1717
- } catch {
1718
- const quoted = /^['"](.*)['"]$/.exec(text)
1719
- return quoted ? quoted[1] : text
1720
- }
1721
- }
1722
-
1723
- /** Parse the supported MCP config shape without rewriting unknown blocks. */
1724
- function configFromBlock(lines, block) {
1725
- const config = {}
1726
- let collection = null
1727
- for (let i = block.index; i < block.endIndex; i++) {
1728
- const line = lines[i]
1729
- const field = /^ (transport|serverName|command|url|cwd|toolCallTimeoutMs|failOnStartupError|args|env|headers|reconnect):\s*(.*)$/.exec(line)
1730
- if (field) {
1731
- const key = field[1]
1732
- const value = field[2]
1733
- if (key === 'args') {
1734
- config.args = []
1735
- collection = 'args'
1736
- } else if (key === 'env' || key === 'headers' || key === 'reconnect') {
1737
- config[key] = {}
1738
- collection = key
1739
- } else {
1740
- config[key] = yamlScalar(value)
1741
- collection = null
1742
- }
1743
- continue
1744
- }
1745
- const item = /^ -\s*(.*)$/.exec(line)
1746
- if (item && collection === 'args') {
1747
- const value = yamlScalar(item[1])
1748
- if (typeof value === 'string') config.args.push(value)
1749
- continue
1750
- }
1751
- const property = /^ ([^:]+):\s*(.*)$/.exec(line)
1752
- if (property && collection && collection !== 'args') {
1753
- config[collection][property[1].trim()] = yamlScalar(property[2])
1754
- }
1755
- }
1756
- if (config.transport === 'stdio' && typeof config.serverName === 'string' && typeof config.command === 'string') return config
1757
- if (config.transport === 'streamable-http' && typeof config.serverName === 'string' && typeof config.url === 'string') return config
1758
- return null
1759
- }
1760
-
1761
- /**
1762
- * Serialize one MCP entry into YAML lines (the exact shape the mcp-client
1763
- * plugin consumes). The entry id is a stable local handle; serverName is the
1764
- * model-facing namespace.
1765
- * @param entry - normalized MCP entry.
1766
- * @returns YAML lines (without trailing newline).
1767
- */
1768
- function mcpEntryLines(entry) {
1769
- const out = []
1770
- out.push('- id: ' + JSON.stringify(entry.id))
1771
- out.push(" name: '@deepseek-ai/dsh-mcp-client'")
1772
- out.push(' config:')
1773
- out.push(' transport: ' + entry.config.transport)
1774
- out.push(' serverName: ' + JSON.stringify(entry.config.serverName))
1775
- if (entry.config.transport === 'stdio') {
1776
- out.push(' command: ' + JSON.stringify(entry.config.command))
1777
- const args = entry.config.args ?? []
1778
- if (args.length > 0) {
1779
- out.push(' args:')
1780
- for (const a of args) out.push(' - ' + JSON.stringify(a))
1781
- }
1782
- const env = entry.config.env ?? {}
1783
- const envKeys = Object.keys(env)
1784
- if (envKeys.length > 0) {
1785
- out.push(' env:')
1786
- for (const k of envKeys) out.push(' ' + k + ': ' + JSON.stringify(env[k]))
1787
- }
1788
- if (entry.config.cwd) out.push(' cwd: ' + JSON.stringify(entry.config.cwd))
1789
- } else {
1790
- out.push(' url: ' + JSON.stringify(entry.config.url))
1791
- const headers = entry.config.headers ?? {}
1792
- const headerKeys = Object.keys(headers)
1793
- if (headerKeys.length > 0) {
1794
- out.push(' headers:')
1795
- for (const k of headerKeys) out.push(' ' + k + ': ' + JSON.stringify(headers[k]))
1796
- }
1797
- }
1798
- if (entry.config.toolCallTimeoutMs !== undefined) {
1799
- out.push(' toolCallTimeoutMs: ' + Number(entry.config.toolCallTimeoutMs))
1800
- }
1801
- if (entry.config.failOnStartupError === true) {
1802
- out.push(' failOnStartupError: true')
1803
- }
1804
- if (entry.config.reconnect && typeof entry.config.reconnect === 'object') {
1805
- out.push(' reconnect:')
1806
- for (const key of ['enabled', 'initialDelayMs', 'maxDelayMs', 'maxAttempts']) {
1807
- if (entry.config.reconnect[key] !== undefined) {
1808
- out.push(' ' + key + ': ' + JSON.stringify(entry.config.reconnect[key]))
1809
- }
1810
- }
1811
- }
1812
- return out
1813
- }
1814
-
1815
- /**
1816
- * List the MCP entries currently declared in the profile patch file.
1817
- * @returns { entries, patchPath }.
1818
- */
1819
- function listMcpEntries() {
1820
- const { lines, patchPath } = readPatchLines(profileDir)
1821
- const entries = []
1822
- for (const block of topLevelBlocks(lines)) {
1823
- if (!isMcpBlock(lines, block)) continue
1824
- const { id, serverName } = blockIdentity(lines, block)
1825
- if (id === null) continue
1826
- const config = configFromBlock(lines, block)
1827
- entries.push({
1828
- id,
1829
- serverName: config?.serverName ?? serverName ?? id,
1830
- config,
1831
- raw: lines.slice(block.index, block.endIndex).join('\n'),
1832
- })
1833
- }
1834
- return { entries, patchPath }
1835
- }
1836
-
1837
- /**
1838
- * Persist the patch file atomically.
1839
- * @param patchPath - patch file path.
1840
- * @param text - next full file content.
1841
- */
1842
- function writePatch(patchPath, text) {
1843
- const temp = patchPath + '.dsh-admin.tmp'
1844
- writeFileSync(temp, text, 'utf8')
1845
- renameSync(temp, patchPath)
1846
- }
1847
-
1848
- const mcpService = {
1849
- async list() {
1850
- return listMcpEntries()
1851
- },
1852
-
1853
- async upsert(entry) {
1854
- if (entry === null || typeof entry !== 'object' || typeof entry.id !== 'string' || entry.id === '') {
1855
- throw new Error('mcp-admin: upsert requires an entry with a non-empty id')
1856
- }
1857
- if (!/^[A-Za-z0-9_-]{1,64}$/.test(entry.id)) {
1858
- throw new Error('mcp-admin: entry id must match [A-Za-z0-9_-]{1,64}')
1859
- }
1860
- const cfg = entry.config
1861
- if (cfg === null || typeof cfg !== 'object' || typeof cfg.transport !== 'string'
1862
- || (cfg.transport !== 'stdio' && cfg.transport !== 'streamable-http')) {
1863
- throw new Error('mcp-admin: config.transport must be \'stdio\' or \'streamable-http\'')
1864
- }
1865
- if (typeof cfg.serverName !== 'string' || !/^[A-Za-z0-9_-]{1,32}$/.test(cfg.serverName)) {
1866
- throw new Error('mcp-admin: serverName must match [A-Za-z0-9_-]{1,32}')
1867
- }
1868
- if (cfg.transport === 'stdio' && (typeof cfg.command !== 'string' || cfg.command === '')) {
1869
- throw new Error('mcp-admin: stdio entries require a command')
1870
- }
1871
- if (cfg.transport === 'streamable-http' && (typeof cfg.url !== 'string' || cfg.url === '')) {
1872
- throw new Error('mcp-admin: streamable-http entries require a url')
1873
- }
1874
- // Patch-file mutations ride the same serialized operation queue as
1875
- // pluginAdmin: the body is fully synchronous today (the event loop
1876
- // already serializes it), but the queue keeps the read-modify-write
1877
- // atomic if an await ever lands inside and the duplicate-serverName
1878
- // check then sees the queue-time file state, not a stale snapshot.
1879
- return enqueue(async () => {
1880
- const { lines, patchPath } = readPatchLines(profileDir)
1881
- const blocks = topLevelBlocks(lines)
1882
- const duplicateServer = blocks.find(block => {
1883
- if (!isMcpBlock(lines, block)) return false
1884
- const identity = blockIdentity(lines, block)
1885
- return identity.id !== entry.id && identity.serverName === cfg.serverName
1886
- })
1887
- if (duplicateServer !== undefined) {
1888
- throw new Error(`mcp-admin: serverName '${cfg.serverName}' is already used by another MCP entry`)
1889
- }
1890
- const target = blocks.find(block => {
1891
- if (!isMcpBlock(lines, block)) return false
1892
- return blockIdentity(lines, block).id === entry.id
1893
- })
1894
- const blockLines = mcpEntryLines(entry)
1895
- let next
1896
- if (target !== undefined) {
1897
- next = [
1898
- ...lines.slice(0, target.index),
1899
- ...blockLines,
1900
- ...lines.slice(target.endIndex),
1901
- ].join('\n')
1902
- } else {
1903
- // Append after the last entry. The file may carry a header comment
1904
- // plus a '[]' placeholder (an empty YAML list). That placeholder is
1905
- // a complete document: leaving it in place and starting a new
1906
- // `- id:` line below produces a second document and a YAML parse
1907
- // error ("end of the stream or a document separator is expected")
1908
- // at profile boot. So replace the placeholder line with the block;
1909
- // otherwise append after the last line.
1910
- let placeholder = -1
1911
- for (let i = lines.length - 1; i >= 0; i--) {
1912
- if (lines[i].trim() === '[]') { placeholder = i; break }
1913
- }
1914
- if (placeholder !== -1) {
1915
- next = [
1916
- ...lines.slice(0, placeholder),
1917
- ...blockLines,
1918
- ...lines.slice(placeholder + 1),
1919
- ].join('\n')
1920
- if (!next.endsWith('\n')) next += '\n'
1921
- } else {
1922
- const trimmed = lines.join('\n').trimEnd()
1923
- const base = trimmed === '' ? '' : trimmed + '\n'
1924
- next = base + blockLines.join('\n') + '\n'
1925
- }
1926
- }
1927
- writePatch(patchPath, next)
1928
- return { ok: true, id: entry.id, entries: listMcpEntries().entries }
1929
- })
1930
- },
1931
-
1932
- async remove(id) {
1933
- if (typeof id !== 'string' || id === '') {
1934
- throw new Error('mcp-admin: remove requires an entry id')
1935
- }
1936
- return enqueue(async () => {
1937
- const { lines, patchPath } = readPatchLines(profileDir)
1938
- const blocks = topLevelBlocks(lines)
1939
- const target = blocks.find(block => {
1940
- if (!isMcpBlock(lines, block)) return false
1941
- return blockIdentity(lines, block).id === id
1942
- })
1943
- if (target === undefined) {
1944
- throw new Error(`mcp-admin: entry '${id}' not found in ${PROFILE_PATCH_FILENAME}`)
1945
- }
1946
- const next = [
1947
- ...lines.slice(0, target.index),
1948
- ...lines.slice(target.endIndex),
1949
- ].join('\n').trimEnd() + '\n'
1950
- writePatch(patchPath, next)
1951
- return { ok: true, id, entries: listMcpEntries().entries }
1952
- })
1953
- },
1954
-
1955
- /**
1956
- * Probe the connectivity of one configured MCP server (by entry id) from
1957
- * the host, without requiring a dsh restart. The probe mirrors the real
1958
- * dsh-mcp-client handshake (initialize → initialized → tools/list) over
1959
- * the entry's configured transport, with a strict timeout so a dead
1960
- * endpoint fails fast instead of hanging the panel.
1961
- * @param id - MCP entry id.
1962
- * @returns a probe result (never rejects):
1963
- * { ok: true, serverInfo, toolCount, transport, ms, pingOk? } or
1964
- * { ok: false, error, transport, ms, stderr? }.
1965
- */
1966
- async test(id) {
1967
- if (typeof id !== 'string' || id === '') {
1968
- throw new Error('mcp-admin: test requires an entry id')
1969
- }
1970
- const entry = listMcpEntries().entries.find(entry => entry.id === id)
1971
- if (entry === undefined) {
1972
- throw new Error(`mcp-admin: entry '${id}' not found in ${PROFILE_PATCH_FILENAME}`)
1973
- }
1974
- if (entry.config === null || entry.config === undefined) {
1975
- throw new Error(`mcp-admin: entry '${id}' has an unparsable config; fix ${PROFILE_PATCH_FILENAME} manually`)
1976
- }
1977
- return probeMcpServer(entry.config)
1978
- },
1979
- }
1980
-
1981
- const mcpBinding = Object.freeze({ service: mcpService, serviceKey: MCP_SERVICE_KEY, namespace: MCP_NAMESPACE })
1982
- Object.defineProperty(mcpService, 'typertRemote', { value: mcpBinding, enumerable: false })
1983
- ctx.provide(MCP_SERVICE_KEY, mcpService)
1984
-
1985
- /* ---------------------- Typert Descriptors Register ---------------------- */
1986
- const specParam = [{ name: 'spec', wire: 'spec', source: 'json', codec: { mode: 'src-json' } }]
1987
- const nameParam = [{ name: 'name', wire: 'name', source: 'json', codec: { mode: 'src-json' } }]
1988
- const sessionParam = [{ name: 'sessionId', wire: 'sessionId', source: 'json', codec: { mode: 'src-json' } }]
1989
-
1990
- ctx.effect(() => ctx.typert.register({
1991
- package: PACKAGE,
1992
- face: 'host',
1993
- schemas: [],
1994
- model: { services: [], events: [], objects: [] },
1995
- invocations: [
1996
- // pluginAdmin
1997
- {
1998
- id: `${PACKAGE}/list`,
1999
- service: PLUGIN_SERVICE_KEY,
2000
- namespace: PLUGIN_NAMESPACE,
2001
- method: 'list',
2002
- invocation: { kind: 'direct' },
2003
- parameters: [],
2004
- result: { mode: 'src-json' },
2005
- },
2006
- {
2007
- id: `${PACKAGE}/install`,
2008
- service: PLUGIN_SERVICE_KEY,
2009
- namespace: PLUGIN_NAMESPACE,
2010
- method: 'install',
2011
- invocation: { kind: 'direct' },
2012
- parameters: specParam,
2013
- result: { mode: 'src-json' },
2014
- },
2015
- {
2016
- id: `${PACKAGE}/remove`,
2017
- service: PLUGIN_SERVICE_KEY,
2018
- namespace: PLUGIN_NAMESPACE,
2019
- method: 'remove',
2020
- invocation: { kind: 'direct' },
2021
- parameters: nameParam,
2022
- result: { mode: 'src-json' },
2023
- },
2024
- {
2025
- id: `${PACKAGE}/checkUpdates`,
2026
- service: PLUGIN_SERVICE_KEY,
2027
- namespace: PLUGIN_NAMESPACE,
2028
- method: 'checkUpdates',
2029
- invocation: { kind: 'direct' },
2030
- parameters: [],
2031
- result: { mode: 'src-json' },
2032
- },
2033
- // sessionAdmin
2034
- {
2035
- id: `${PACKAGE}/session/list`,
2036
- service: SESSION_SERVICE_KEY,
2037
- namespace: SESSION_NAMESPACE,
2038
- method: 'list',
2039
- invocation: { kind: 'direct' },
2040
- parameters: [],
2041
- result: { mode: 'src-json' },
2042
- },
2043
- {
2044
- id: `${PACKAGE}/session/archive`,
2045
- service: SESSION_SERVICE_KEY,
2046
- namespace: SESSION_NAMESPACE,
2047
- method: 'archive',
2048
- invocation: { kind: 'direct' },
2049
- parameters: sessionParam,
2050
- result: { mode: 'src-json' },
2051
- },
2052
- {
2053
- id: `${PACKAGE}/session/unarchive`,
2054
- service: SESSION_SERVICE_KEY,
2055
- namespace: SESSION_NAMESPACE,
2056
- method: 'unarchive',
2057
- invocation: { kind: 'direct' },
2058
- parameters: sessionParam,
2059
- result: { mode: 'src-json' },
2060
- },
2061
- {
2062
- id: `${PACKAGE}/session/deleteSession`,
2063
- service: SESSION_SERVICE_KEY,
2064
- namespace: SESSION_NAMESPACE,
2065
- method: 'deleteSession',
2066
- invocation: { kind: 'direct' },
2067
- parameters: sessionParam,
2068
- result: { mode: 'src-json' },
2069
- },
2070
- {
2071
- id: `${PACKAGE}/session/closeSession`,
2072
- service: SESSION_SERVICE_KEY,
2073
- namespace: SESSION_NAMESPACE,
2074
- method: 'closeSession',
2075
- invocation: { kind: 'direct' },
2076
- parameters: sessionParam,
2077
- result: { mode: 'src-json' },
2078
- },
2079
- // fsAdmin
2080
- {
2081
- id: `${PACKAGE}/fs/reveal`,
2082
- service: FS_SERVICE_KEY,
2083
- namespace: FS_NAMESPACE,
2084
- method: 'reveal',
2085
- invocation: { kind: 'direct' },
2086
- parameters: [{ name: 'path', wire: 'path', source: 'json', codec: { mode: 'src-json' } }],
2087
- result: { mode: 'src-json' },
2088
- },
2089
- // mcpAdmin
2090
- {
2091
- id: `${PACKAGE}/mcp/list`,
2092
- service: MCP_SERVICE_KEY,
2093
- namespace: MCP_NAMESPACE,
2094
- method: 'list',
2095
- invocation: { kind: 'direct' },
2096
- parameters: [],
2097
- result: { mode: 'src-json' },
2098
- },
2099
- {
2100
- id: `${PACKAGE}/mcp/upsert`,
2101
- service: MCP_SERVICE_KEY,
2102
- namespace: MCP_NAMESPACE,
2103
- method: 'upsert',
2104
- invocation: { kind: 'direct' },
2105
- parameters: [{ name: 'entry', wire: 'entry', source: 'json', codec: { mode: 'src-json' } }],
2106
- result: { mode: 'src-json' },
2107
- },
2108
- {
2109
- id: `${PACKAGE}/mcp/remove`,
2110
- service: MCP_SERVICE_KEY,
2111
- namespace: MCP_NAMESPACE,
2112
- method: 'remove',
2113
- invocation: { kind: 'direct' },
2114
- parameters: [{ name: 'id', wire: 'id', source: 'json', codec: { mode: 'src-json' } }],
2115
- result: { mode: 'src-json' },
2116
- },
2117
- {
2118
- id: `${PACKAGE}/mcp/test`,
2119
- service: MCP_SERVICE_KEY,
2120
- namespace: MCP_NAMESPACE,
2121
- method: 'test',
2122
- invocation: { kind: 'direct' },
2123
- parameters: [{ name: 'id', wire: 'id', source: 'json', codec: { mode: 'src-json' } }],
2124
- result: { mode: 'src-json' },
2125
- },
2126
- ],
2127
- }), 'plugin-admin: typert descriptors (plugins, sessions, fs, mcp)')
2128
- }
1
+ /**
2
+ * dsh-plugin-admin host half. Zero dsh imports on purpose: everything rides
3
+ * the live Cordis Context (services by key) and plain-data typert registration.
4
+ *
5
+ * Remote surfaces served by the /api RPC gateway:
6
+ *
7
+ * 1. Namespace `pluginAdmin`:
8
+ * - list() → the profile's bundle layers with version/source/removable
9
+ * - install(spec) → `pnpm add <spec>` in the profile directory, then
10
+ * reconcile the package.json `dsh.profile.bundles` layer list
11
+ * - remove(name) → `pnpm remove <name>` + the same reconcile
12
+ *
13
+ * 2. Namespace `sessionAdmin`:
14
+ * - list() → all persisted sessions with archived/live flags
15
+ * - archive(sessionId) → mark session as archived in workspace registry
16
+ * - unarchive(sessionId) → remove from the registry's archived set
17
+ * - deleteSession(sessionId) → rm the session log directory, detach workspace
18
+ * accounting, and clear any archived-set entry
19
+ */
20
+
21
+ import { spawn, spawnSync } from 'node:child_process'
22
+ import { existsSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
23
+ import { rm } from 'node:fs/promises'
24
+ import { createRequire } from 'node:module'
25
+ import { basename, dirname, join } from 'node:path'
26
+ import { homedir } from 'node:os'
27
+ import { fileURLToPath } from 'node:url'
28
+ import { setTimeout as sleep } from 'node:timers/promises'
29
+
30
+ /** Services required before this plugin mounts. */
31
+ export const inject = ['typert', 'workspaceRegistry', 'sessionPersistence']
32
+
33
+ const PLUGIN_SERVICE_KEY = 'pluginAdmin'
34
+ const PLUGIN_NAMESPACE = 'pluginAdmin'
35
+ const SESSION_SERVICE_KEY = 'sessionAdmin'
36
+ const SESSION_NAMESPACE = 'sessionAdmin'
37
+ const FS_SERVICE_KEY = 'fsAdmin'
38
+ const FS_NAMESPACE = 'fsAdmin'
39
+ const MCP_SERVICE_KEY = 'mcpAdmin'
40
+ const MCP_NAMESPACE = 'mcpAdmin'
41
+ const PACKAGE = 'dsh-plugin-admin'
42
+ const MODULE_DIR = dirname(fileURLToPath(import.meta.url))
43
+ const PNPM_TIMEOUT_MS = 5 * 60_000
44
+
45
+ // Remote update check knobs: query the npm registry (the same registry npm
46
+ // uses — env override, .npmrc, or the official default) for the `latest`
47
+ // dist-tag of each registry-installed bundle and compare with the local
48
+ // version. Bounded concurrency, strict timeout, and a short-lived cache so
49
+ // the panel never hammers the registry on every refresh.
50
+ const UPDATE_CHECK_TIMEOUT_MS = 8_000
51
+ const UPDATE_CHECK_CONCURRENCY = 4
52
+ const UPDATE_CHECK_CACHE_TTL_MS = 5 * 60_000
53
+ const NPM_REGISTRY_DEFAULT = 'https://registry.npmjs.org'
54
+
55
+ // MCP connectivity probe knobs: the probe speaks the same newline-delimited
56
+ // JSON-RPC (stdio) / Streamable HTTP protocol the dsh-mcp-client plugin uses,
57
+ // but with a strict budget so a wedged server can never hang the panel.
58
+ const MCP_PROBE_TIMEOUT_MS = 10_000
59
+ const MCP_PROBE_HTTP_TIMEOUT_MS = 8_000
60
+ const MCP_PROBE_MAX_RESPONSE_BYTES = 256 * 1024
61
+
62
+ // The MCP client plugin whose config instances this admin manages.
63
+ const MCP_PLUGIN_NAME = '@deepseek-ai/dsh-mcp-client'
64
+ // Profile patch file holding the MCP server entries (top-level plugin instances).
65
+ const PROFILE_PATCH_FILENAME = 'cordis.patch.yml'
66
+
67
+ // Session-summary cache: re-reading every session's full event log on each
68
+ // list() call is O(history volume). The persistence service exposes a cheap
69
+ // per-session `revision` token (via inspect), so we cache the derived
70
+ // { title, summary, messageCount } against it and only re-inspect when the
71
+ // revision moves. The cache lives for the plugin's lifetime.
72
+ const SESSION_SUMMARY_CACHE_TTL_MS = 60_000
73
+ // Bound concurrent log reads while listing: dozens of large sessions should
74
+ // never fan out into unbounded Promise.all I/O.
75
+ const SESSION_LIST_CONCURRENCY = 4
76
+ // Guard against pathological single sessions: only this many events are
77
+ // examined per session before the loop bails (messageCount may undercount).
78
+ const SESSION_EVENT_SCAN_CAP = 20_000
79
+
80
+ // sessionId -> { revision, title, summary, messageCount, at }. Keyed by the
81
+ // persistence revision token so unchanged sessions skip re-reading their
82
+ // whole event log on every panel refresh.
83
+ const sessionSummaryCache = new Map()
84
+
85
+ /* ========================================================================== */
86
+ /* Plugin Admin Logic */
87
+ /* ========================================================================== */
88
+
89
+ /**
90
+ * Resolve the profile directory from the config-tree anchor: the loader's
91
+ * baseUrl is the cordis.yml anchor; the profile's package.json sits beside
92
+ * it (or the anchor already is the directory).
93
+ * @param baseUrl - the loader config-tree anchor.
94
+ * @returns the profile directory holding package.json.
95
+ * @throws {Error} when package.json cannot be located beside the anchor.
96
+ */
97
+ function profileDirOf(baseUrl) {
98
+ const anchor = typeof baseUrl === 'string' && baseUrl.startsWith('file:')
99
+ ? fileURLToPath(baseUrl)
100
+ : String(baseUrl)
101
+ if (existsSync(join(anchor, 'package.json'))) return anchor
102
+ const parent = dirname(anchor)
103
+ if (existsSync(join(parent, 'package.json'))) return parent
104
+ throw new Error(`plugin-admin: no profile package.json beside config anchor ${String(baseUrl)}`)
105
+ }
106
+
107
+ /**
108
+ * @param profileDir - the profile directory.
109
+ * @returns a require anchored at the profile's package.json.
110
+ */
111
+ function requireOf(profileDir) {
112
+ return createRequire(join(profileDir, 'package.json'))
113
+ }
114
+
115
+ /**
116
+ * @param require - profile-anchored require.
117
+ * @param name - dependency package name.
118
+ * @returns the parsed manifest, or undefined when unresolvable.
119
+ */
120
+ function readManifest(require, name) {
121
+ try {
122
+ return JSON.parse(readFileSync(require.resolve(`${name}/package.json`), 'utf8'))
123
+ } catch {
124
+ return undefined
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Whether a package declares a bundle patch (i.e. is a profile layer).
130
+ * @param require - profile-anchored require.
131
+ * @param name - dependency package name.
132
+ */
133
+ function declaresBundle(require, name) {
134
+ const manifest = readManifest(require, name)
135
+ return manifest !== undefined
136
+ && typeof manifest.dsh === 'object' && manifest.dsh !== null
137
+ && manifest.dsh.bundle !== undefined
138
+ && manifest.dsh.bundle.patch !== undefined
139
+ }
140
+
141
+ /**
142
+ * The local source path a dependency spec installs from, when it is a local
143
+ * install (`link:<dir>` / `file:<dir|tarball>` or a bare absolute path).
144
+ * Registry ranges, dist-tags, and remote URLs resolve to null.
145
+ *
146
+ * The absolute-path branch is anchored so only drive-letter (C:\...), UNC
147
+ * (\\\\host\\share), and rooted POSIX (/) paths count as local; a plain
148
+ * `//` inside a URL (https://...) is deliberately not matched — remote git
149
+ * and tarball dependencies are installs, not local sources.
150
+ * @param spec - the raw dependency range from the profile manifest.
151
+ * @returns the local path string, or null for registry/remote installs.
152
+ */
153
+ function localSpecPath(spec) {
154
+ if (typeof spec !== 'string') return null
155
+ const linked = /^(?:link|file):(.+)$/.exec(spec)
156
+ if (linked !== null) return linked[1]
157
+ if (/(?:^[a-zA-Z]:[\\/])|(?:^[\\/]{2})|(?:^\/)/.test(spec)) return spec
158
+ return null
159
+ }
160
+
161
+ /**
162
+ * Resolve the npm registry the user actually installs from: the npm_config
163
+ * env override first, then a `registry=` line in the nearest .npmrc (user or
164
+ * profile), falling back to the official registry. Mirrors npm's own
165
+ * resolution well enough for update checks; a mismatch only means the check
166
+ * queries a different mirror, which is acceptable.
167
+ * @param profileDir - profile directory (checked for a local .npmrc).
168
+ * @returns the registry base URL (no trailing slash).
169
+ */
170
+ function resolveNpmRegistry(profileDir) {
171
+ if (typeof process.env.npm_config_registry === 'string' && process.env.npm_config_registry.trim() !== '') {
172
+ return process.env.npm_config_registry.trim().replace(/\/+$/, '')
173
+ }
174
+ const candidates = [
175
+ join(profileDir, '.npmrc'),
176
+ join(homedir(), '.npmrc'),
177
+ ]
178
+ for (const file of candidates) {
179
+ try {
180
+ const text = readFileSync(file, 'utf8')
181
+ const match = /^\s*registry\s*=\s*(\S+)\s*$/m.exec(text)
182
+ if (match) return match[1].replace(/\/+$/, '')
183
+ } catch {
184
+ // file absent — try the next candidate
185
+ }
186
+ }
187
+ return NPM_REGISTRY_DEFAULT
188
+ }
189
+
190
+ /**
191
+ * Query the npm registry for the `latest` dist-tag version of one package.
192
+ * Strict timeout; returns null on any failure so a dead registry never
193
+ * breaks the panel.
194
+ * @param registry - registry base URL.
195
+ * @param name - package name (scoped names are URL-encoded).
196
+ * @returns the latest version string, or null when unknown/unreachable.
197
+ */
198
+ async function fetchLatestVersion(registry, name) {
199
+ try {
200
+ const url = `${registry}/${name.split('/').map(encodeURIComponent).join('/')}/latest`
201
+ const controller = new AbortController()
202
+ const timer = setTimeout(() => controller.abort(), UPDATE_CHECK_TIMEOUT_MS)
203
+ let response
204
+ try {
205
+ response = await fetch(url, { signal: controller.signal, headers: { Accept: 'application/json' } })
206
+ } finally {
207
+ clearTimeout(timer)
208
+ }
209
+ if (!response.ok) return null
210
+ const data = await response.json()
211
+ if (data && typeof data.version === 'string') return data.version
212
+ return null
213
+ } catch {
214
+ return null
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Remote update check for one plugin entry: only registry-installed bundles
220
+ * (dependency-managed and not a local path) are queried. The result never
221
+ * throws — network failures surface as `error` on the entry.
222
+ * @param registry - registry base URL.
223
+ * @param plugin - plugin list entry ({ name, version, dependency, localPath }).
224
+ * @returns { name, version, latest, updateAvailable, error? }.
225
+ */
226
+ async function checkPluginUpdate(registry, plugin) {
227
+ if (!plugin.dependency || plugin.localPath !== null) {
228
+ return { name: plugin.name, version: plugin.version, latest: null, updateAvailable: false }
229
+ }
230
+ const latest = await fetchLatestVersion(registry, plugin.name)
231
+ if (latest === null) {
232
+ return { name: plugin.name, version: plugin.version, latest: null, updateAvailable: false, error: '无法查询远程版本(网络或 registry 不可达)' }
233
+ }
234
+ return {
235
+ name: plugin.name,
236
+ version: plugin.version,
237
+ latest,
238
+ updateAvailable: plugin.version !== latest,
239
+ }
240
+ }
241
+
242
+ /** Run bounded-concurrency async work over a list, preserving order. */
243
+ async function mapConcurrent(items, limit, worker) {
244
+ const results = new Array(items.length)
245
+ let cursor = 0
246
+ const runners = new Array(Math.min(limit, items.length)).fill(0).map(async () => {
247
+ while (true) {
248
+ const index = cursor++
249
+ if (index >= items.length) return
250
+ results[index] = await worker(items[index], index)
251
+ }
252
+ })
253
+ await Promise.all(runners)
254
+ return results
255
+ }
256
+
257
+ /**
258
+ * Write the bundle layer list back into the profile manifest.
259
+ * @param profileDir - the profile directory.
260
+ * @param bundles - the complete next bundle list.
261
+ */
262
+ function writeManifest(profileDir, pkg) {
263
+ const manifestPath = join(profileDir, 'package.json')
264
+ const tempPath = manifestPath + '.dsh-admin.tmp'
265
+ writeFileSync(tempPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8')
266
+ renameSync(tempPath, manifestPath)
267
+ }
268
+
269
+ /**
270
+ * Atomically replace the profile manifest: write the next content to a
271
+ * sibling temp file and rename over the original. A crash mid-write then
272
+ * leaves either the old or the new package.json — never a truncated JSON
273
+ * that would take the whole profile down at next dsh start.
274
+ * @param profileDir - the profile directory.
275
+ * @param pkg - the complete next manifest object.
276
+ */
277
+ function writeBundles(profileDir, bundles) {
278
+ const manifestPath = join(profileDir, 'package.json')
279
+ const pkg = JSON.parse(readFileSync(manifestPath, 'utf8'))
280
+ pkg.dsh = { ...pkg.dsh, profile: { ...pkg.dsh?.profile, bundles } }
281
+ writeManifest(profileDir, pkg)
282
+ }
283
+
284
+ /**
285
+ * Synchronize `dsh.profile.bundles` with the dependency state: bundle-
286
+ * declaring dependencies join (dependency order), dependency-managed
287
+ * entries that stopped being bundles leave, in-box entries stay.
288
+ * @param profileDir - the profile directory.
289
+ * @returns whether the manifest changed.
290
+ */
291
+ function reconcileBundles(profileDir) {
292
+ const require = requireOf(profileDir)
293
+ const manifestPath = join(profileDir, 'package.json')
294
+ const pkg = JSON.parse(readFileSync(manifestPath, 'utf8'))
295
+ const dependencies = Object.keys(pkg.dependencies ?? {})
296
+ const bundles = pkg.dsh?.profile?.bundles ?? []
297
+ let changed = false
298
+ for (const name of dependencies) {
299
+ if (declaresBundle(require, name) && !bundles.includes(name)) {
300
+ bundles.push(name)
301
+ changed = true
302
+ }
303
+ }
304
+ for (const name of [...bundles]) {
305
+ if (dependencies.includes(name) && !declaresBundle(require, name)) {
306
+ bundles.splice(bundles.indexOf(name), 1)
307
+ changed = true
308
+ }
309
+ }
310
+ if (changed) {
311
+ pkg.dsh = { ...pkg.dsh, profile: { ...pkg.dsh?.profile, bundles } }
312
+ writeManifest(profileDir, pkg)
313
+ }
314
+ return changed
315
+ }
316
+
317
+ /**
318
+ * Terminate a process and its whole descendant tree.
319
+ * @param pid - process id to kill.
320
+ */
321
+ function killProcessTree(pid) {
322
+ if (typeof pid !== 'number' || !Number.isFinite(pid) || pid <= 0) return
323
+ if (process.platform === 'win32') {
324
+ // taskkill /T walks the tree; /F forces. With shell:true the direct
325
+ // child is cmd.exe, and its children (pnpm + node) would otherwise
326
+ // survive a bare kill().
327
+ try {
328
+ spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], { windowsHide: true })
329
+ } catch {
330
+ // fall through to the direct kill below
331
+ }
332
+ }
333
+ try {
334
+ process.kill(pid)
335
+ } catch {
336
+ // already gone
337
+ }
338
+ }
339
+
340
+ /**
341
+ * Run one pnpm invocation in the profile directory (async; never blocks host loop).
342
+ * @param profileDir - working directory for pnpm.
343
+ * @param args - pnpm arguments.
344
+ * @returns the command's combined output tail on success.
345
+ * @throws {Error} carrying the output tail when pnpm exits non-zero.
346
+ */
347
+ function runPnpm(profileDir, args) {
348
+ return new Promise((resolve, reject) => {
349
+ const child = spawn('pnpm', args, {
350
+ cwd: profileDir,
351
+ shell: process.platform === 'win32',
352
+ env: process.env,
353
+ })
354
+ let output = ''
355
+ const record = (chunk) => {
356
+ output += chunk
357
+ if (output.length > 16_384) output = output.slice(-8_192)
358
+ }
359
+ child.stdout?.on('data', record)
360
+ child.stderr?.on('data', record)
361
+ const timer = setTimeout(() => {
362
+ killProcessTree(child.pid)
363
+ reject(new Error(`pnpm timed out after ${String(PNPM_TIMEOUT_MS / 1000)}s: ${output}`))
364
+ }, PNPM_TIMEOUT_MS)
365
+ child.on('error', (error) => {
366
+ clearTimeout(timer)
367
+ reject(error.code === 'ENOENT'
368
+ ? new Error('pnpm not found on PATH — install pnpm to manage profile plugins')
369
+ : error)
370
+ })
371
+ child.on('close', (code) => {
372
+ clearTimeout(timer)
373
+ if (code === 0) resolve(output.trim())
374
+ else reject(new Error(`pnpm ${args.join(' ')} exited with code ${String(code)}:\n${output.trim()}`))
375
+ })
376
+ })
377
+ }
378
+
379
+
380
+ /**
381
+ * Characters permitted in a pnpm install/remove operand. Single-token
382
+ * operands only: package names, scoped names (@scope/name), version
383
+ * suffixes (^1.2.3, ~1.2.3, name@*), git URLs (git+https://...#ref), and
384
+ * drive-letter/UNC/POSIX paths. Every cmd.exe separator, redirect, and
385
+ * expansion character is excluded by construction — including <, >, =, |,
386
+ * &, %, !, quotes, backticks, parens, braces, commas, and whitespace (the
387
+ * >/< semver range forms like >=1.0.0 are multi-token and would already be
388
+ * split by the shell, so dropping them loses nothing real). On Windows the
389
+ * spawn below uses shell:true (pnpm ships as a .cmd shim), so the operand
390
+ * is one token of the joined command line — metacharacters here would be
391
+ * the difference between pnpm and a second command.
392
+ */
393
+ const PNPM_OPERAND_ALLOWED = /^[A-Za-z0-9@\/_.:\\^~*=+#-]+$/
394
+
395
+ /**
396
+ * Validate one pnpm operand and return it trimmed. Refuses leading dashes
397
+ * (an operand must never masquerade as a pnpm flag) and any character
398
+ * outside the allowlist (a whole class of shell metacharacters is rejected
399
+ * at once instead of a hand-maintained blocklist of separators).
400
+ * @param field - human label for the error message (e.g. 'install spec').
401
+ * @param value - raw operand from the RPC boundary.
402
+ * @returns the trimmed, validated operand.
403
+ * @throws {Error} when the operand is a flag or carries shell metacharacters.
404
+ */
405
+ function assertPnpmOperand(field, value) {
406
+ const operand = value.trim()
407
+ if (/^-/.test(operand)) {
408
+ throw new Error('plugin-admin: ' + field + ' \'' + operand.slice(0, 48) + '\' looks like a CLI flag')
409
+ }
410
+ if (!PNPM_OPERAND_ALLOWED.test(operand)) {
411
+ throw new Error('plugin-admin: ' + field + ' carries shell metacharacters — only package specs, version ranges, and local paths are accepted')
412
+ }
413
+ return operand
414
+ }
415
+
416
+ /* Exported for host-check.mjs: the shell-allowlist validator and the local
417
+ * source-path classifier are pure functions, so the self-check drives them
418
+ * directly without touching pnpm or the real profile manifest. The update
419
+ * checker is exported too — its registry fetch is injected, so the host-check
420
+ * can drive it against a local HTTP stub without touching the real npm. */
421
+ export { localSpecPath, assertPnpmOperand, resolveNpmRegistry, fetchLatestVersion, checkPluginUpdate }
422
+
423
+ /* ========================================================================== */
424
+ /* MCP Connectivity Probe */
425
+ /* ========================================================================== */
426
+
427
+ /**
428
+ * Run a best-effort connectivity probe against one MCP server configuration.
429
+ *
430
+ * stdio: spawn the configured command (the same way the mcp-client plugin's
431
+ * StdioClientTransport does — default env + explicit env, cwd applied, no
432
+ * shell), then speak newline-delimited JSON-RPC: `initialize`, followed by
433
+ * `notifications/initialized`, then `tools/list` (so the tool count and
434
+ * serverInfo come from the real handshake). The process is killed with its
435
+ * whole descendant tree when the probe finishes or times out, and the stderr
436
+ * tail is captured for a diagnosable failure message.
437
+ *
438
+ * streamable-http: POST an `initialize` request with the Accept header the
439
+ * MCP SDK uses, wait for the JSON response (or the first SSE event carrying
440
+ * the matching id), then `ping` and `tools/list`. The probe reports the
441
+ * server's declared identity and capability summary.
442
+ *
443
+ * Never throws: it returns { ok, ... } so the UI can render per-entry results
444
+ * without try/catch around every call site.
445
+ *
446
+ * @param cfg - normalized MCP entry config ({ transport, serverName, ... }).
447
+ * @returns probe result: { ok: true, serverInfo, toolCount, transport, ms } or
448
+ * { ok: false, error, transport, ms, stderr? }.
449
+ */
450
+ /**
451
+ * Recursively strip `undefined` values (typert's JSON boundary rejects them —
452
+ * a field present with `undefined` fails "business result failed boundary
453
+ * validation"). Arrays keep their length; object keys with undefined values
454
+ * are removed.
455
+ * @param value - any JSON-ish value.
456
+ * @returns a copy with every undefined leaf removed.
457
+ */
458
+ function jsonSafe(value) {
459
+ if (value === undefined) return undefined
460
+ if (Array.isArray(value)) return value.map(jsonSafe)
461
+ if (value !== null && typeof value === 'object') {
462
+ const out = {}
463
+ for (const key of Object.keys(value)) {
464
+ const next = jsonSafe(value[key])
465
+ if (next !== undefined) out[key] = next
466
+ }
467
+ return out
468
+ }
469
+ return value
470
+ }
471
+
472
+ /**
473
+ * Run a best-effort connectivity probe against one MCP server configuration.
474
+ *
475
+ * stdio: spawn the configured command (the same way the mcp-client plugin's
476
+ * StdioClientTransport does — default env + explicit env, cwd applied, no
477
+ * shell), then speak newline-delimited JSON-RPC: `initialize`, followed by
478
+ * `notifications/initialized`, then `tools/list` (so the tool count and
479
+ * serverInfo come from the real handshake). The process is killed with its
480
+ * whole descendant tree when the probe finishes or times out, and the stderr
481
+ * tail is captured for a diagnosable failure message.
482
+ *
483
+ * streamable-http: POST an `initialize` request with the Accept header the
484
+ * MCP SDK uses, wait for the JSON response (or the first SSE event carrying
485
+ * the matching id), then `ping` and `tools/list`. The probe reports the
486
+ * server's declared identity and capability summary.
487
+ *
488
+ * Never throws: it returns { ok, ... } so the UI can render per-entry results
489
+ * without try/catch around every call site.
490
+ *
491
+ * @param cfg - normalized MCP entry config ({ transport, serverName, ... }).
492
+ * @returns probe result: { ok: true, serverInfo, toolCount, transport, ms } or
493
+ * { ok: false, error, transport, ms, stderr? }.
494
+ */
495
+ async function probeMcpServer(cfg) {
496
+ const startedAt = Date.now()
497
+ const ms = () => Date.now() - startedAt
498
+ let outcome
499
+ try {
500
+ if (cfg.transport === 'stdio') {
501
+ const probe = await probeMcpStdio(cfg)
502
+ outcome = { ok: probe.ok, transport: 'stdio', ms: ms(), ...probe }
503
+ } else if (cfg.transport === 'streamable-http') {
504
+ const probe = await probeMcpHttp(cfg)
505
+ outcome = { ok: probe.ok, transport: 'streamable-http', ms: ms(), ...probe }
506
+ } else {
507
+ outcome = { ok: false, transport: String(cfg.transport), ms: ms(), error: 'unknown transport' }
508
+ }
509
+ } catch (error) {
510
+ outcome = { ok: false, transport: String(cfg.transport), ms: ms(), error: error instanceof Error ? error.message : String(error) }
511
+ }
512
+ // The typert gateway boundary rejects undefined-valued fields.
513
+ return jsonSafe(outcome)
514
+ }
515
+
516
+ /**
517
+ * Spawn an MCP stdio server command the way the real dsh-mcp-client plugin
518
+ * does: the MCP SDK's StdioClientTransport uses cross-spawn, which on Windows
519
+ * wraps non-`.exe` commands (`.cmd`/`.bat` shims like npx, npm, pnpm) in
520
+ * `cmd.exe /d /s /c` so they resolve through PATHEXT. Node's raw spawn with
521
+ * `shell:false` would fail those with ENOENT. This helper mirrors that
522
+ * behavior so the probe tests what dsh actually launches.
523
+ * @param command - executable name (possibly a .cmd shim).
524
+ * @param args - argument list.
525
+ * @param options - spawn options (cwd/env/stdio).
526
+ * @returns the spawned ChildProcess.
527
+ */
528
+ function spawnMcpCommand(command, args, options) {
529
+ if (process.platform === 'win32' && !/\.(exe|com|bat|cmd)$/i.test(command)) {
530
+ // Same shape cross-spawn produces: cmd.exe /d /s /c "<escaped command and args>".
531
+ const shellCommand = [command, ...args].map(escapeCmdArg).join(' ')
532
+ return spawn(process.env.comspec || 'cmd.exe', ['/d', '/s', '/c', '"' + shellCommand + '"'], {
533
+ ...options,
534
+ shell: false,
535
+ windowsVerbatimArguments: true,
536
+ windowsHide: true,
537
+ })
538
+ }
539
+ return spawn(command, args, { ...options, shell: false, windowsHide: process.platform === 'win32' })
540
+ }
541
+
542
+ /** Escape one token for a Windows cmd.exe /c command line (cross-spawn style). */
543
+ function escapeCmdArg(arg) {
544
+ const text = String(arg)
545
+ // Only wrap when the token carries whitespace or cmd metacharacters.
546
+ if (/^[A-Za-z0-9_\-./:\\@^~=+#]+$/.test(text)) return text
547
+ return '"' + text.replace(/"/g, '\\"') + '"'
548
+ }
549
+
550
+ /**
551
+ * Split an inline command line into argv tokens, honoring double quotes so
552
+ * paths with spaces ("C:\Program Files\...") stay one token. Used to turn a
553
+ * user's `command: "npx -y fetcher-mcp"` into [npx, -y, fetcher-mcp].
554
+ * @param line - the raw command string.
555
+ * @returns array of tokens (never empty for a non-blank line).
556
+ */
557
+ function splitCommandLine(line) {
558
+ const tokens = []
559
+ let current = ''
560
+ let inQuotes = false
561
+ for (let i = 0; i < line.length; i++) {
562
+ const ch = line[i]
563
+ if (ch === '"') {
564
+ inQuotes = !inQuotes
565
+ continue
566
+ }
567
+ if (ch === ' ' || ch === '\t') {
568
+ if (inQuotes) {
569
+ current += ch
570
+ } else if (current !== '') {
571
+ tokens.push(current)
572
+ current = ''
573
+ }
574
+ continue
575
+ }
576
+ current += ch
577
+ }
578
+ if (current !== '') tokens.push(current)
579
+ return tokens
580
+ }
581
+
582
+ /**
583
+ * MCP stdio probe: spawn + newline-delimited JSON-RPC handshake.
584
+ * @param cfg - stdio config.
585
+ * @returns { ok, serverInfo?, toolCount?, error?, stderr? }.
586
+ */
587
+ function probeMcpStdio(cfg) {
588
+ return new Promise((resolve) => {
589
+ let settled = false
590
+ let child
591
+ let stdout = ''
592
+ let stderr = ''
593
+ let buffer = ''
594
+ let nextId = 1
595
+ const pending = new Map()
596
+
597
+ const finish = (outcome) => {
598
+ if (settled) return
599
+ settled = true
600
+ if (child && child.pid) killProcessTree(child.pid)
601
+ resolve(outcome)
602
+ }
603
+ const fail = (error, extra = {}) => finish({ ok: false, error, stderr: stderr.trim().slice(-2_000) || undefined, ...extra })
604
+
605
+ // Kill the probe if the server never answers.
606
+ const timer = setTimeout(() => {
607
+ fail(`probe timed out after ${MCP_PROBE_TIMEOUT_MS}ms — no JSON-RPC response`, { stderr: stderr.trim().slice(-2_000) || undefined })
608
+ }, MCP_PROBE_TIMEOUT_MS)
609
+
610
+ const send = (method, params) => {
611
+ const id = nextId++
612
+ const message = JSON.stringify({ jsonrpc: '2.0', id, method, params })
613
+ pending.set(id, method)
614
+ if (child.stdin && !child.stdin.write(message + '\n')) {
615
+ child.stdin.once('drain', () => {})
616
+ }
617
+ return id
618
+ }
619
+ const onLine = (line) => {
620
+ let message
621
+ try {
622
+ message = JSON.parse(line)
623
+ } catch {
624
+ return // ignore non-JSON lines (some servers log to stdout)
625
+ }
626
+ if (message && message.id !== undefined && pending.has(message.id)) {
627
+ if (message.error) {
628
+ fail(`server rejected ${pending.get(message.id)}: ${message.error.message || JSON.stringify(message.error)}`)
629
+ return
630
+ }
631
+ const method = pending.get(message.id)
632
+ pending.delete(message.id)
633
+ if (method === 'initialize') {
634
+ const info = message.result?.serverInfo
635
+ if (info) {
636
+ child.stdoutInfo = info
637
+ child.toolCount = Array.isArray(message.result.tools) ? message.result.tools.length : undefined
638
+ }
639
+ // After initialize the client must send notifications/initialized.
640
+ child.stdin.write(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n')
641
+ // Then ask for the tool list (the real dsh-mcp-client does this too).
642
+ send('tools/list')
643
+ return
644
+ }
645
+ if (method === 'tools/list') {
646
+ const tools = Array.isArray(message.result?.tools) ? message.result.tools : []
647
+ const outcome = {
648
+ ok: true,
649
+ serverInfo: child.stdoutInfo || undefined,
650
+ toolCount: tools.length,
651
+ tools: tools
652
+ .map(tool => (tool && typeof tool === 'object' && typeof tool.name === 'string') ? tool.name : null)
653
+ .filter(name => name !== null),
654
+ }
655
+ if (child.probeInlineWarning) outcome.warning = child.probeInlineWarning
656
+ finish(outcome)
657
+ }
658
+ }
659
+ }
660
+
661
+ try {
662
+ // The dsh-mcp-client plugin treats `command` as the executable name and
663
+ // `args` as the argument list. Users often write the whole invocation
664
+ // inline ("npx -y fetcher-mcp"); split it so the probe tests the same
665
+ // thing they meant. When args ARE configured they win (that's the
666
+ // plugin-faithful shape). The probe still flags the divergence so the
667
+ // UI can warn that dsh itself would fail to launch this config.
668
+ const inlineCommand = Array.isArray(cfg.args) && cfg.args.length > 0
669
+ ? [cfg.command, ...cfg.args]
670
+ : splitCommandLine(cfg.command)
671
+ const command = inlineCommand[0]
672
+ const args = inlineCommand.slice(1)
673
+ const wasInline = Array.isArray(cfg.args) && cfg.args.length > 0 ? false : inlineCommand.length > 1
674
+ child = spawnMcpCommand(command, args, {
675
+ cwd: cfg.cwd || undefined,
676
+ env: { ...process.env, ...(cfg.env || {}) },
677
+ stdio: ['pipe', 'pipe', 'pipe'],
678
+ })
679
+ if (wasInline) child.probeInlineWarning = `command 含整行调用(${cfg.command})。探测已自动拆分执行成功,但 dsh 实际要求 command 仅为可执行名、参数放 args(如 command: npx + args: [-y, fetcher-mcp]),否则 dsh 启动该 MCP 服务器会失败——请在编辑表单中把命令拆分到 args 后保存。`
680
+ } catch (error) {
681
+ clearTimeout(timer)
682
+ finish({ ok: false, error: `failed to spawn '${cfg.command}': ${error.message}` })
683
+ return
684
+ }
685
+
686
+ child.stdout?.on('data', (chunk) => {
687
+ stdout += chunk
688
+ if (stdout.length > MCP_PROBE_MAX_RESPONSE_BYTES) {
689
+ fail('server response exceeded ' + MCP_PROBE_MAX_RESPONSE_BYTES + ' bytes')
690
+ return
691
+ }
692
+ buffer += chunk
693
+ let index
694
+ while ((index = buffer.indexOf('\n')) !== -1) {
695
+ const line = buffer.slice(0, index)
696
+ buffer = buffer.slice(index + 1)
697
+ if (line.trim() !== '') onLine(line)
698
+ }
699
+ })
700
+ child.stderr?.on('data', (chunk) => {
701
+ stderr += chunk
702
+ if (stderr.length > MCP_PROBE_MAX_RESPONSE_BYTES) stderr = stderr.slice(-MCP_PROBE_MAX_RESPONSE_BYTES)
703
+ })
704
+ child.on('error', (error) => {
705
+ clearTimeout(timer)
706
+ fail(error.code === 'ENOENT'
707
+ ? `command not found: ${cfg.command.trim().split(/\s+/)[0]}`
708
+ : `failed to start '${cfg.command}': ${error.message}`)
709
+ })
710
+ child.on('close', (code) => {
711
+ if (!settled) {
712
+ clearTimeout(timer)
713
+ const tail = stderr.trim().slice(-2_000)
714
+ fail(`server process exited with code ${String(code)}${tail ? ': ' + tail : ''}`)
715
+ }
716
+ })
717
+
718
+ // Kick off the handshake once the process is up. If spawn already failed
719
+ // the 'error' event settles first.
720
+ child.on('spawn', () => {
721
+ send('initialize', {
722
+ protocolVersion: '2025-11-25',
723
+ capabilities: {},
724
+ clientInfo: { name: 'dsh-plugin-admin', version: '0.0.1' },
725
+ })
726
+ })
727
+ })
728
+ }
729
+
730
+ /**
731
+ * MCP streamable-http probe: POST initialize over HTTP and wait for a
732
+ * response. Handles both plain-JSON and SSE (text/event-stream) responses.
733
+ * @param cfg - streamable-http config.
734
+ * @returns { ok, serverInfo?, toolCount?, error? }.
735
+ */
736
+ function probeMcpHttp(cfg) {
737
+ return new Promise((resolve) => {
738
+ let settled = false
739
+ const finish = (outcome) => {
740
+ if (settled) return
741
+ settled = true
742
+ resolve(outcome)
743
+ }
744
+ const fail = (error) => finish({ ok: false, error })
745
+ const timer = setTimeout(() => {
746
+ fail(`probe timed out after ${MCP_PROBE_HTTP_TIMEOUT_MS}ms — no HTTP response from ${cfg.url}`)
747
+ }, MCP_PROBE_HTTP_TIMEOUT_MS)
748
+
749
+ const doProbe = async () => {
750
+ try {
751
+ const init = {
752
+ method: 'POST',
753
+ headers: {
754
+ 'Content-Type': 'application/json',
755
+ Accept: 'application/json, text/event-stream',
756
+ ...(cfg.headers || {}),
757
+ },
758
+ body: JSON.stringify({
759
+ jsonrpc: '2.0',
760
+ id: 1,
761
+ method: 'initialize',
762
+ params: {
763
+ protocolVersion: '2025-11-25',
764
+ capabilities: {},
765
+ clientInfo: { name: 'dsh-plugin-admin', version: '0.0.1' },
766
+ },
767
+ }),
768
+ }
769
+ const controller = new AbortController()
770
+ const abortTimer = setTimeout(() => controller.abort(), MCP_PROBE_HTTP_TIMEOUT_MS)
771
+ let response
772
+ try {
773
+ response = await fetch(cfg.url, { ...init, signal: controller.signal })
774
+ } finally {
775
+ clearTimeout(abortTimer)
776
+ }
777
+ if (!response.ok) {
778
+ fail(`HTTP ${response.status} ${response.statusText} from ${cfg.url}`)
779
+ return
780
+ }
781
+ const contentType = (response.headers.get('content-type') || '').toLowerCase()
782
+ let serverInfo
783
+ let toolCount
784
+ let matched = false
785
+ if (contentType.includes('text/event-stream')) {
786
+ const reader = response.body.getReader()
787
+ const decoder = new TextDecoder()
788
+ let acc = ''
789
+ let dataLine = ''
790
+ while (true) {
791
+ const { done, value } = await reader.read()
792
+ if (done) break
793
+ acc += decoder.decode(value, { stream: true })
794
+ if (acc.length > MCP_PROBE_MAX_RESPONSE_BYTES) {
795
+ fail('server response exceeded ' + MCP_PROBE_MAX_RESPONSE_BYTES + ' bytes')
796
+ return
797
+ }
798
+ // SSE frames: "data: {...}\n\n"
799
+ const frames = acc.split('\n\n')
800
+ acc = frames.pop()
801
+ for (const frame of frames) {
802
+ for (const line of frame.split('\n')) {
803
+ if (line.startsWith('data:')) dataLine = line.slice(5).trim()
804
+ }
805
+ if (dataLine === '') continue
806
+ try {
807
+ const message = JSON.parse(dataLine)
808
+ dataLine = ''
809
+ if (message.id === 1) {
810
+ matched = true
811
+ serverInfo = message.result?.serverInfo
812
+ if (Array.isArray(message.result?.tools)) toolCount = message.result.tools.length
813
+ }
814
+ } catch {
815
+ // ignore malformed SSE data frames
816
+ }
817
+ }
818
+ }
819
+ if (!matched) {
820
+ fail('no initialize response in the SSE stream from ' + cfg.url)
821
+ return
822
+ }
823
+ } else {
824
+ const text = await response.text()
825
+ if (text.length > MCP_PROBE_MAX_RESPONSE_BYTES) {
826
+ fail('server response exceeded ' + MCP_PROBE_MAX_RESPONSE_BYTES + ' bytes')
827
+ return
828
+ }
829
+ let message
830
+ try {
831
+ message = JSON.parse(text)
832
+ } catch {
833
+ fail('server returned non-JSON response from ' + cfg.url)
834
+ return
835
+ }
836
+ if (message.id !== 1) {
837
+ fail('server returned a response without the initialize id from ' + cfg.url)
838
+ return
839
+ }
840
+ if (message.error) {
841
+ fail('server rejected initialize: ' + (message.error.message || JSON.stringify(message.error)))
842
+ return
843
+ }
844
+ serverInfo = message.result?.serverInfo
845
+ if (Array.isArray(message.result?.tools)) toolCount = message.result.tools.length
846
+ }
847
+ // Optional follow-up ping to prove the session stays usable, then
848
+ // ask for the tool list so the UI can show what the server offers.
849
+ let pingOk = true
850
+ let tools = []
851
+ try {
852
+ const ping = await fetch(cfg.url, {
853
+ ...init,
854
+ signal: AbortSignal.timeout(MCP_PROBE_HTTP_TIMEOUT_MS),
855
+ body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'ping', params: {} }),
856
+ })
857
+ if (!ping.ok) pingOk = false
858
+ if (pingOk) {
859
+ tools = await httpToolNames(cfg.url, init)
860
+ }
861
+ } catch {
862
+ pingOk = false
863
+ }
864
+ finish({
865
+ ok: true,
866
+ serverInfo,
867
+ toolCount: tools.length,
868
+ tools,
869
+ pingOk,
870
+ })
871
+ } catch (error) {
872
+ fail(error instanceof Error && error.name === 'AbortError'
873
+ ? `connection to ${cfg.url} timed out`
874
+ : `HTTP request to ${cfg.url} failed: ${error instanceof Error ? error.message : String(error)}`)
875
+ } finally {
876
+ clearTimeout(timer)
877
+ }
878
+ }
879
+ void doProbe()
880
+ })
881
+ }
882
+
883
+ /**
884
+ * Ask a streamable-http MCP server for its tool names (tools/list), handling
885
+ * both plain-JSON and SSE responses. Best-effort: any failure returns [].
886
+ * @param url - MCP endpoint URL.
887
+ * @param init - base request init (headers).
888
+ * @returns the list of tool names.
889
+ */
890
+ async function httpToolNames(url, init) {
891
+ try {
892
+ const response = await fetch(url, {
893
+ ...init,
894
+ signal: AbortSignal.timeout(MCP_PROBE_HTTP_TIMEOUT_MS),
895
+ body: JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'tools/list', params: {} }),
896
+ })
897
+ if (!response.ok) return []
898
+ const contentType = (response.headers.get('content-type') || '').toLowerCase()
899
+ let tools = []
900
+ if (contentType.includes('text/event-stream')) {
901
+ const reader = response.body.getReader()
902
+ const decoder = new TextDecoder()
903
+ let acc = ''
904
+ let dataLine = ''
905
+ while (true) {
906
+ const { done, value } = await reader.read()
907
+ if (done) break
908
+ acc += decoder.decode(value, { stream: true })
909
+ const frames = acc.split('\n\n')
910
+ acc = frames.pop()
911
+ for (const frame of frames) {
912
+ for (const line of frame.split('\n')) {
913
+ if (line.startsWith('data:')) dataLine = line.slice(5).trim()
914
+ }
915
+ if (dataLine === '') continue
916
+ try {
917
+ const message = JSON.parse(dataLine)
918
+ dataLine = ''
919
+ if (message.id === 3 && Array.isArray(message.result?.tools)) {
920
+ tools = message.result.tools
921
+ }
922
+ } catch {
923
+ // ignore malformed SSE data frames
924
+ }
925
+ }
926
+ }
927
+ } else {
928
+ const text = await response.text()
929
+ const message = JSON.parse(text)
930
+ if (Array.isArray(message.result?.tools)) tools = message.result.tools
931
+ }
932
+ return tools
933
+ .map(tool => (tool && typeof tool === 'object' && typeof tool.name === 'string') ? tool.name : null)
934
+ .filter(name => name !== null)
935
+ } catch {
936
+ return []
937
+ }
938
+ }
939
+
940
+ /* ========================================================================== */
941
+ /* Session Admin Logic */
942
+ /* ========================================================================== */
943
+
944
+ /**
945
+ * Check the workspace registry's soft-private write path (requireState /
946
+ * setState / enqueueOperation) and the archived-set state shape. These are
947
+ * dsh internals rather than a public API — the check exists so a dsh version
948
+ * change fails loudly at startup instead of silently breaking archive state
949
+ * later. Called from apply() and re-checked on every write.
950
+ * @param registry - live workspace registry service.
951
+ * @returns the current domain state carrying `archivedSessionIds`.
952
+ * @throws {Error} naming the exact missing members when incompatible.
953
+ */
954
+ function registryStateFor(registry) {
955
+ const missing = []
956
+ if (typeof registry.requireState !== 'function') missing.push('requireState')
957
+ if (typeof registry.setState !== 'function') missing.push('setState')
958
+ if (typeof registry.enqueueOperation !== 'function') missing.push('enqueueOperation')
959
+ if (missing.length > 0) {
960
+ throw new Error(`session-admin: workspace registry missing archived-set write path members [${missing.join(', ')}] — dsh version changed?`)
961
+ }
962
+ const state = registry.requireState()
963
+ if (state === null || typeof state !== 'object' || !Array.isArray(state.archivedSessionIds)) {
964
+ throw new Error('session-admin: workspace registry state shape incompatible (archivedSessionIds array expected)')
965
+ }
966
+ return state
967
+ }
968
+
969
+ /**
970
+ * Remove one session id from the registry's archived set through the
971
+ * registry's own serialized write chain.
972
+ * @param ctx - plugin context carrying workspaceRegistry.
973
+ * @param sessionId - session to unarchive.
974
+ */
975
+ async function removeFromArchivedSet(ctx, sessionId) {
976
+ const registry = ctx.workspaceRegistry
977
+ const state = registryStateFor(registry)
978
+ if (!state.archivedSessionIds.includes(sessionId)) return
979
+ await registry.enqueueOperation(async () => {
980
+ const current = registryStateFor(registry)
981
+ await registry.setState({
982
+ ...current,
983
+ archivedSessionIds: current.archivedSessionIds.filter(id => id !== sessionId),
984
+ })
985
+ })
986
+ }
987
+
988
+ /**
989
+ * @param ctx - plugin context.
990
+ * @param sessionId - candidate id.
991
+ * @returns whether the session is live (an attached agent session).
992
+ */
993
+ function sessionIsLive(ctx, sessionId) {
994
+ const sessions = ctx.get('sessions')
995
+ return sessions !== undefined && typeof sessions.get === 'function'
996
+ && sessions.get(sessionId) !== undefined
997
+ }
998
+
999
+ /**
1000
+ * Capture the AgentHandle dsh's agent factory returns when it creates or
1001
+ * resumes a live agent. The handle's `dispose()` is dsh's ONLY complete
1002
+ * teardown path for a live agent+session: it stops the loop, waits for
1003
+ * quiescence, unregisters the agent, removes the session from the in-memory
1004
+ * SessionStore (emitting `session/disposed`), which lets the persistence
1005
+ * backend flush buffered events and release its write path — so the session's
1006
+ * log can then be removed WITHOUT being resurrected by a later flush.
1007
+ *
1008
+ * dsh deliberately hands the handle only to the creator ("CAPABILITY: among
1009
+ * consumers, only the holder can tear this agent down"), so the Web host
1010
+ * (dsh-host-apiproxy) discards it after resume. This plugin wraps the PUBLIC
1011
+ * `ctx.agents` service methods transparently — calling through to the originals
1012
+ * and returning their exact results — and keeps a private id -> handle map so
1013
+ * the admin panel can later dispose an online session by id.
1014
+ *
1015
+ * The wrapper is best-effort by design: if the agents service is absent, the
1016
+ * factory shape changes, or a session was created before this plugin mounted,
1017
+ * online-session close simply degrades to the existing "restart to delete"
1018
+ * behavior — never a crash.
1019
+ *
1020
+ * @param ctx - plugin context.
1021
+ * @returns an object with `get(sessionId)` (the captured handle, or undefined)
1022
+ * and `wrapped` (whether the agents service is present to wrap).
1023
+ */
1024
+ function installAgentHandleCapture(ctx) {
1025
+ const handles = new Map()
1026
+ const agents = ctx.get('agents')
1027
+ if (agents === undefined || typeof agents !== 'object' || agents === null) {
1028
+ return { get: () => undefined, wrapped: false }
1029
+ }
1030
+
1031
+ const wrap = (service, methodName) => {
1032
+ const original = service[methodName]
1033
+ if (typeof original !== 'function') return
1034
+ service[methodName] = function (...args) {
1035
+ const result = original.apply(this, args)
1036
+ if (result !== null && typeof result === 'object' && typeof result.then === 'function') {
1037
+ return result.then((handle) => {
1038
+ if (handle !== null && typeof handle === 'object'
1039
+ && typeof handle.dispose === 'function'
1040
+ && typeof handle.agent?.id === 'string') {
1041
+ handles.set(handle.agent.id, handle)
1042
+ }
1043
+ return handle
1044
+ })
1045
+ }
1046
+ if (result !== null && typeof result === 'object'
1047
+ && typeof result.dispose === 'function'
1048
+ && typeof result.agent?.id === 'string') {
1049
+ handles.set(result.agent.id, result)
1050
+ }
1051
+ return result
1052
+ }
1053
+ }
1054
+
1055
+ // create() / resume() are the two public factories that produce AgentHandle
1056
+ // values. The loop's config-driven agents call resume() through the same
1057
+ // service, so those are captured too.
1058
+ wrap(agents, 'create')
1059
+ wrap(agents, 'resume')
1060
+
1061
+ return {
1062
+ wrapped: true,
1063
+ get: (sessionId) => handles.get(sessionId),
1064
+ delete: (sessionId) => handles.delete(sessionId),
1065
+ size: () => handles.size,
1066
+ }
1067
+ }
1068
+
1069
+ /* ========================================================================== */
1070
+ /* Plugin Main Apply */
1071
+ /* ========================================================================== */
1072
+
1073
+ /**
1074
+ * Mount the unified plugin & session admin remote services and their typert descriptors.
1075
+ * @param ctx - plugin context carrying typert, workspaceRegistry, sessionPersistence.
1076
+ */
1077
+ export function apply(ctx) {
1078
+ const profileDir = profileDirOf(ctx.baseUrl)
1079
+ // Probe the registry write path at mount time so a dsh version change
1080
+ // fails the plugin mount loudly instead of breaking archive state on the
1081
+ // first session-admin call.
1082
+ registryStateFor(ctx.workspaceRegistry)
1083
+ // Capture the AgentHandles dsh produces for live agents (see
1084
+ // installAgentHandleCapture) so online sessions can be torn down through
1085
+ // dsh's official dispose chain before their logs are removed.
1086
+ const handleCapture = installAgentHandleCapture(ctx)
1087
+ let operationTail = Promise.resolve()
1088
+
1089
+ function enqueue(operation) {
1090
+ const run = operationTail.then(operation, operation)
1091
+ operationTail = run.catch(() => {})
1092
+ return run
1093
+ }
1094
+
1095
+ function listLayers() {
1096
+ const require = requireOf(profileDir)
1097
+ const pkg = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8'))
1098
+ const dependencies = new Map(Object.entries(pkg.dependencies ?? {}))
1099
+ const bundles = pkg.dsh?.profile?.bundles ?? []
1100
+ const plugins = bundles.map((name) => {
1101
+ const manifest = readManifest(require, name)
1102
+ return {
1103
+ name,
1104
+ version: manifest?.version ?? null,
1105
+ dependency: dependencies.has(name),
1106
+ removable: dependencies.has(name),
1107
+ localPath: localSpecPath(dependencies.get(name)),
1108
+ }
1109
+ })
1110
+ return { profileDir, plugins }
1111
+ }
1112
+
1113
+ /* ---------------------- Plugin Admin Remote Service ---------------------- */
1114
+ // Remote-update cache: name -> { at, latest } so repeated panel refreshes
1115
+ // within the TTL do not re-hit the registry. Only 'latest' is stored; the
1116
+ // 'updateAvailable' flag is recomputed on every read against the CURRENT
1117
+ // installed version (see checkUpdates) — a still-outdated plugin keeps
1118
+ // flagging on each re-open, while one upgraded in between stops flagging.
1119
+ const updateCache = new Map()
1120
+
1121
+ const pluginService = {
1122
+ async list() {
1123
+ return listLayers()
1124
+ },
1125
+
1126
+ /**
1127
+ * Check every registry-installed bundle for a newer version on the npm
1128
+ * registry (same registry npm/pnpm use). In-box bundles and local-path
1129
+ * installs are skipped. Never throws: each entry reports its own
1130
+ * `updateAvailable`/`latest`/`error`. Results are cached briefly; a cache
1131
+ * hit still recomputes `updateAvailable` against the current installed
1132
+ * version so the reminder survives repeated panel opens and clears only
1133
+ * once the plugin is actually upgraded.
1134
+ * @returns { updates, checkedAt } where updates is per-plugin status.
1135
+ */
1136
+ async checkUpdates() {
1137
+ const now = Date.now()
1138
+ const { plugins } = listLayers()
1139
+ const registry = resolveNpmRegistry(profileDir)
1140
+ const cached = []
1141
+ const todo = []
1142
+ for (const plugin of plugins) {
1143
+ if (!plugin.dependency || plugin.localPath !== null) continue
1144
+ const hit = updateCache.get(plugin.name)
1145
+ if (hit !== undefined && hit.latest !== null && now - hit.at < UPDATE_CHECK_CACHE_TTL_MS) {
1146
+ // Serve the cached 'latest' but recompute the flag against the
1147
+ // CURRENT installed version. The cached entry must carry
1148
+ // updateAvailable a bare { name, latest } is read by the client
1149
+ // as "up to date", which is why the ⬆ 有新版本 reminder used to
1150
+ // vanish on the second open of the panel within the TTL.
1151
+ cached.push({
1152
+ name: plugin.name,
1153
+ version: plugin.version,
1154
+ latest: hit.latest,
1155
+ updateAvailable: plugin.version !== hit.latest,
1156
+ })
1157
+ } else {
1158
+ todo.push(plugin)
1159
+ }
1160
+ }
1161
+ const fresh = await mapConcurrent(todo, UPDATE_CHECK_CONCURRENCY, async (plugin) => {
1162
+ const result = await checkPluginUpdate(registry, plugin)
1163
+ if (result.latest !== null) {
1164
+ updateCache.set(plugin.name, { at: now, latest: result.latest })
1165
+ }
1166
+ return result
1167
+ })
1168
+ const updates = [...cached, ...fresh]
1169
+ return { updates, checkedAt: now }
1170
+ },
1171
+
1172
+ async install(spec) {
1173
+ if (typeof spec !== 'string' || spec.trim() === '') {
1174
+ throw new Error('plugin-admin: install requires a spec string')
1175
+ }
1176
+ const operand = assertPnpmOperand('install spec', spec)
1177
+ return enqueue(async () => {
1178
+ const output = await runPnpm(profileDir, ['add', operand])
1179
+ reconcileBundles(profileDir)
1180
+ return { output, ...listLayers() }
1181
+ })
1182
+ },
1183
+
1184
+ async remove(name) {
1185
+ if (typeof name !== 'string' || name.trim() === '') {
1186
+ throw new Error('plugin-admin: remove requires a package name')
1187
+ }
1188
+ const operand = assertPnpmOperand('package name', name)
1189
+ const dependencies = new Set(Object.keys(
1190
+ JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')).dependencies ?? {},
1191
+ ))
1192
+ if (!dependencies.has(operand)) {
1193
+ throw new Error(`plugin-admin: '${operand}' is not a dependency-managed plugin (in-box bundles are not removable here)`)
1194
+ }
1195
+ return enqueue(async () => {
1196
+ const output = await runPnpm(profileDir, ['remove', operand])
1197
+ const pkg = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8'))
1198
+ const bundles = pkg.dsh?.profile?.bundles ?? []
1199
+ // Filter by the validated operand (trimmed) — the raw RPC `name` with
1200
+ // surrounding whitespace would miss the entry and leave behind a
1201
+ // phantom bundle that reconcileBundles cannot heal (it only prunes
1202
+ // entries that are still dependencies).
1203
+ const at = bundles.indexOf(operand)
1204
+ if (at !== -1) writeBundles(profileDir, bundles.filter(entry => entry !== operand))
1205
+ reconcileBundles(profileDir)
1206
+ return { output, ...listLayers() }
1207
+ })
1208
+ },
1209
+ }
1210
+
1211
+ const pluginBinding = Object.freeze({ service: pluginService, serviceKey: PLUGIN_SERVICE_KEY, namespace: PLUGIN_NAMESPACE })
1212
+ Object.defineProperty(pluginService, 'typertRemote', { value: pluginBinding, enumerable: false })
1213
+ ctx.provide(PLUGIN_SERVICE_KEY, pluginService)
1214
+
1215
+ function sessionBaseName(path) {
1216
+ if (!path) return ''
1217
+ const parts = path.replace(/\\/g, '/').split('/')
1218
+ const last = parts[parts.length - 1]
1219
+ return last === '' ? (parts[parts.length - 2] || path) : last
1220
+ }
1221
+
1222
+ /**
1223
+ * Extract { title, summary, messageCount } from a session's events. The
1224
+ * scan is capped at SESSION_EVENT_SCAN_CAP events so a pathological log
1225
+ * cannot monopolize the host loop; the message count may undercount past
1226
+ * the cap, which is an acceptable trade for the panel.
1227
+ * @param events - session events (live or persisted).
1228
+ * @returns derived title, summary, and message count.
1229
+ */
1230
+ function deriveSessionSummary(events) {
1231
+ let title = ''
1232
+ let summary = ''
1233
+ let messageCount = 0
1234
+ if (!events || events.length === 0) return { title, summary, messageCount }
1235
+ const cap = Math.min(events.length, SESSION_EVENT_SCAN_CAP)
1236
+ for (let i = 0; i < cap; i++) {
1237
+ const ev = events[i]
1238
+ if (ev.type === 'session/title' && ev.data && typeof ev.data.title === 'string' && ev.data.title.trim()) {
1239
+ title = ev.data.title.trim()
1240
+ }
1241
+ if (ev.type === 'user/message') {
1242
+ messageCount++
1243
+ if (!summary) {
1244
+ const content = ev.data?.content
1245
+ if (Array.isArray(content)) {
1246
+ summary = content
1247
+ .filter(b => b && b.type === 'text' && typeof b.text === 'string')
1248
+ .map(b => b.text.trim())
1249
+ .filter(Boolean)
1250
+ .join(' ')
1251
+ } else if (typeof ev.data?.text === 'string') {
1252
+ summary = ev.data.text.trim()
1253
+ }
1254
+ }
1255
+ }
1256
+ }
1257
+ // Derive a fallback title from the first line of the summary when no
1258
+ // explicit session/title event exists.
1259
+ if (!title && summary) {
1260
+ const firstLine = summary.split('\n')[0].trim()
1261
+ title = firstLine.length > 45 ? firstLine.slice(0, 45) + '...' : firstLine
1262
+ }
1263
+ return { title, summary, messageCount }
1264
+ }
1265
+
1266
+ /**
1267
+ * Run an async mapper over an array with at most `limit` concurrent
1268
+ * executions. Replaces the unbounded Promise.all fan-out in list().
1269
+ * @param items - values to map.
1270
+ * @param limit - max concurrency.
1271
+ * @param mapper - async (item, index) => result.
1272
+ * @returns results in input order.
1273
+ */
1274
+ async function mapLimit(items, limit, mapper) {
1275
+ const results = new Array(items.length)
1276
+ let cursor = 0
1277
+ async function worker() {
1278
+ while (cursor < items.length) {
1279
+ const index = cursor++
1280
+ results[index] = await mapper(items[index], index)
1281
+ }
1282
+ }
1283
+ const workers = []
1284
+ const count = Math.max(1, Math.min(limit, items.length))
1285
+ for (let i = 0; i < count; i++) workers.push(worker())
1286
+ await Promise.all(workers)
1287
+ return results
1288
+ }
1289
+
1290
+ /* --------------------- Session Admin Remote Service --------------------- */
1291
+ /**
1292
+ * Remove a session's durable artifacts: log directory, workspace
1293
+ * accounting, projection-cache record, archived-set entry, and the
1294
+ * derived-summary cache. Shared by deleteSession (non-live sessions) and
1295
+ * closeSession (after an online session has been torn down).
1296
+ *
1297
+ * The live-guard is a concurrency safety net for the deleteSession path: a
1298
+ * session must not lose its log while it is (or just became) live, because
1299
+ * the in-memory session would resurrect the file on the next flush.
1300
+ * closeSession passes skipLiveGuard=true it has already torn the live
1301
+ * session down through the official dispose chain, so the guard would only
1302
+ * see the session's (now stale) live marker and wrongly refuse.
1303
+ * @param sessionId - the session to remove.
1304
+ * @param skipLiveGuard - whether to skip the "session became live" check.
1305
+ */
1306
+ async function removeSessionArtifacts(sessionId, skipLiveGuard = false) {
1307
+ // Resolve the accounting workspace BEFORE removing the log: the entity
1308
+ // getter projects against the registry's live header index, so after the
1309
+ // rm (or any concurrent re-index) the id may stop resolving. detachSession
1310
+ // prunes every record member missing from that index, so it must never be
1311
+ // fired at unrelated workspaces one stale index entry would strip their
1312
+ // whole durable session list (sessions then fall into ungrouped).
1313
+ const accounting = ctx.workspaceRegistry.list()
1314
+ .find(workspace => workspace.sessionIds.includes(sessionId))
1315
+ // 1. Remove durable log artifacts
1316
+ const headers = await ctx.sessionPersistence.list()
1317
+ const header = headers.find(candidate => candidate.id === sessionId)
1318
+ if (header !== undefined) {
1319
+ const location = ctx.sessionPersistence.locate(header)
1320
+ if (location !== undefined) {
1321
+ if (location.kind !== 'jsonl') {
1322
+ throw new Error(`session-admin: persistence backend '${location.kind}' artifacts are not handled by this plugin`)
1323
+ }
1324
+ const targetDir = dirname(location.path)
1325
+ // The rm below owns the whole directory. If the persistence layout
1326
+ // ever put two sessions in one directory, a recursive delete here
1327
+ // would take the neighbor's log with it — fail closed instead of
1328
+ // wiping co-tenant data.
1329
+ for (const candidate of headers) {
1330
+ if (candidate.id === sessionId) continue
1331
+ let other
1332
+ try {
1333
+ other = ctx.sessionPersistence.locate(candidate)
1334
+ } catch {
1335
+ other = undefined
1336
+ }
1337
+ if (other?.kind !== 'jsonl') continue
1338
+ if (dirname(other.path) === targetDir
1339
+ || dirname(other.path).replace(/\\/g, '/').toLowerCase()
1340
+ === targetDir.replace(/\\/g, '/').toLowerCase()) {
1341
+ throw new Error(`session-admin: refusing to delete '${sessionId}' — session '${candidate.id}' logs live in the same directory ('${targetDir}'); remove it manually`)
1342
+ }
1343
+ }
1344
+ // Guard against a concurrent resume racing the rm: deleting the log
1345
+ // of a session that just became live again would resurrect on flush.
1346
+ // Skipped on the closeSession path, which has already torn the live
1347
+ // session down through the official dispose chain.
1348
+ if (!skipLiveGuard && sessionIsLive(ctx, sessionId)) {
1349
+ throw new Error(`session '${sessionId}' became live — close it before deleting`)
1350
+ }
1351
+ await rm(targetDir, { recursive: true, force: true })
1352
+ }
1353
+ }
1354
+ // 2. Detach workspace accounting — targeted, never a batch sweep
1355
+ if (accounting !== undefined) {
1356
+ await accounting.detachSession(sessionId)
1357
+ }
1358
+ // 3. Drop the session's projection-cache record so client-side session
1359
+ // projections (sidebar tree) stop showing the deleted session right
1360
+ // away instead of lingering in "未分组" until the next reload. The
1361
+ // storage domain is already open by dsh-session-projection-cache.
1362
+ try {
1363
+ const projDomain = ctx.get ? ctx.get('storageDomain')?.get('session_projcache') : undefined
1364
+ if (projDomain !== undefined && typeof projDomain.table === 'function') {
1365
+ const sessionsTable = projDomain.table('sessions')
1366
+ if (sessionsTable !== undefined && typeof sessionsTable.delete === 'function') {
1367
+ await sessionsTable.delete(sessionId)
1368
+ }
1369
+ }
1370
+ } catch (error) {
1371
+ // Non-fatal: worst case the sidebar refreshes it away on reload.
1372
+ }
1373
+ // 4. Clear archived-set entry
1374
+ await removeFromArchivedSet(ctx, sessionId)
1375
+ // 5. Evict the derived-summary cache entry so the map never grows
1376
+ // with deleted sessions (and a reused id never serves stale data).
1377
+ sessionSummaryCache.delete(sessionId)
1378
+ }
1379
+
1380
+ const sessionService = {
1381
+ async list() {
1382
+ const headers = await ctx.sessionPersistence.list()
1383
+ const archivedIds = ctx.workspaceRegistry.archivedSessionIds
1384
+ const sessionsService = ctx.get('sessions')
1385
+ const workspaces = ctx.workspaceRegistry.list()
1386
+ // Mirror the sidebar's grouping: every session's accounting workspace
1387
+ // comes from the registry's filtered sessionIds projection, so the
1388
+ // admin view and the sidebar never disagree about membership. The host
1389
+ // Workspace entity exposes its id as `id` (WorkspaceView's
1390
+ // `workspaceId` is the wire-side rename done by apiproxy) — map it
1391
+ // explicitly to keep the boundary JSON-safe (undefined values trip
1392
+ // typert's assertJsonValue).
1393
+ const sessionToWorkspace = new Map()
1394
+ for (const ws of workspaces) {
1395
+ for (const sid of ws.sessionIds) {
1396
+ sessionToWorkspace.set(sid, { workspaceId: ws.id ?? null, title: ws.title ?? null })
1397
+ }
1398
+ }
1399
+
1400
+ // Cheap revision tokens (listSnapshots) let us skip re-reading whole
1401
+ // event logs for sessions whose summary is already cached and fresh.
1402
+ const snapshots = typeof ctx.sessionPersistence.listSnapshots === 'function'
1403
+ ? await ctx.sessionPersistence.listSnapshots()
1404
+ : []
1405
+ const revisionBySession = new Map()
1406
+ for (const snapshot of snapshots) {
1407
+ const id = snapshot?.header?.id ?? snapshot?.header ?? null
1408
+ if (typeof id === 'string' && snapshot?.revision !== undefined) {
1409
+ revisionBySession.set(id, snapshot.revision)
1410
+ }
1411
+ }
1412
+
1413
+ const sessions = await mapLimit(headers, SESSION_LIST_CONCURRENCY, async (header) => {
1414
+ const live = sessionIsLive(ctx, header.id)
1415
+
1416
+ // 1. Summary cache: reuse unless the revision moved (or the entry
1417
+ // is older than the TTL when no revision is available).
1418
+ const revision = revisionBySession.get(header.id)
1419
+ const cached = sessionSummaryCache.get(header.id)
1420
+ const now = Date.now()
1421
+ let derived = null
1422
+ if (cached !== undefined && !cached.summaryError
1423
+ && (revision !== undefined ? cached.revision === revision : now - cached.at <= SESSION_SUMMARY_CACHE_TTL_MS)) {
1424
+ derived = cached
1425
+ }
1426
+
1427
+ if (derived === null) {
1428
+ // 2. Live session events are in memory cheapest read.
1429
+ const liveSession = sessionsService?.get?.(header.id)
1430
+ let events = liveSession ? liveSession.events : null
1431
+
1432
+ if (!events && ctx.sessionPersistence !== undefined) {
1433
+ try {
1434
+ const inspection = await ctx.sessionPersistence.inspect(header.id)
1435
+ events = inspection?.events ?? []
1436
+ // inspect may carry a fresher revision than the snapshot list.
1437
+ if (revision === undefined && inspection?.revision !== undefined) {
1438
+ revisionBySession.set(header.id, inspection.revision)
1439
+ }
1440
+ } catch (error) {
1441
+ events = []
1442
+ derived = {
1443
+ title: '', summary: '', messageCount: 0,
1444
+ summaryError: error instanceof Error && error.message ? error.message : String(error),
1445
+ }
1446
+ }
1447
+ }
1448
+
1449
+ if (derived === null) derived = deriveSessionSummary(events)
1450
+ derived.revision = revisionBySession.get(header.id) ?? null
1451
+ derived.at = now
1452
+ sessionSummaryCache.set(header.id, derived)
1453
+ }
1454
+
1455
+ // Prefer the projection-cache title (the same displayTitle the
1456
+ // sidebar shows) so deleting by title from the sidebar menu matches
1457
+ // the same session on the host side. cachedSnapshot works from the
1458
+ // stored header — no live session needed — so ended sessions get
1459
+ // their real title too instead of a cwd-basename fallback.
1460
+ let projTitle = null
1461
+ try {
1462
+ const projCache = ctx.get ? ctx.get('sessionProjectionCache') : undefined
1463
+ if (projCache !== undefined && typeof projCache.cachedSnapshot === 'function') {
1464
+ const snap = projCache.cachedSnapshot(header)
1465
+ if (snap && snap.values && typeof snap.values.title === 'string' && snap.values.title !== '') {
1466
+ projTitle = snap.values.title
1467
+ }
1468
+ }
1469
+ } catch (error) {
1470
+ projTitle = null
1471
+ }
1472
+
1473
+ const ws = sessionToWorkspace.get(header.id)
1474
+ return {
1475
+ id: header.id,
1476
+ cwd: header.cwd ?? null,
1477
+ createdAt: header.createdAt,
1478
+ parentSession: header.parentSession ?? null,
1479
+ archived: archivedIds.includes(header.id),
1480
+ live,
1481
+ title: projTitle || derived.title || (header.cwd ? sessionBaseName(header.cwd) : '未命名会话'),
1482
+ summary: derived.summary ? (derived.summary.length > 180 ? derived.summary.slice(0, 180) + '...' : derived.summary) : '',
1483
+ summaryError: derived.summaryError || null,
1484
+ messageCount: derived.messageCount,
1485
+ workspaceId: ws?.workspaceId ?? null,
1486
+ workspaceTitle: ws?.title ?? null,
1487
+ }
1488
+ })
1489
+
1490
+ sessions.sort((left, right) => right.createdAt - left.createdAt)
1491
+ return {
1492
+ sessions,
1493
+ workspaces: workspaces.map(ws => ({
1494
+ workspaceId: ws.id ?? null,
1495
+ title: ws.title ?? null,
1496
+ path: ws.path ?? null,
1497
+ })),
1498
+ }
1499
+ },
1500
+
1501
+ async archive(sessionId) {
1502
+ if (typeof sessionId !== 'string' || sessionId === '') {
1503
+ throw new Error('session-admin: archive requires a sessionId string')
1504
+ }
1505
+ const headers = await ctx.sessionPersistence.list()
1506
+ if (!headers.some(header => header.id === sessionId)) {
1507
+ throw new Error(`session-admin: session '${sessionId}' does not exist`)
1508
+ }
1509
+ await ctx.workspaceRegistry.archiveSession(sessionId)
1510
+ return { archived: sessionId }
1511
+ },
1512
+
1513
+ async unarchive(sessionId) {
1514
+ if (typeof sessionId !== 'string' || sessionId === '') {
1515
+ throw new Error('session-admin: unarchive requires a sessionId string')
1516
+ }
1517
+ await removeFromArchivedSet(ctx, sessionId)
1518
+ return { unarchived: sessionId }
1519
+ },
1520
+
1521
+ /**
1522
+ * Delete a non-live (ended) session's durable artifacts. Online sessions
1523
+ * must use closeSession instead disposing the live agent first so the
1524
+ * log cannot resurrect.
1525
+ * @param sessionId - the session to delete.
1526
+ */
1527
+ async deleteSession(sessionId) {
1528
+ if (typeof sessionId !== 'string' || sessionId === '') {
1529
+ throw new Error('session-admin: deleteSession requires a sessionId string')
1530
+ }
1531
+ if (sessionIsLive(ctx, sessionId)) {
1532
+ throw new Error(`session '${sessionId}' is live close it before deleting`)
1533
+ }
1534
+ await removeSessionArtifacts(sessionId)
1535
+ return { deleted: sessionId }
1536
+ },
1537
+
1538
+ /**
1539
+ * Delete an ONLINE session without restarting dsh. If the session is
1540
+ * live in the in-memory store, its captured AgentHandle is disposed first
1541
+ * — dsh's official teardown chain stops the agent loop, waits for
1542
+ * quiescence, unregisters the agent, removes the session from the store
1543
+ * (emitting `session/disposed`), and lets the persistence backend flush
1544
+ * buffered events and release its write path — so removing the log file
1545
+ * afterwards cannot resurrect it. Non-live sessions simply skip the
1546
+ * dispose step.
1547
+ *
1548
+ * The dispose is a real agent shutdown: a running conversation in that
1549
+ * session is stopped. Callers must surface this before invoking.
1550
+ *
1551
+ * @param sessionId - the session to close and delete.
1552
+ * @returns { deleted: sessionId }.
1553
+ */
1554
+ async closeSession(sessionId) {
1555
+ if (typeof sessionId !== 'string' || sessionId === '') {
1556
+ throw new Error('session-admin: closeSession requires a sessionId string')
1557
+ }
1558
+ if (sessionIsLive(ctx, sessionId)) {
1559
+ const handle = handleCapture.get(sessionId)
1560
+ if (handle === undefined) {
1561
+ throw new Error(`session '${sessionId}' is live but its agent handle was not captured (created before this plugin mounted?) — restart dsh, then delete`)
1562
+ }
1563
+ await handle.dispose()
1564
+ handleCapture.delete(sessionId)
1565
+ }
1566
+ await removeSessionArtifacts(sessionId, true)
1567
+ return { deleted: sessionId }
1568
+ },
1569
+ }
1570
+
1571
+ const sessionBinding = Object.freeze({ service: sessionService, serviceKey: SESSION_SERVICE_KEY, namespace: SESSION_NAMESPACE })
1572
+ Object.defineProperty(sessionService, 'typertRemote', { value: sessionBinding, enumerable: false })
1573
+ ctx.provide(SESSION_SERVICE_KEY, sessionService)
1574
+
1575
+ /* ----------------------- FS Admin Remote Service ------------------------ */
1576
+ /**
1577
+ * Bring the Explorer window showing `path` to the foreground.
1578
+ * explorer.exe spawned from a background service has no foreground rights
1579
+ * (Windows foreground-lock), so the new window opens behind the active one.
1580
+ * This helper runs a PowerShell script that polls for the window and raises
1581
+ * it with the classic ALT-key + SetForegroundWindow trick: a simulated ALT
1582
+ * keypress makes the system treat the caller as having user input, which
1583
+ * grants SetForegroundWindow permission. Fail-soft and never blocks the
1584
+ * host loop.
1585
+ * @param path - the directory (or file) the Explorer window shows.
1586
+ */
1587
+ function bringExplorerWindowToFront(path) {
1588
+ try {
1589
+ const helperPath = join(MODULE_DIR, 'bring-explorer.ps1')
1590
+ // Windows caveats: the helper must spawn WITHOUT detached and with
1591
+ // real stdout/stderr pipes — detached or 'ignore' stdio leaves the
1592
+ // child with invalid handles and PowerShell silently fails to start
1593
+ // in a non-interactive context. unref() still lets dsh exit freely.
1594
+ const child = spawn('powershell.exe', [
1595
+ '-NoProfile', '-ExecutionPolicy', 'Bypass',
1596
+ '-File', helperPath, '-Path', path,
1597
+ ], { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
1598
+ child.stdout?.resume()
1599
+ child.stderr?.resume()
1600
+ child.on('error', () => { /* non-fatal */ })
1601
+ child.unref()
1602
+ } catch {
1603
+ /* non-fatal */
1604
+ }
1605
+ }
1606
+
1607
+
1608
+ /**
1609
+ * Reveal a filesystem path in the platform file manager.
1610
+ * @param path - absolute path to reveal (workspace directory or file).
1611
+ * @returns the revealed path.
1612
+ */
1613
+ const fsService = {
1614
+ async reveal(path) {
1615
+ if (typeof path !== 'string' || path.trim() === '') {
1616
+ throw new Error('fs-admin: reveal requires a path string')
1617
+ }
1618
+ const trimmed = path.trim()
1619
+ // Best-effort: spawn a reveal command without blocking the host loop.
1620
+ // Windows: a directory opens its own Explorer window; a file gets
1621
+ // /select to reveal it inside its parent folder. macOS: open -R;
1622
+ // Linux: xdg-open (no portable 'select' verb).
1623
+ let args
1624
+ if (process.platform === 'win32') {
1625
+ let isDir = false
1626
+ try { isDir = statSync(trimmed).isDirectory() } catch { isDir = false }
1627
+ args = isDir ? [trimmed] : ['/select,' + trimmed]
1628
+ } else if (process.platform === 'darwin') {
1629
+ args = ['-R', trimmed]
1630
+ } else {
1631
+ // Linux: xdg-open on a directory opens it in the file manager; a
1632
+ // file has no portable 'select' verb, so its parent is opened.
1633
+ let isDir = false
1634
+ try { isDir = statSync(trimmed).isDirectory() } catch { isDir = false }
1635
+ args = [isDir ? trimmed : dirname(trimmed)]
1636
+ }
1637
+ const cmd = process.platform === 'win32'
1638
+ ? spawn('explorer.exe', args, { detached: true, stdio: 'ignore' })
1639
+ : process.platform === 'darwin'
1640
+ ? spawn('open', args, { detached: true, stdio: 'ignore' })
1641
+ : spawn('xdg-open', args, { detached: true, stdio: 'ignore' })
1642
+ cmd.on('error', () => { /* non-fatal: the user can open the path manually */ })
1643
+ cmd.unref()
1644
+
1645
+ // Windows: bring the newly opened Explorer window to the foreground.
1646
+ // explorer.exe spawns from a background service have no foreground
1647
+ // rights, so the window opens behind the current one. A PowerShell
1648
+ // helper polls for the window and uses the classic ALT-key +
1649
+ // SetForegroundWindow trick (simulated user input) to raise it.
1650
+ if (process.platform === 'win32') {
1651
+ try { bringExplorerWindowToFront(trimmed) } catch { /* best-effort */ }
1652
+ }
1653
+ return { path: trimmed }
1654
+ },
1655
+ }
1656
+
1657
+ const fsBinding = Object.freeze({ service: fsService, serviceKey: FS_SERVICE_KEY, namespace: FS_NAMESPACE })
1658
+ Object.defineProperty(fsService, 'typertRemote', { value: fsBinding, enumerable: false })
1659
+ ctx.provide(FS_SERVICE_KEY, fsService)
1660
+
1661
+ /* ----------------------- MCP Admin Remote Service ----------------------- */
1662
+ /**
1663
+ * Parse top-level plugin-instance entries from a cordis patch file. Only
1664
+ * entries naming the MCP client plugin are returned; every other entry is
1665
+ * left untouched. The editor is line-based (zero YAML dependency) and
1666
+ * tracks entries by their `- id:` / `name:` top-level rows.
1667
+ * @param profileDir - the profile directory.
1668
+ * @returns the parsed MCP entries and the raw patch lines.
1669
+ */
1670
+ function readPatchLines(profileDir) {
1671
+ const patchPath = join(profileDir, PROFILE_PATCH_FILENAME)
1672
+ const text = existsSync(patchPath) ? readFileSync(patchPath, 'utf8') : ''
1673
+ return { text, lines: text.split(/\r?\n/), patchPath }
1674
+ }
1675
+
1676
+ /**
1677
+ * Locate the top-level entry blocks in the patch file. A block starts at a
1678
+ * line matching /^- / (top-level list item) and continues through the next
1679
+ * top-level item or the file end.
1680
+ * @param lines - patch file lines.
1681
+ * @returns array of { index, endIndex } block spans.
1682
+ */
1683
+ function topLevelBlocks(lines) {
1684
+ const blocks = []
1685
+ let start = -1
1686
+ for (let i = 0; i < lines.length; i++) {
1687
+ if (/^- /.test(lines[i])) {
1688
+ if (start !== -1) blocks.push({ index: start, endIndex: i })
1689
+ start = i
1690
+ }
1691
+ }
1692
+ if (start !== -1) blocks.push({ index: start, endIndex: lines.length })
1693
+ return blocks
1694
+ }
1695
+
1696
+ /**
1697
+ * Whether a block is an MCP client entry: its lines contain a `name:` row
1698
+ * whose value is the MCP plugin name.
1699
+ * @param lines - patch file lines.
1700
+ * @param block - block span.
1701
+ */
1702
+ function isMcpBlock(lines, block) {
1703
+ for (let i = block.index; i < block.endIndex; i++) {
1704
+ const line = lines[i]
1705
+ if (/^\s*name:\s*['"]?@deepseek-ai\/dsh-mcp-client['"]?\s*$/.test(line)) return true
1706
+ }
1707
+ return false
1708
+ }
1709
+
1710
+ /**
1711
+ * Extract the entry id (`- id: <id>`) and serverName from a block.
1712
+ * @param lines - patch file lines.
1713
+ * @param block - block span.
1714
+ * @returns { id, serverName } or null when the id is absent.
1715
+ */
1716
+ function blockIdentity(lines, block) {
1717
+ let id = null
1718
+ let serverName = null
1719
+ for (let i = block.index; i < block.endIndex; i++) {
1720
+ const line = lines[i]
1721
+ const idMatch = /^-\s*id:\s*['"]?([^'"\s]+)['"]?\s*$/.exec(line)
1722
+ if (idMatch) id = idMatch[1]
1723
+ const nameMatch = /^\s*serverName:\s*['"]?([^'"\s]+)['"]?\s*$/.exec(line)
1724
+ if (nameMatch) serverName = nameMatch[1]
1725
+ }
1726
+ return { id, serverName }
1727
+ }
1728
+
1729
+ function yamlScalar(value) {
1730
+ const text = value.trim()
1731
+ try {
1732
+ return JSON.parse(text)
1733
+ } catch {
1734
+ const quoted = /^['"](.*)['"]$/.exec(text)
1735
+ return quoted ? quoted[1] : text
1736
+ }
1737
+ }
1738
+
1739
+ /** Parse the supported MCP config shape without rewriting unknown blocks. */
1740
+ function configFromBlock(lines, block) {
1741
+ const config = {}
1742
+ let collection = null
1743
+ for (let i = block.index; i < block.endIndex; i++) {
1744
+ const line = lines[i]
1745
+ const field = /^ (transport|serverName|command|url|cwd|toolCallTimeoutMs|failOnStartupError|args|env|headers|reconnect):\s*(.*)$/.exec(line)
1746
+ if (field) {
1747
+ const key = field[1]
1748
+ const value = field[2]
1749
+ if (key === 'args') {
1750
+ config.args = []
1751
+ collection = 'args'
1752
+ } else if (key === 'env' || key === 'headers' || key === 'reconnect') {
1753
+ config[key] = {}
1754
+ collection = key
1755
+ } else {
1756
+ config[key] = yamlScalar(value)
1757
+ collection = null
1758
+ }
1759
+ continue
1760
+ }
1761
+ const item = /^ -\s*(.*)$/.exec(line)
1762
+ if (item && collection === 'args') {
1763
+ const value = yamlScalar(item[1])
1764
+ if (typeof value === 'string') config.args.push(value)
1765
+ continue
1766
+ }
1767
+ const property = /^ ([^:]+):\s*(.*)$/.exec(line)
1768
+ if (property && collection && collection !== 'args') {
1769
+ config[collection][property[1].trim()] = yamlScalar(property[2])
1770
+ }
1771
+ }
1772
+ if (config.transport === 'stdio' && typeof config.serverName === 'string' && typeof config.command === 'string') return config
1773
+ if (config.transport === 'streamable-http' && typeof config.serverName === 'string' && typeof config.url === 'string') return config
1774
+ return null
1775
+ }
1776
+
1777
+ /**
1778
+ * Serialize one MCP entry into YAML lines (the exact shape the mcp-client
1779
+ * plugin consumes). The entry id is a stable local handle; serverName is the
1780
+ * model-facing namespace.
1781
+ * @param entry - normalized MCP entry.
1782
+ * @returns YAML lines (without trailing newline).
1783
+ */
1784
+ function mcpEntryLines(entry) {
1785
+ const out = []
1786
+ out.push('- id: ' + JSON.stringify(entry.id))
1787
+ out.push(" name: '@deepseek-ai/dsh-mcp-client'")
1788
+ out.push(' config:')
1789
+ out.push(' transport: ' + entry.config.transport)
1790
+ out.push(' serverName: ' + JSON.stringify(entry.config.serverName))
1791
+ if (entry.config.transport === 'stdio') {
1792
+ out.push(' command: ' + JSON.stringify(entry.config.command))
1793
+ const args = entry.config.args ?? []
1794
+ if (args.length > 0) {
1795
+ out.push(' args:')
1796
+ for (const a of args) out.push(' - ' + JSON.stringify(a))
1797
+ }
1798
+ const env = entry.config.env ?? {}
1799
+ const envKeys = Object.keys(env)
1800
+ if (envKeys.length > 0) {
1801
+ out.push(' env:')
1802
+ for (const k of envKeys) out.push(' ' + k + ': ' + JSON.stringify(env[k]))
1803
+ }
1804
+ if (entry.config.cwd) out.push(' cwd: ' + JSON.stringify(entry.config.cwd))
1805
+ } else {
1806
+ out.push(' url: ' + JSON.stringify(entry.config.url))
1807
+ const headers = entry.config.headers ?? {}
1808
+ const headerKeys = Object.keys(headers)
1809
+ if (headerKeys.length > 0) {
1810
+ out.push(' headers:')
1811
+ for (const k of headerKeys) out.push(' ' + k + ': ' + JSON.stringify(headers[k]))
1812
+ }
1813
+ }
1814
+ if (entry.config.toolCallTimeoutMs !== undefined) {
1815
+ out.push(' toolCallTimeoutMs: ' + Number(entry.config.toolCallTimeoutMs))
1816
+ }
1817
+ if (entry.config.failOnStartupError === true) {
1818
+ out.push(' failOnStartupError: true')
1819
+ }
1820
+ if (entry.config.reconnect && typeof entry.config.reconnect === 'object') {
1821
+ out.push(' reconnect:')
1822
+ for (const key of ['enabled', 'initialDelayMs', 'maxDelayMs', 'maxAttempts']) {
1823
+ if (entry.config.reconnect[key] !== undefined) {
1824
+ out.push(' ' + key + ': ' + JSON.stringify(entry.config.reconnect[key]))
1825
+ }
1826
+ }
1827
+ }
1828
+ return out
1829
+ }
1830
+
1831
+ /**
1832
+ * List the MCP entries currently declared in the profile patch file.
1833
+ * @returns { entries, patchPath }.
1834
+ */
1835
+ function listMcpEntries() {
1836
+ const { lines, patchPath } = readPatchLines(profileDir)
1837
+ const entries = []
1838
+ for (const block of topLevelBlocks(lines)) {
1839
+ if (!isMcpBlock(lines, block)) continue
1840
+ const { id, serverName } = blockIdentity(lines, block)
1841
+ if (id === null) continue
1842
+ const config = configFromBlock(lines, block)
1843
+ entries.push({
1844
+ id,
1845
+ serverName: config?.serverName ?? serverName ?? id,
1846
+ config,
1847
+ raw: lines.slice(block.index, block.endIndex).join('\n'),
1848
+ })
1849
+ }
1850
+ return { entries, patchPath }
1851
+ }
1852
+
1853
+ /**
1854
+ * Persist the patch file atomically.
1855
+ * @param patchPath - patch file path.
1856
+ * @param text - next full file content.
1857
+ */
1858
+ function writePatch(patchPath, text) {
1859
+ const temp = patchPath + '.dsh-admin.tmp'
1860
+ writeFileSync(temp, text, 'utf8')
1861
+ renameSync(temp, patchPath)
1862
+ }
1863
+
1864
+ const mcpService = {
1865
+ async list() {
1866
+ return listMcpEntries()
1867
+ },
1868
+
1869
+ async upsert(entry) {
1870
+ if (entry === null || typeof entry !== 'object' || typeof entry.id !== 'string' || entry.id === '') {
1871
+ throw new Error('mcp-admin: upsert requires an entry with a non-empty id')
1872
+ }
1873
+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(entry.id)) {
1874
+ throw new Error('mcp-admin: entry id must match [A-Za-z0-9_-]{1,64}')
1875
+ }
1876
+ const cfg = entry.config
1877
+ if (cfg === null || typeof cfg !== 'object' || typeof cfg.transport !== 'string'
1878
+ || (cfg.transport !== 'stdio' && cfg.transport !== 'streamable-http')) {
1879
+ throw new Error('mcp-admin: config.transport must be \'stdio\' or \'streamable-http\'')
1880
+ }
1881
+ if (typeof cfg.serverName !== 'string' || !/^[A-Za-z0-9_-]{1,32}$/.test(cfg.serverName)) {
1882
+ throw new Error('mcp-admin: serverName must match [A-Za-z0-9_-]{1,32}')
1883
+ }
1884
+ if (cfg.transport === 'stdio' && (typeof cfg.command !== 'string' || cfg.command === '')) {
1885
+ throw new Error('mcp-admin: stdio entries require a command')
1886
+ }
1887
+ if (cfg.transport === 'streamable-http' && (typeof cfg.url !== 'string' || cfg.url === '')) {
1888
+ throw new Error('mcp-admin: streamable-http entries require a url')
1889
+ }
1890
+ // Patch-file mutations ride the same serialized operation queue as
1891
+ // pluginAdmin: the body is fully synchronous today (the event loop
1892
+ // already serializes it), but the queue keeps the read-modify-write
1893
+ // atomic if an await ever lands inside — and the duplicate-serverName
1894
+ // check then sees the queue-time file state, not a stale snapshot.
1895
+ return enqueue(async () => {
1896
+ const { lines, patchPath } = readPatchLines(profileDir)
1897
+ const blocks = topLevelBlocks(lines)
1898
+ const duplicateServer = blocks.find(block => {
1899
+ if (!isMcpBlock(lines, block)) return false
1900
+ const identity = blockIdentity(lines, block)
1901
+ return identity.id !== entry.id && identity.serverName === cfg.serverName
1902
+ })
1903
+ if (duplicateServer !== undefined) {
1904
+ throw new Error(`mcp-admin: serverName '${cfg.serverName}' is already used by another MCP entry`)
1905
+ }
1906
+ const target = blocks.find(block => {
1907
+ if (!isMcpBlock(lines, block)) return false
1908
+ return blockIdentity(lines, block).id === entry.id
1909
+ })
1910
+ const blockLines = mcpEntryLines(entry)
1911
+ let next
1912
+ if (target !== undefined) {
1913
+ next = [
1914
+ ...lines.slice(0, target.index),
1915
+ ...blockLines,
1916
+ ...lines.slice(target.endIndex),
1917
+ ].join('\n')
1918
+ } else {
1919
+ // Append after the last entry. The file may carry a header comment
1920
+ // plus a '[]' placeholder (an empty YAML list). That placeholder is
1921
+ // a complete document: leaving it in place and starting a new
1922
+ // `- id:` line below produces a second document and a YAML parse
1923
+ // error ("end of the stream or a document separator is expected")
1924
+ // at profile boot. So replace the placeholder line with the block;
1925
+ // otherwise append after the last line.
1926
+ let placeholder = -1
1927
+ for (let i = lines.length - 1; i >= 0; i--) {
1928
+ if (lines[i].trim() === '[]') { placeholder = i; break }
1929
+ }
1930
+ if (placeholder !== -1) {
1931
+ next = [
1932
+ ...lines.slice(0, placeholder),
1933
+ ...blockLines,
1934
+ ...lines.slice(placeholder + 1),
1935
+ ].join('\n')
1936
+ if (!next.endsWith('\n')) next += '\n'
1937
+ } else {
1938
+ const trimmed = lines.join('\n').trimEnd()
1939
+ const base = trimmed === '' ? '' : trimmed + '\n'
1940
+ next = base + blockLines.join('\n') + '\n'
1941
+ }
1942
+ }
1943
+ writePatch(patchPath, next)
1944
+ return { ok: true, id: entry.id, entries: listMcpEntries().entries }
1945
+ })
1946
+ },
1947
+
1948
+ async remove(id) {
1949
+ if (typeof id !== 'string' || id === '') {
1950
+ throw new Error('mcp-admin: remove requires an entry id')
1951
+ }
1952
+ return enqueue(async () => {
1953
+ const { lines, patchPath } = readPatchLines(profileDir)
1954
+ const blocks = topLevelBlocks(lines)
1955
+ const target = blocks.find(block => {
1956
+ if (!isMcpBlock(lines, block)) return false
1957
+ return blockIdentity(lines, block).id === id
1958
+ })
1959
+ if (target === undefined) {
1960
+ throw new Error(`mcp-admin: entry '${id}' not found in ${PROFILE_PATCH_FILENAME}`)
1961
+ }
1962
+ const next = [
1963
+ ...lines.slice(0, target.index),
1964
+ ...lines.slice(target.endIndex),
1965
+ ].join('\n').trimEnd() + '\n'
1966
+ writePatch(patchPath, next)
1967
+ return { ok: true, id, entries: listMcpEntries().entries }
1968
+ })
1969
+ },
1970
+
1971
+ /**
1972
+ * Probe the connectivity of one configured MCP server (by entry id) from
1973
+ * the host, without requiring a dsh restart. The probe mirrors the real
1974
+ * dsh-mcp-client handshake (initialize initialized tools/list) over
1975
+ * the entry's configured transport, with a strict timeout so a dead
1976
+ * endpoint fails fast instead of hanging the panel.
1977
+ * @param id - MCP entry id.
1978
+ * @returns a probe result (never rejects):
1979
+ * { ok: true, serverInfo, toolCount, transport, ms, pingOk? } or
1980
+ * { ok: false, error, transport, ms, stderr? }.
1981
+ */
1982
+ async test(id) {
1983
+ if (typeof id !== 'string' || id === '') {
1984
+ throw new Error('mcp-admin: test requires an entry id')
1985
+ }
1986
+ const entry = listMcpEntries().entries.find(entry => entry.id === id)
1987
+ if (entry === undefined) {
1988
+ throw new Error(`mcp-admin: entry '${id}' not found in ${PROFILE_PATCH_FILENAME}`)
1989
+ }
1990
+ if (entry.config === null || entry.config === undefined) {
1991
+ throw new Error(`mcp-admin: entry '${id}' has an unparsable config; fix ${PROFILE_PATCH_FILENAME} manually`)
1992
+ }
1993
+ return probeMcpServer(entry.config)
1994
+ },
1995
+ }
1996
+
1997
+ const mcpBinding = Object.freeze({ service: mcpService, serviceKey: MCP_SERVICE_KEY, namespace: MCP_NAMESPACE })
1998
+ Object.defineProperty(mcpService, 'typertRemote', { value: mcpBinding, enumerable: false })
1999
+ ctx.provide(MCP_SERVICE_KEY, mcpService)
2000
+
2001
+ /* ---------------------- Typert Descriptors Register ---------------------- */
2002
+ const specParam = [{ name: 'spec', wire: 'spec', source: 'json', codec: { mode: 'src-json' } }]
2003
+ const nameParam = [{ name: 'name', wire: 'name', source: 'json', codec: { mode: 'src-json' } }]
2004
+ const sessionParam = [{ name: 'sessionId', wire: 'sessionId', source: 'json', codec: { mode: 'src-json' } }]
2005
+
2006
+ ctx.effect(() => ctx.typert.register({
2007
+ package: PACKAGE,
2008
+ face: 'host',
2009
+ schemas: [],
2010
+ model: { services: [], events: [], objects: [] },
2011
+ invocations: [
2012
+ // pluginAdmin
2013
+ {
2014
+ id: `${PACKAGE}/list`,
2015
+ service: PLUGIN_SERVICE_KEY,
2016
+ namespace: PLUGIN_NAMESPACE,
2017
+ method: 'list',
2018
+ invocation: { kind: 'direct' },
2019
+ parameters: [],
2020
+ result: { mode: 'src-json' },
2021
+ },
2022
+ {
2023
+ id: `${PACKAGE}/install`,
2024
+ service: PLUGIN_SERVICE_KEY,
2025
+ namespace: PLUGIN_NAMESPACE,
2026
+ method: 'install',
2027
+ invocation: { kind: 'direct' },
2028
+ parameters: specParam,
2029
+ result: { mode: 'src-json' },
2030
+ },
2031
+ {
2032
+ id: `${PACKAGE}/remove`,
2033
+ service: PLUGIN_SERVICE_KEY,
2034
+ namespace: PLUGIN_NAMESPACE,
2035
+ method: 'remove',
2036
+ invocation: { kind: 'direct' },
2037
+ parameters: nameParam,
2038
+ result: { mode: 'src-json' },
2039
+ },
2040
+ {
2041
+ id: `${PACKAGE}/checkUpdates`,
2042
+ service: PLUGIN_SERVICE_KEY,
2043
+ namespace: PLUGIN_NAMESPACE,
2044
+ method: 'checkUpdates',
2045
+ invocation: { kind: 'direct' },
2046
+ parameters: [],
2047
+ result: { mode: 'src-json' },
2048
+ },
2049
+ // sessionAdmin
2050
+ {
2051
+ id: `${PACKAGE}/session/list`,
2052
+ service: SESSION_SERVICE_KEY,
2053
+ namespace: SESSION_NAMESPACE,
2054
+ method: 'list',
2055
+ invocation: { kind: 'direct' },
2056
+ parameters: [],
2057
+ result: { mode: 'src-json' },
2058
+ },
2059
+ {
2060
+ id: `${PACKAGE}/session/archive`,
2061
+ service: SESSION_SERVICE_KEY,
2062
+ namespace: SESSION_NAMESPACE,
2063
+ method: 'archive',
2064
+ invocation: { kind: 'direct' },
2065
+ parameters: sessionParam,
2066
+ result: { mode: 'src-json' },
2067
+ },
2068
+ {
2069
+ id: `${PACKAGE}/session/unarchive`,
2070
+ service: SESSION_SERVICE_KEY,
2071
+ namespace: SESSION_NAMESPACE,
2072
+ method: 'unarchive',
2073
+ invocation: { kind: 'direct' },
2074
+ parameters: sessionParam,
2075
+ result: { mode: 'src-json' },
2076
+ },
2077
+ {
2078
+ id: `${PACKAGE}/session/deleteSession`,
2079
+ service: SESSION_SERVICE_KEY,
2080
+ namespace: SESSION_NAMESPACE,
2081
+ method: 'deleteSession',
2082
+ invocation: { kind: 'direct' },
2083
+ parameters: sessionParam,
2084
+ result: { mode: 'src-json' },
2085
+ },
2086
+ {
2087
+ id: `${PACKAGE}/session/closeSession`,
2088
+ service: SESSION_SERVICE_KEY,
2089
+ namespace: SESSION_NAMESPACE,
2090
+ method: 'closeSession',
2091
+ invocation: { kind: 'direct' },
2092
+ parameters: sessionParam,
2093
+ result: { mode: 'src-json' },
2094
+ },
2095
+ // fsAdmin
2096
+ {
2097
+ id: `${PACKAGE}/fs/reveal`,
2098
+ service: FS_SERVICE_KEY,
2099
+ namespace: FS_NAMESPACE,
2100
+ method: 'reveal',
2101
+ invocation: { kind: 'direct' },
2102
+ parameters: [{ name: 'path', wire: 'path', source: 'json', codec: { mode: 'src-json' } }],
2103
+ result: { mode: 'src-json' },
2104
+ },
2105
+ // mcpAdmin
2106
+ {
2107
+ id: `${PACKAGE}/mcp/list`,
2108
+ service: MCP_SERVICE_KEY,
2109
+ namespace: MCP_NAMESPACE,
2110
+ method: 'list',
2111
+ invocation: { kind: 'direct' },
2112
+ parameters: [],
2113
+ result: { mode: 'src-json' },
2114
+ },
2115
+ {
2116
+ id: `${PACKAGE}/mcp/upsert`,
2117
+ service: MCP_SERVICE_KEY,
2118
+ namespace: MCP_NAMESPACE,
2119
+ method: 'upsert',
2120
+ invocation: { kind: 'direct' },
2121
+ parameters: [{ name: 'entry', wire: 'entry', source: 'json', codec: { mode: 'src-json' } }],
2122
+ result: { mode: 'src-json' },
2123
+ },
2124
+ {
2125
+ id: `${PACKAGE}/mcp/remove`,
2126
+ service: MCP_SERVICE_KEY,
2127
+ namespace: MCP_NAMESPACE,
2128
+ method: 'remove',
2129
+ invocation: { kind: 'direct' },
2130
+ parameters: [{ name: 'id', wire: 'id', source: 'json', codec: { mode: 'src-json' } }],
2131
+ result: { mode: 'src-json' },
2132
+ },
2133
+ {
2134
+ id: `${PACKAGE}/mcp/test`,
2135
+ service: MCP_SERVICE_KEY,
2136
+ namespace: MCP_NAMESPACE,
2137
+ method: 'test',
2138
+ invocation: { kind: 'direct' },
2139
+ parameters: [{ name: 'id', wire: 'id', source: 'json', codec: { mode: 'src-json' } }],
2140
+ result: { mode: 'src-json' },
2141
+ },
2142
+ ],
2143
+ }), 'plugin-admin: typert descriptors (plugins, sessions, fs, mcp)')
2144
+ }