dsh-plugin-admin 0.1.0

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