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/README.md +104 -0
- package/cordis.patch.yml +7 -0
- package/lib/bring-explorer.ps1 +56 -0
- package/lib/client.js +1751 -0
- package/lib/index.js +1973 -0
- package/package.json +51 -0
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
|
+
}
|