dsh-plugin-admin 0.4.2 → 0.5.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.
@@ -0,0 +1,1878 @@
1
+ /**
2
+ * Subagent administration host half, merged from the former standalone
3
+ * dsh-plugin-subagents plugin. Zero dsh imports on purpose: everything rides
4
+ * the live Cordis Context (services by key) and plain-data typert
5
+ * registration, the same pattern the rest of dsh-plugin-admin uses.
6
+ *
7
+ * The module manages ONE artifact: the profile `cordis.patch.yml` rows whose
8
+ * `name` is `@deepseek-ai/dsh-tool-subagent`. Each row is one named subagent —
9
+ * a delegation tool instance with its own toolName (子智能体名称), persona (提示词),
10
+ * toolFilter (工具约束) and agentOptions (模型指定). Writing the file is the
11
+ * whole persistence story: the profile patch layer is hot-reloaded by the
12
+ * launcher's watch-only Cordis HMR on every long-lived surface, so saved rows
13
+ * mount/dispose live AND survive restarts, because boot composes the same file.
14
+ *
15
+ * Remote surface served by the /api RPC gateway — namespace `subagentAdmin`:
16
+ *
17
+ * 1. list() → managed entries with live mount status, plus the picker meta
18
+ * (registered providers with capabilities, candidate tool names).
19
+ * 2. upsert(entry) → validate, then replace the entry's block by id or append
20
+ * a new one; atomic write; journal append.
21
+ * 3. remove(id) → validate existence, delete the block; atomic write; journal.
22
+ * 4. history(limit) → the change journal (配置台账), newest first.
23
+ * 5. cliList/cliUpsert/cliRemove/cliInstall → external CLI backend management
24
+ * (harness provider packages via patch rows, generic commands via a
25
+ * plugin-owned JSON config + live-registered command providers).
26
+ *
27
+ * The managed-block marker comments are a byte-stable deployment contract:
28
+ * profiles managed by the former standalone plugin keep working unchanged.
29
+ */
30
+
31
+ import { execFile, execFileSync } from 'node:child_process'
32
+ import { randomUUID } from 'node:crypto'
33
+ import { appendFileSync, copyFileSync, existsSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'
34
+ import { createRequire } from 'node:module'
35
+ import { dirname, join } from 'node:path'
36
+ import { fileURLToPath } from 'node:url'
37
+ import { TOOL_SEED, RESERVED_TOOL_NAMES } from './tool-seed.js'
38
+
39
+ const DESCRIPTOR_PACKAGE = 'dsh-plugin-admin'
40
+ const SERVICE_KEY = 'subagentAdmin'
41
+ const NAMESPACE = 'subagentAdmin'
42
+ const PLUGIN_NAME = '@deepseek-ai/dsh-tool-subagent'
43
+ const PROFILE_PATCH_FILENAME = 'cordis.patch.yml'
44
+ const HISTORY_FILENAME = 'subagent-admin.history.jsonl'
45
+ const BACKUP_SUFFIX = '.bak-subagent-admin'
46
+ const TMP_SUFFIX = '.tmp-subagent-admin'
47
+
48
+ /** Marker comment above the managed `- insert:` block this plugin owns. */
49
+ export const MANAGED_BLOCK_MARKER = '# >>> dsh-plugin-subagents managed rows (auto-managed, do not edit by hand) <<<'
50
+
51
+ /** Marker comment above the managed `- insert:` block holding CLI backend rows. */
52
+ export const CLI_BLOCK_MARKER = '# >>> dsh-plugin-subagents cli backends (auto-managed, do not edit by hand) <<<'
53
+
54
+ /**
55
+ * External CLI subagent providers shipped by the harness that this panel can
56
+ * mount and configure. `runnerPackage` + `cliCommand` power availability
57
+ * detection; `permissionModes` mirrors each provider package's Config schema.
58
+ */
59
+ export const CLI_BACKENDS = [
60
+ {
61
+ id: 'subagent-codex',
62
+ label: 'Codex',
63
+ packageName: '@deepseek-ai/dsh-subagent-codex',
64
+ runnerPackage: '@openai/codex',
65
+ cliCommand: 'codex',
66
+ permissionModes: ['never', 'approve-for-me', 'dangerously-bypass-approvals-and-sandbox'],
67
+ defaultConfig: { providerName: 'codex', permissionMode: 'never', disposeGraceMs: 3000, env: {} },
68
+ },
69
+ {
70
+ id: 'subagent-claude-code',
71
+ label: 'Claude',
72
+ packageName: '@deepseek-ai/dsh-subagent-claude-code',
73
+ runnerPackage: '@anthropic-ai/claude-agent-sdk',
74
+ cliPackage: '@anthropic-ai/claude-code',
75
+ cliCommand: 'claude',
76
+ permissionModes: ['dontAsk', 'acceptEdits', 'auto', 'plan', 'bypassPermissions'],
77
+ defaultConfig: { providerName: 'claude-code', permissionMode: 'dontAsk', disposeGraceMs: 3000, env: {} },
78
+ },
79
+ ]
80
+
81
+ /** Well-known agent CLIs scanned for display only — the harness has no provider for them. */
82
+ export const CLI_SCAN_ONLY = ['gemini', 'qwen', 'opencode']
83
+
84
+ const CLI_CONFIG_KEYS = ['providerName', 'permissionMode', 'disposeGraceMs', 'env']
85
+ const CLI_PROVIDER_NAME_PATTERN = /^[a-z][a-z0-9_-]{0,47}$/
86
+ const CLI_ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
87
+
88
+ /** Generic external-CLI backends persist to this plugin-owned file (NOT the patch: those rows would be instantiated as bundles). */
89
+ const CLI_GENERIC_CONFIG_FILENAME = 'subagent-admin.cli.json'
90
+ const CLI_GENERIC_ID_PREFIX = 'cli-'
91
+ /** Non-interactive invocation presets for well-known agent CLIs; unknown commands default to a bare {prompt}. */
92
+ const CLI_GENERIC_PRESET_ARGS = {
93
+ codex: ['exec', '{prompt}'],
94
+ claude: ['-p', '{prompt}'],
95
+ gemini: ['-p', '{prompt}'],
96
+ qwen: ['-p', '{prompt}'],
97
+ opencode: ['run', '{prompt}'],
98
+ }
99
+ const CLI_GENERIC_RESERVED_PROVIDER_NAMES = new Set(['spawn', 'fork', 'subagent', 'subagent_fork', 'run_code', 'codex', 'claude-code'])
100
+ const CLI_GENERIC_MAX_ARGS = 20
101
+ const CLI_ARG_MAX_CHARS = 256
102
+ const CLI_DIAGNOSTIC_MAX_CHARS = 2000
103
+
104
+ /** Rewrite the journal once it grows past this many bytes (keep the tail). */
105
+ const JOURNAL_ROTATE_BYTES = 512 * 1024
106
+ /** Journal lines kept after a rotation. */
107
+ const JOURNAL_KEEP_LINES = 400
108
+
109
+ const PERSONA_MAX_CHARS = 32768
110
+ const MODEL_MAX_CHARS = 200
111
+ const MAX_TOKENS_MAX = 2_000_000
112
+ const TOOLNAME_PATTERN = /^[a-z][a-z0-9_]{1,47}$/
113
+ const TOOL_REF_PATTERN = /^[a-z][a-z0-9_]{0,63}$/
114
+ const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/
115
+ const CONFIG_KEYS = ['provider', 'toolName', 'enableRunInBackground', 'backgroundMode', 'agentOptions', 'persona', 'toolFilter', 'maxDepth']
116
+
117
+ /* ========================================================================== */
118
+ /* Patch-file line-block editor */
119
+ /* ========================================================================== */
120
+
121
+ /**
122
+ * Read the profile patch file and locate the top-level entry blocks. A block
123
+ * starts at a line matching /^- / and runs to the next top-level item or EOF.
124
+ * @param profileDir - the profile directory holding cordis.patch.yml.
125
+ * @returns { patchPath, text, lines, blocks } where blocks are { index, endIndex } spans.
126
+ */
127
+ function readPatch(profileDir) {
128
+ const patchPath = join(profileDir, PROFILE_PATCH_FILENAME)
129
+ const text = existsSync(patchPath) ? readFileSync(patchPath, 'utf8') : ''
130
+ const lines = text.split(/\r?\n/)
131
+ const blocks = []
132
+ let start = -1
133
+ for (let i = 0; i < lines.length; i++) {
134
+ if (/^- /.test(lines[i])) {
135
+ if (start !== -1) blocks.push({ index: start, endIndex: i })
136
+ start = i
137
+ }
138
+ }
139
+ if (start !== -1) blocks.push({ index: start, endIndex: lines.length })
140
+ return { patchPath, text, lines, blocks }
141
+ }
142
+
143
+ /** Whether a block is a managed tool-subagent entry (its `name:` row matches). */
144
+ function isSubagentBlock(lines, block) {
145
+ for (let i = block.index; i < block.endIndex; i++) {
146
+ if (/^\s*name:\s*['"]?@deepseek-ai\/dsh-tool-subagent['"]?\s*$/.test(lines[i])) return true
147
+ }
148
+ return false
149
+ }
150
+
151
+ /** The block's top-level `- id:` value, or null. */
152
+ function blockId(lines, block) {
153
+ for (let i = block.index; i < block.endIndex; i++) {
154
+ const match = /^-\s*id:\s*['"]?([^'"\s]+)['"]?\s*$/.exec(lines[i])
155
+ if (match) return match[1]
156
+ }
157
+ return null
158
+ }
159
+
160
+ /** Unquote one YAML scalar the way the plugin (and the admin plugin) writes them. */
161
+ function yamlScalar(value) {
162
+ const text = value.trim()
163
+ try {
164
+ return JSON.parse(text)
165
+ } catch {
166
+ const quoted = /^['"](.*)['"]$/.exec(text)
167
+ return quoted ? quoted[1] : text
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Parse one nested mapping block (the `config:` body) with indent-aware
173
+ * recursion. Handles exactly the shapes this plugin serializes: scalars,
174
+ * string lists (`- item`), and one nested mapping level.
175
+ * @param lines - patch file lines.
176
+ * @param startIndex - first line index INSIDE the mapping (after `config:`).
177
+ * @param endIndex - exclusive end of the enclosing entry block.
178
+ * @param indent - the mapping's own indent (spaces).
179
+ * @returns the parsed plain object.
180
+ */
181
+ function parseMapping(lines, startIndex, endIndex, indent) {
182
+ const out = {}
183
+ const fieldAt = new RegExp(`^ {${indent}}([A-Za-z][A-Za-z0-9_]*):\\s?(.*)$`)
184
+ let i = startIndex
185
+ while (i < endIndex) {
186
+ const line = lines[i]
187
+ if (line.trim() === '' || line.trim().startsWith('#')) { i++; continue }
188
+ const currentIndent = line.length - line.trimStart().length
189
+ if (currentIndent < indent) break
190
+ const field = fieldAt.exec(line)
191
+ if (!field) { i++; continue }
192
+ const key = field[1]
193
+ const value = field[2]
194
+ if (value !== '') {
195
+ out[key] = yamlScalar(value)
196
+ i++
197
+ continue
198
+ }
199
+ // Empty value: a nested mapping or a list follows at a deeper indent.
200
+ let j = i + 1
201
+ while (j < endIndex && (lines[j].trim() === '' || lines[j].trim().startsWith('#'))) j++
202
+ if (j >= endIndex) { out[key] = undefined; i++; continue }
203
+ const nextIndent = lines[j].length - lines[j].trimStart().length
204
+ if (nextIndent <= indent) { out[key] = undefined; i++; continue }
205
+ if (/^\s*-\s/.test(lines[j])) {
206
+ out[key] = parseList(lines, j, endIndex, nextIndent)
207
+ i = j + listLength(lines, j, endIndex)
208
+ continue
209
+ }
210
+ out[key] = parseMapping(lines, j, endIndex, nextIndent)
211
+ i = advancePastMapping(lines, j, endIndex, nextIndent)
212
+ }
213
+ return out
214
+ }
215
+
216
+ /** Parse an indented `- item` string list. */
217
+ function parseList(lines, startIndex, endIndex, indent) {
218
+ const out = []
219
+ let i = startIndex
220
+ while (i < endIndex) {
221
+ const line = lines[i]
222
+ const currentIndent = line.length - line.trimStart().length
223
+ if (line.trim() === '' ) { i++; continue }
224
+ if (currentIndent < indent || !/^\s*-\s/.test(line)) break
225
+ const item = /^-\s+(.*)$/.exec(line.slice(indent))
226
+ if (item) out.push(yamlScalar(item[1]))
227
+ i++
228
+ }
229
+ return out
230
+ }
231
+
232
+ /** Count consecutive list item lines (plus blanks) from a list start. */
233
+ function listLength(lines, startIndex, endIndex) {
234
+ let count = 0
235
+ let i = startIndex
236
+ while (i < endIndex) {
237
+ const line = lines[i]
238
+ if (line.trim() === '') { count++; i++; continue }
239
+ if (!/^\s*-\s/.test(line)) break
240
+ count++
241
+ i++
242
+ }
243
+ return count
244
+ }
245
+
246
+ /** Advance past a nested mapping: return the first line index at or above `indent`. */
247
+ function advancePastMapping(lines, startIndex, endIndex, indent) {
248
+ let i = startIndex
249
+ while (i < endIndex) {
250
+ const line = lines[i]
251
+ if (line.trim() === '') { i++; continue }
252
+ const currentIndent = line.length - line.trimStart().length
253
+ if (currentIndent < indent) break
254
+ i++
255
+ }
256
+ return i
257
+ }
258
+
259
+ /**
260
+ * Parse the entry config from a block: the `config:` body of a managed row.
261
+ * @returns the parsed config object (possibly partial), or null when the block has no config body.
262
+ */
263
+ function configFromBlock(lines, block) {
264
+ for (let i = block.index; i < block.endIndex; i++) {
265
+ if (/^\s{2}config:\s*$/.test(lines[i])) {
266
+ let bodyStart = i + 1
267
+ while (bodyStart < block.endIndex && lines[bodyStart].trim() === '') bodyStart++
268
+ if (bodyStart >= block.endIndex) return {}
269
+ const bodyIndent = lines[bodyStart].length - lines[bodyStart].trimStart().length
270
+ return parseMapping(lines, bodyStart, block.endIndex, bodyIndent)
271
+ }
272
+ }
273
+ return null
274
+ }
275
+
276
+ /**
277
+ * Serialize one managed entry into canonical YAML lines. The entry rides a
278
+ * `- insert:` list as a top-level row, indented 4 spaces under the insert key.
279
+ * Multi-line persona text rides a double-quoted YAML scalar with \n escapes,
280
+ * which round-trips through the loader's YAML parser.
281
+ * @param entry - { id, config } with the validated config shape.
282
+ * @param indent - leading spaces before the entry's own `- ` (default 4: inside `- insert:`).
283
+ * @returns YAML lines (no trailing newline).
284
+ */
285
+ export function serializeEntryLines(entry, indent = 4) {
286
+ const pad = ' '.repeat(indent)
287
+ const config = entry.config
288
+ const out = []
289
+ out.push(`${pad}- id: ${JSON.stringify(entry.id)}`)
290
+ out.push(`${pad} name: '${PLUGIN_NAME}'`)
291
+ out.push(`${pad} config:`)
292
+ out.push(`${pad} provider: ${JSON.stringify(config.provider)}`)
293
+ out.push(`${pad} toolName: ${JSON.stringify(config.toolName)}`)
294
+ if (config.persona !== undefined) {
295
+ out.push(`${pad} persona: ${JSON.stringify(config.persona)}`)
296
+ }
297
+ if (config.toolFilter !== undefined) {
298
+ out.push(`${pad} toolFilter:`)
299
+ const allow = config.toolFilter.allow ?? []
300
+ const deny = config.toolFilter.deny ?? []
301
+ if (allow.length > 0) {
302
+ out.push(`${pad} allow:`)
303
+ for (const name of allow) out.push(`${pad} - ${JSON.stringify(name)}`)
304
+ }
305
+ if (deny.length > 0) {
306
+ out.push(`${pad} deny:`)
307
+ for (const name of deny) out.push(`${pad} - ${JSON.stringify(name)}`)
308
+ }
309
+ }
310
+ if (config.agentOptions !== undefined) {
311
+ out.push(`${pad} agentOptions:`)
312
+ if (config.agentOptions.provider !== undefined) out.push(`${pad} provider: ${JSON.stringify(config.agentOptions.provider)}`)
313
+ if (config.agentOptions.model !== undefined) out.push(`${pad} model: ${JSON.stringify(config.agentOptions.model)}`)
314
+ if (config.agentOptions.maxTokens !== undefined) out.push(`${pad} maxTokens: ${Number(config.agentOptions.maxTokens)}`)
315
+ }
316
+ if (config.maxDepth !== undefined) {
317
+ out.push(`${pad} maxDepth: ${config.maxDepth === 'provider-managed' ? "'provider-managed'" : Number(config.maxDepth)}`)
318
+ }
319
+ if (config.backgroundMode !== undefined) {
320
+ out.push(`${pad} backgroundMode: ${config.backgroundMode}`)
321
+ }
322
+ if (config.enableRunInBackground !== undefined) {
323
+ out.push(`${pad} enableRunInBackground: ${config.enableRunInBackground === true}`)
324
+ }
325
+ return out
326
+ }
327
+
328
+ /**
329
+ * Insert or replace one managed entry inside this plugin's `- insert:` block
330
+ * (creating block + marker at EOF when absent). A legacy top-level row with
331
+ * the same id is removed (migrated into the managed block). Non-managed rows
332
+ * keep their lines byte-for-byte.
333
+ * @param lines - patch file lines.
334
+ * @param entry - { id, config } validated entry.
335
+ * @returns new lines array.
336
+ */
337
+ export function upsertIntoLines(lines, entry) {
338
+ let next = removeLegacyRow(lines, entry.id)
339
+ const managed = locateManagedBlock(next)
340
+ const fresh = serializeEntryLines(entry)
341
+
342
+ if (managed === null) {
343
+ const block = [MANAGED_BLOCK_MARKER, '- insert:', ...fresh]
344
+ const trimmed = [...next]
345
+ while (trimmed.length > 0 && trimmed[trimmed.length - 1].trim() === '') trimmed.pop()
346
+ return [...trimmed, '', ...block]
347
+ }
348
+
349
+ if (managed.blockIndex === -1) {
350
+ // Marker survived without its insert block: rebuild the block after it.
351
+ return [...next.slice(0, managed.markerIndex + 1), '- insert:', ...fresh, ...next.slice(managed.markerIndex + 1)]
352
+ }
353
+
354
+ // Find the inner sub-entry carrying the same id and replace it in place.
355
+ const inner = next.slice(managed.blockIndex + 1, managed.blockEndIndex)
356
+ for (let i = 0; i < inner.length; i++) {
357
+ if (!/^ {4}- /.test(inner[i])) continue
358
+ if (/^ {4}-\s*id:\s*['"]?([^'"\s]+)['"]?\s*$/.exec(inner[i])?.[1] === entry.id) {
359
+ const innerEnd = (() => {
360
+ for (let j = i + 1; j < inner.length; j++) if (/^ {4}- /.test(inner[j])) return j
361
+ return inner.length
362
+ })()
363
+ return [
364
+ ...next.slice(0, managed.blockIndex + 1),
365
+ ...inner.slice(0, i),
366
+ ...fresh,
367
+ ...inner.slice(innerEnd),
368
+ ...next.slice(managed.blockEndIndex),
369
+ ]
370
+ }
371
+ }
372
+ return [...next.slice(0, managed.blockEndIndex), ...fresh, ...next.slice(managed.blockEndIndex)]
373
+ }
374
+
375
+ /** Remove one legacy top-level tool-subagent row by id. @returns new lines. */
376
+ function removeLegacyRow(lines, id) {
377
+ for (const block of topLevelBlocks(lines)) {
378
+ if (!isSubagentBlock(lines, block)) continue
379
+ if (blockId(lines, block) === id) {
380
+ return [...lines.slice(0, block.index), ...lines.slice(block.endIndex)]
381
+ }
382
+ }
383
+ return lines
384
+ }
385
+
386
+ /**
387
+ * Remove one managed entry by id: from the managed insert block (and any
388
+ * legacy top-level row with the same id). An emptied managed block is removed
389
+ * together with its marker.
390
+ * @param lines - patch file lines.
391
+ * @param id - managed entry id.
392
+ * @returns { lines, removed } where removed reports whether any row was deleted.
393
+ */
394
+ export function removeFromLines(lines, id) {
395
+ let removed = false
396
+ let next = lines
397
+ const legacy = removeLegacyRow(next, id)
398
+ if (legacy !== next) { removed = true; next = legacy }
399
+
400
+ const managed = locateManagedBlock(next)
401
+ if (managed && managed.blockIndex !== -1) {
402
+ const inner = next.slice(managed.blockIndex + 1, managed.blockEndIndex)
403
+ let kept = []
404
+ let current = null
405
+ const flush = () => {
406
+ if (current === null) return
407
+ const idMatch = /^ {4}-\s*id:\s*['"]?([^'"\s]+)['"]?\s*$/.exec(current[0])
408
+ if (idMatch && idMatch[1] === id) { removed = true } else { kept = [...kept, ...current] }
409
+ current = null
410
+ }
411
+ for (const line of inner) {
412
+ if (/^ {4}- /.test(line)) { flush(); current = [line] }
413
+ else if (current !== null) current = [...current, line]
414
+ }
415
+ flush()
416
+ if (removed) {
417
+ if (kept.length === 0) {
418
+ next = [...next.slice(0, managed.markerIndex), ...next.slice(managed.blockEndIndex)]
419
+ if (next[managed.markerIndex] !== undefined && next[managed.markerIndex].trim() === ''
420
+ && managed.markerIndex > 0 && next[managed.markerIndex - 1].trim() === '') {
421
+ next = [...next.slice(0, managed.markerIndex), ...next.slice(managed.markerIndex + 1)]
422
+ }
423
+ } else {
424
+ next = [...next.slice(0, managed.blockIndex + 1), ...kept, ...next.slice(managed.blockEndIndex)]
425
+ }
426
+ }
427
+ }
428
+ return { lines: next, removed }
429
+ }
430
+
431
+ /** Every top-level patch block as { index, endIndex } spans. */
432
+ function topLevelBlocks(lines) {
433
+ const blocks = []
434
+ let start = -1
435
+ for (let i = 0; i < lines.length; i++) {
436
+ if (/^- /.test(lines[i])) {
437
+ if (start !== -1) blocks.push({ index: start, endIndex: i })
438
+ start = i
439
+ }
440
+ }
441
+ if (start !== -1) blocks.push({ index: start, endIndex: lines.length })
442
+ return blocks
443
+ }
444
+
445
+ /**
446
+ * Locate this plugin's managed `- insert:` block (identified by the marker
447
+ * comment line immediately above it).
448
+ * @returns { markerIndex, blockIndex, blockEndIndex } or null when absent.
449
+ */
450
+ function locateManagedBlock(lines) {
451
+ for (let i = 0; i < lines.length; i++) {
452
+ if (lines[i].trim() !== MANAGED_BLOCK_MARKER) continue
453
+ let j = i + 1
454
+ while (j < lines.length && (lines[j].trim() === '' || lines[j].startsWith('#'))) j++
455
+ if (j < lines.length && /^- insert:\s*$/.test(lines[j])) {
456
+ let end = j + 1
457
+ while (end < lines.length && !/^- /.test(lines[end])) end++
458
+ return { markerIndex: i, blockIndex: j, blockEndIndex: end }
459
+ }
460
+ return { markerIndex: i, blockIndex: -1, blockEndIndex: -1 }
461
+ }
462
+ return null
463
+ }
464
+
465
+ /**
466
+ * Parse every managed entry from patch text: the rows inside this plugin's
467
+ * managed `- insert:` block, plus legacy top-level `@deepseek-ai/dsh-tool-subagent`
468
+ * rows (upsert migrates those into the managed block).
469
+ * @param text - the patch file text.
470
+ * @returns array of { id, config, raw, legacy }.
471
+ */
472
+ export function parseManagedEntries(text) {
473
+ const lines = text.split(/\r?\n/)
474
+ const entries = []
475
+
476
+ const managed = locateManagedBlock(lines)
477
+ if (managed && managed.blockIndex !== -1) {
478
+ const inner = lines.slice(managed.blockIndex + 1, managed.blockEndIndex)
479
+ let start = -1
480
+ for (let i = 0; i < inner.length; i++) {
481
+ if (/^ {4}- /.test(inner[i])) {
482
+ if (start !== -1) pushInner(entries, inner, start, i)
483
+ start = i
484
+ }
485
+ }
486
+ if (start !== -1) pushInner(entries, inner, start, inner.length)
487
+ }
488
+
489
+ for (const block of topLevelBlocks(lines)) {
490
+ if (!isSubagentBlock(lines, block)) continue
491
+ const id = blockId(lines, block)
492
+ if (id === null) continue
493
+ entries.push({
494
+ id,
495
+ config: configFromBlock(lines, block),
496
+ raw: lines.slice(block.index, block.endIndex).join('\n'),
497
+ legacy: true,
498
+ })
499
+ }
500
+ return entries
501
+ }
502
+
503
+ /** Parse one sub-entry inside the managed insert block. */
504
+ function pushInner(entries, inner, start, end) {
505
+ const raw = inner.slice(start, end)
506
+ const idMatch = /^ {4}-\s*id:\s*['"]?([^'"\s]+)['"]?\s*$/.exec(raw[0])
507
+ if (!idMatch) return
508
+ const isOurs = raw.some(line => /^ {6}name:\s*['"]?@deepseek-ai\/dsh-tool-subagent['"]?\s*$/.test(line))
509
+ if (!isOurs) return
510
+ let configStart = -1
511
+ for (let i = 0; i < raw.length; i++) {
512
+ if (/^ {6}config:\s*$/.test(raw[i])) { configStart = i + 1; break }
513
+ }
514
+ const config = configStart === -1 ? {} : parseMapping(raw, configStart, raw.length, 8)
515
+ entries.push({ id: idMatch[1], config, raw: raw.join('\n'), legacy: false })
516
+ }
517
+
518
+ /** Every top-level block id in the patch file, whatever plugin it names. */
519
+ function allBlockIds(lines) {
520
+ return topLevelBlocks(lines)
521
+ .map(block => blockId(lines, block))
522
+ .filter(id => id !== null)
523
+ }
524
+
525
+ /* ========================================================================== */
526
+ /* CLI backend block editor + detection */
527
+ /* ========================================================================== */
528
+
529
+ /**
530
+ * Locate the CLI backend managed `- insert:` block (identified by the CLI
531
+ * marker comment line immediately above it). Same shape as locateManagedBlock.
532
+ * @returns { markerIndex, blockIndex, blockEndIndex } or null when absent.
533
+ */
534
+ function locateCliBlock(lines) {
535
+ for (let i = 0; i < lines.length; i++) {
536
+ if (lines[i].trim() !== CLI_BLOCK_MARKER) continue
537
+ let j = i + 1
538
+ while (j < lines.length && (lines[j].trim() === '' || lines[j].startsWith('#'))) j++
539
+ if (j < lines.length && /^- insert:\s*$/.test(lines[j])) {
540
+ let end = j + 1
541
+ while (end < lines.length && !/^- /.test(lines[end])) end++
542
+ return { markerIndex: i, blockIndex: j, blockEndIndex: end }
543
+ }
544
+ return { markerIndex: i, blockIndex: -1, blockEndIndex: -1 }
545
+ }
546
+ return null
547
+ }
548
+
549
+ /**
550
+ * Parse the CLI backend rows from patch text: rows inside the CLI block whose
551
+ * `name:` names one of the known CLI provider packages.
552
+ * @param text - the patch file text.
553
+ * @returns array of { backendId, packageName, config }.
554
+ */
555
+ export function parseCliBackends(text) {
556
+ const lines = text.split(/\r?\n/)
557
+ const known = new Map(CLI_BACKENDS.map(item => [item.packageName, item]))
558
+ const block = locateCliBlock(lines)
559
+ if (!block || block.blockIndex === -1) return []
560
+ const inner = lines.slice(block.blockIndex + 1, block.blockEndIndex)
561
+ const spans = []
562
+ let start = -1
563
+ for (let i = 0; i < inner.length; i++) {
564
+ if (/^ {4}- /.test(inner[i])) {
565
+ if (start !== -1) spans.push([start, i])
566
+ start = i
567
+ }
568
+ }
569
+ if (start !== -1) spans.push([start, inner.length])
570
+ const rows = []
571
+ for (const [from, to] of spans) {
572
+ const raw = inner.slice(from, to)
573
+ const idMatch = /^ {4}-\s*id:\s*['"]?([^'"\s]+)['"]?\s*$/.exec(raw[0])
574
+ if (!idMatch) continue
575
+ const nameLine = raw.find(line => /^ {6}name:\s/.test(line))
576
+ if (nameLine === undefined) continue
577
+ const packageName = yamlScalar(nameLine.replace(/^ {6}name:\s*/, ''))
578
+ if (!known.has(packageName)) continue
579
+ let configStart = -1
580
+ for (let i = 0; i < raw.length; i++) {
581
+ if (/^ {6}config:\s*$/.test(raw[i])) { configStart = i + 1; break }
582
+ }
583
+ const config = configStart === -1 ? {} : parseMapping(raw, configStart, raw.length, 8)
584
+ rows.push({ backendId: idMatch[1], packageName, config })
585
+ }
586
+ return rows
587
+ }
588
+
589
+ /**
590
+ * Serialize one CLI backend row (indent 4 inside `- insert:`). An empty env is
591
+ * omitted entirely — the provider packages' Config schema defaults it to {}.
592
+ * @param backend - CLI_BACKENDS entry.
593
+ * @param config - validated config (providerName/permissionMode/disposeGraceMs/env).
594
+ * @returns YAML lines (no trailing newline).
595
+ */
596
+ export function serializeCliRow(backend, config, indent = 4) {
597
+ const pad = ' '.repeat(indent)
598
+ const out = []
599
+ out.push(`${pad}- id: ${JSON.stringify(backend.id)}`)
600
+ out.push(`${pad} name: '${backend.packageName}'`)
601
+ out.push(`${pad} config:`)
602
+ out.push(`${pad} providerName: ${JSON.stringify(config.providerName)}`)
603
+ out.push(`${pad} permissionMode: ${JSON.stringify(config.permissionMode)}`)
604
+ out.push(`${pad} disposeGraceMs: ${Number(config.disposeGraceMs)}`)
605
+ const envKeys = Object.keys(config.env || {})
606
+ if (envKeys.length > 0) {
607
+ out.push(`${pad} env:`)
608
+ for (const key of envKeys) out.push(`${pad} ${key}: ${JSON.stringify(String(config.env[key]))}`)
609
+ }
610
+ return out
611
+ }
612
+
613
+ /**
614
+ * Insert or replace one CLI backend row inside the CLI `- insert:` block
615
+ * (creating block + marker at EOF when absent).
616
+ * @returns new lines array.
617
+ */
618
+ export function upsertCliIntoLines(lines, backend, config) {
619
+ const fresh = serializeCliRow(backend, config)
620
+ const block = locateCliBlock(lines)
621
+ if (block === null) {
622
+ const trimmed = [...lines]
623
+ while (trimmed.length > 0 && trimmed[trimmed.length - 1].trim() === '') trimmed.pop()
624
+ return [...trimmed, '', CLI_BLOCK_MARKER, '- insert:', ...fresh]
625
+ }
626
+ if (block.blockIndex === -1) {
627
+ return [...lines.slice(0, block.markerIndex + 1), '- insert:', ...fresh, ...lines.slice(block.markerIndex + 1)]
628
+ }
629
+ const inner = lines.slice(block.blockIndex + 1, block.blockEndIndex)
630
+ for (let i = 0; i < inner.length; i++) {
631
+ if (!/^ {4}- /.test(inner[i])) continue
632
+ const idMatch = /^ {4}-\s*id:\s*['"]?([^'"\s]+)['"]?\s*$/.exec(inner[i])
633
+ if (idMatch && idMatch[1] === backend.id) {
634
+ let innerEnd = inner.length
635
+ for (let j = i + 1; j < inner.length; j++) {
636
+ if (/^ {4}- /.test(inner[j])) { innerEnd = j; break }
637
+ }
638
+ return [
639
+ ...lines.slice(0, block.blockIndex + 1),
640
+ ...inner.slice(0, i),
641
+ ...fresh,
642
+ ...inner.slice(innerEnd),
643
+ ...lines.slice(block.blockEndIndex),
644
+ ]
645
+ }
646
+ }
647
+ return [...lines.slice(0, block.blockEndIndex), ...fresh, ...lines.slice(block.blockEndIndex)]
648
+ }
649
+
650
+ /**
651
+ * Remove one CLI backend row by id; an emptied CLI block is removed together
652
+ * with its marker.
653
+ * @returns { lines, removed } where removed reports whether a row was deleted.
654
+ */
655
+ export function removeCliFromLines(lines, backendId) {
656
+ const block = locateCliBlock(lines)
657
+ if (!block || block.blockIndex === -1) return { lines, removed: false }
658
+ const inner = lines.slice(block.blockIndex + 1, block.blockEndIndex)
659
+ let kept = []
660
+ let removed = false
661
+ let current = null
662
+ const flush = () => {
663
+ if (current === null) return
664
+ const idMatch = /^ {4}-\s*id:\s*['"]?([^'"\s]+)['"]?\s*$/.exec(current[0])
665
+ if (idMatch && idMatch[1] === backendId) removed = true
666
+ else kept = [...kept, ...current]
667
+ current = null
668
+ }
669
+ for (const line of inner) {
670
+ if (/^ {4}- /.test(line)) { flush(); current = [line] }
671
+ else if (current !== null) current = [...current, line]
672
+ }
673
+ flush()
674
+ if (!removed) return { lines, removed }
675
+ let next
676
+ if (kept.length === 0) {
677
+ next = [...lines.slice(0, block.markerIndex), ...lines.slice(block.blockEndIndex)]
678
+ if (next[block.markerIndex] !== undefined && next[block.markerIndex].trim() === ''
679
+ && block.markerIndex > 0 && next[block.markerIndex - 1].trim() === '') {
680
+ next = [...next.slice(0, block.markerIndex), ...next.slice(block.markerIndex + 1)]
681
+ }
682
+ } else {
683
+ next = [...lines.slice(0, block.blockIndex + 1), ...kept, ...lines.slice(block.blockEndIndex)]
684
+ }
685
+ return { lines: next, removed: true }
686
+ }
687
+
688
+ /**
689
+ * Validate one cliUpsert config against the backend's schema. Throws a
690
+ * user-facing Error on the first violated rule.
691
+ * @param backend - CLI_BACKENDS entry.
692
+ * @param config - untrusted config object.
693
+ */
694
+ export function validateCliConfig(backend, config) {
695
+ if (config === null || typeof config !== 'object' || Array.isArray(config)) {
696
+ throw new Error('config 必须是对象')
697
+ }
698
+ const unknownKeys = Object.keys(config).filter(key => !CLI_CONFIG_KEYS.includes(key))
699
+ if (unknownKeys.length > 0) {
700
+ throw new Error(`config 含未知字段:${unknownKeys.join(', ')}(允许:${CLI_CONFIG_KEYS.join(', ')})`)
701
+ }
702
+ if (config.providerName !== undefined
703
+ && (typeof config.providerName !== 'string' || !CLI_PROVIDER_NAME_PATTERN.test(config.providerName))) {
704
+ throw new Error(`providerName 必须是 1-48 位小写字母/数字/下划线/中划线且字母开头:${JSON.stringify(config.providerName ?? null)}`)
705
+ }
706
+ if (config.permissionMode !== undefined && !backend.permissionModes.includes(config.permissionMode)) {
707
+ throw new Error(`permissionMode 只能是:${backend.permissionModes.join(' / ')}(当前:${JSON.stringify(config.permissionMode)})`)
708
+ }
709
+ if (config.disposeGraceMs !== undefined
710
+ && (typeof config.disposeGraceMs !== 'number' || !Number.isFinite(config.disposeGraceMs) || config.disposeGraceMs < 0)) {
711
+ throw new Error(`disposeGraceMs 必须是非负数字(毫秒;当前:${JSON.stringify(config.disposeGraceMs)})`)
712
+ }
713
+ if (config.env !== undefined) {
714
+ if (config.env === null || typeof config.env !== 'object' || Array.isArray(config.env)) {
715
+ throw new Error('env 必须是字符串键值对对象')
716
+ }
717
+ for (const [key, value] of Object.entries(config.env)) {
718
+ if (!CLI_ENV_KEY_PATTERN.test(key)) {
719
+ throw new Error(`env 键名只能是字母/数字/下划线且字母或下划线开头:${JSON.stringify(key)}`)
720
+ }
721
+ if (typeof value !== 'string') {
722
+ throw new Error(`env["${key}"] 必须是字符串`)
723
+ }
724
+ }
725
+ }
726
+ }
727
+
728
+ /** Cached npm global root (lazy, one sync probe): lets the resolver see -g installed packages. */
729
+ let npmGlobalRootCache
730
+ function npmGlobalRoot() {
731
+ if (npmGlobalRootCache !== undefined) return npmGlobalRootCache
732
+ try {
733
+ const root = execFileSync('npm', ['root', '-g'], {
734
+ timeout: 15000,
735
+ shell: process.platform === 'win32',
736
+ encoding: 'utf8',
737
+ }).trim()
738
+ npmGlobalRootCache = root !== '' && existsSync(root) ? root : null
739
+ } catch {
740
+ npmGlobalRootCache = null
741
+ }
742
+ return npmGlobalRootCache
743
+ }
744
+
745
+ /**
746
+ * Build a package resolver for the profile environment: the profile anchor
747
+ * first (walks up into the in-box symlink farm), then this plugin's own module
748
+ * anchor, then the npm global root (so -g installed packages are recognized).
749
+ * Returns { ok, version } — ok:false when unresolvable.
750
+ */
751
+ /** Probe one manifest: packages with strict "exports" may hide ./package.json,
752
+ * so fall back to resolving the main entry and walking up (bounded by
753
+ * node_modules) to locate the manifest file. */
754
+ function resolveManifestPath(make, packageName) {
755
+ try {
756
+ return make().resolve(`${packageName}/package.json`)
757
+ } catch {
758
+ const entry = make().resolve(packageName)
759
+ let dir = dirname(entry)
760
+ while (dir.includes('node_modules')) {
761
+ const candidate = join(dir, 'package.json')
762
+ if (existsSync(candidate)) return candidate
763
+ const parent = dirname(dir)
764
+ if (parent === dir) break
765
+ dir = parent
766
+ }
767
+ throw new Error(`manifest not found for ${packageName}`)
768
+ }
769
+ }
770
+
771
+ function packageResolverFor(profileDir) {
772
+ const anchors = [
773
+ () => createRequire(join(profileDir, 'package.json')),
774
+ () => createRequire(import.meta.url),
775
+ () => {
776
+ const root = npmGlobalRoot()
777
+ return createRequire(join(root || process.cwd(), 'package.json'))
778
+ },
779
+ ]
780
+ return (packageName) => {
781
+ for (const make of anchors) {
782
+ try {
783
+ const manifestPath = resolveManifestPath(make, packageName)
784
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
785
+ return { ok: true, version: typeof manifest.version === 'string' ? manifest.version : null }
786
+ } catch {
787
+ // try the next anchor
788
+ }
789
+ }
790
+ return { ok: false, version: null }
791
+ }
792
+ }
793
+
794
+ /**
795
+ * Probe one CLI on PATH: presence + best-effort `--version`. Only ever called
796
+ * with fixed command names — never user input.
797
+ * @returns Promise<{ ok, version }> — ok:false when absent or broken.
798
+ */
799
+ function probePathCommand(command) {
800
+ return new Promise((resolve) => {
801
+ try {
802
+ execFile(command, ['--version'], { timeout: 5000, shell: process.platform === 'win32' }, (error, stdout) => {
803
+ if (error) {
804
+ resolve({ ok: false, version: null })
805
+ return
806
+ }
807
+ const version = String(stdout || '').trim().split(/\r?\n/)[0] || null
808
+ resolve({ ok: version !== null, version })
809
+ })
810
+ } catch {
811
+ resolve({ ok: false, version: null })
812
+ }
813
+ })
814
+ }
815
+
816
+ /**
817
+ * Detect CLI backend availability for the panel: provider/runner package
818
+ * resolvability, PATH CLI presence + version, current mount state and config,
819
+ * plus a display-only scan of other well-known agent CLIs.
820
+ * @param profileDir - profile directory (patch anchor + package resolution).
821
+ * @param probes - test seam: { resolvePackageRelease, probePathCommand }.
822
+ * @returns { backends, others } for the CLI tab.
823
+ */
824
+ export async function detectCliBackends(profileDir, probes) {
825
+ const resolvePackage = (probes && probes.resolvePackageRelease) || packageResolverFor(profileDir)
826
+ const probeCommand = (probes && probes.probePathCommand) || probePathCommand
827
+ const { lines } = readPatch(profileDir)
828
+ const mounted = new Map(parseCliBackends(lines.join('\n')).map(row => [row.backendId, row.config]))
829
+ const backends = await Promise.all(CLI_BACKENDS.map(async (backend) => {
830
+ const [providerPackage, runner, cli] = await Promise.all([
831
+ resolvePackage(backend.packageName),
832
+ resolvePackage(backend.runnerPackage),
833
+ probeCommand(backend.cliCommand),
834
+ ])
835
+ const config = mounted.get(backend.id)
836
+ return {
837
+ id: backend.id,
838
+ label: backend.label,
839
+ packageName: backend.packageName,
840
+ runnerPackage: backend.runnerPackage,
841
+ cliPackage: backend.cliPackage || null,
842
+ cliCommand: backend.cliCommand,
843
+ permissionModes: backend.permissionModes,
844
+ missing: missingPackagesFor(backend, resolvePackage),
845
+ providerPackage,
846
+ runner,
847
+ cli,
848
+ mounted: config !== undefined,
849
+ config: config !== undefined ? config : backend.defaultConfig,
850
+ }
851
+ }))
852
+ const others = await Promise.all(CLI_SCAN_ONLY.map(async (name) => ({ name, cli: await probeCommand(name) })))
853
+ return { backends, others }
854
+ }
855
+
856
+ /* ========================================================================== */
857
+ /* Generic external-CLI command provider */
858
+ /* ========================================================================== */
859
+
860
+ /** Derive the generic backend id base ('gemini' → 'gemini', 'C:\x\Aider.exe' → 'aider'). */
861
+ function genericIdBase(command) {
862
+ const base = String(command ?? '')
863
+ .replace(/^[A-Za-z]:[\\/]/, '')
864
+ .split(/[\\/]/)
865
+ .pop()
866
+ .toLowerCase()
867
+ .replace(/[^a-z0-9_-]+/g, '-')
868
+ .replace(/^-+|-+$/g, '')
869
+ .slice(0, 40)
870
+ return base !== '' ? base : 'custom'
871
+ }
872
+
873
+ /** Preset non-interactive args for well-known agent CLIs; bare {prompt} otherwise. */
874
+ function genericPresetArgs(command) {
875
+ return CLI_GENERIC_PRESET_ARGS[String(command ?? '').toLowerCase()] || ['{prompt}']
876
+ }
877
+
878
+ /** Simple command names resolve from PATH; absolute paths are allowed; anything else is rejected. */
879
+ function isValidCliCommandValue(command) {
880
+ if (typeof command !== 'string' || command === '' || /\s/.test(command)) return false
881
+ if (/^[A-Za-z]:[\\/]/.test(command) || command.startsWith('/')) return true
882
+ return /^[A-Za-z][A-Za-z0-9._-]*$/.test(command)
883
+ }
884
+
885
+ /**
886
+ * Validate one generic external-CLI backend entry (the shape persisted to
887
+ * subagent-admin.cli.json). Throws a user-facing Error on the first violated
888
+ * rule.
889
+ * @param backend - { id, command, args, providerName, disposeGraceMs, env, cwd? }.
890
+ * @param taken - { takenProviderNames?: Set<string> } names claimed by others.
891
+ */
892
+ export function validateGenericCliBackend(backend, taken) {
893
+ if (backend === null || typeof backend !== 'object' || Array.isArray(backend)) {
894
+ throw new Error('后端配置必须是对象')
895
+ }
896
+ if (typeof backend.id !== 'string' || !new RegExp(`^${CLI_GENERIC_ID_PREFIX}[a-z0-9][a-z0-9_-]{0,47}$`).test(backend.id)) {
897
+ throw new Error(`后端 ID 必须是 "${CLI_GENERIC_ID_PREFIX}" 开头的小写字母/数字/下划线/中划线:${JSON.stringify(backend.id ?? null)}`)
898
+ }
899
+ if (!isValidCliCommandValue(backend.command)) {
900
+ throw new Error(`command 只能是 PATH 上的命令名或绝对路径,且不含空格:${JSON.stringify(backend.command ?? null)}`)
901
+ }
902
+ if (!Array.isArray(backend.args) || backend.args.length === 0 || backend.args.length > CLI_GENERIC_MAX_ARGS
903
+ || !backend.args.every(arg => typeof arg === 'string' && arg.length > 0 && arg.length <= CLI_ARG_MAX_CHARS)) {
904
+ throw new Error(`args 必须是 1-${CLI_GENERIC_MAX_ARGS} 个非空字符串(单条 ≤ ${CLI_ARG_MAX_CHARS} 字符),用 {prompt} 占位提示词`)
905
+ }
906
+ if (!backend.args.includes('{prompt}')) {
907
+ throw new Error('args 必须包含 {prompt} 占位符(提示词将替换该占位符传入 CLI)')
908
+ }
909
+ if (typeof backend.providerName !== 'string' || !CLI_PROVIDER_NAME_PATTERN.test(backend.providerName)) {
910
+ throw new Error(`providerName 必须是 1-48 位小写字母/数字/下划线/中划线且字母开头:${JSON.stringify(backend.providerName ?? null)}`)
911
+ }
912
+ if (CLI_GENERIC_RESERVED_PROVIDER_NAMES.has(backend.providerName)) {
913
+ throw new Error(`providerName "${backend.providerName}" 是保留名(内置后端已占用)`)
914
+ }
915
+ if ((taken && taken.takenProviderNames && taken.takenProviderNames.has(backend.providerName))) {
916
+ throw new Error(`providerName "${backend.providerName}" 已被其他后端占用`)
917
+ }
918
+ if (backend.disposeGraceMs !== undefined
919
+ && (typeof backend.disposeGraceMs !== 'number' || !Number.isFinite(backend.disposeGraceMs) || backend.disposeGraceMs < 0)) {
920
+ throw new Error(`disposeGraceMs 必须是非负数字(毫秒;当前:${JSON.stringify(backend.disposeGraceMs)})`)
921
+ }
922
+ if (backend.cwd !== undefined && (typeof backend.cwd !== 'string' || backend.cwd === '')) {
923
+ throw new Error('cwd 必须是非空字符串(绝对路径)')
924
+ }
925
+ if (backend.env !== undefined) {
926
+ if (backend.env === null || typeof backend.env !== 'object' || Array.isArray(backend.env)) {
927
+ throw new Error('env 必须是字符串键值对对象')
928
+ }
929
+ for (const [key, value] of Object.entries(backend.env)) {
930
+ if (!CLI_ENV_KEY_PATTERN.test(key)) {
931
+ throw new Error(`env 键名只能是字母/数字/下划线且字母或下划线开头:${JSON.stringify(key)}`)
932
+ }
933
+ if (typeof value !== 'string') {
934
+ throw new Error(`env["${key}"] 必须是字符串`)
935
+ }
936
+ }
937
+ }
938
+ }
939
+
940
+ /** Collect the text of one 'collect'-mode stdout/stderr output collector. */
941
+ function readCollectText(collector) {
942
+ if (!collector || typeof collector.readFrom !== 'function') return ''
943
+ try {
944
+ const chunk = collector.readFrom(0)
945
+ return typeof chunk?.text === 'string' ? chunk.text : ''
946
+ } catch {
947
+ return ''
948
+ }
949
+ }
950
+
951
+ function textBlocksFrom(text) {
952
+ const trimmed = String(text ?? '').trim()
953
+ return trimmed === '' ? [] : [{ type: 'text', text: trimmed }]
954
+ }
955
+
956
+ function limitCliDiagnostic(text) {
957
+ return String(text ?? '').trim().slice(0, CLI_DIAGNOSTIC_MAX_CHARS)
958
+ }
959
+
960
+ /**
961
+ * Build a one-shot external-CLI subagent provider: prompt text replaces the
962
+ * {prompt} placeholder in the argv template, stdout becomes the delegation
963
+ * result, a non-zero exit (or spawn failure) maps to stopReason 'error' with a
964
+ * stderr-tail diagnostic. All start capabilities are off — one-shot, plain
965
+ * text in/out, no persona/toolFilter/depth/outputSchema support.
966
+ * @param subprocess - the subprocess service (ctx.subprocess).
967
+ * @param spec - { providerName, command, args, disposeGraceMs, env?, cwd? }.
968
+ */
969
+ export function createCliCommandProvider(subprocess, spec) {
970
+ const argvTemplate = [spec.command, ...spec.args]
971
+ const graceMs = typeof spec.disposeGraceMs === 'number' && Number.isFinite(spec.disposeGraceMs) && spec.disposeGraceMs >= 0
972
+ ? spec.disposeGraceMs
973
+ : 3000
974
+ const extraEnv = spec.env && Object.keys(spec.env).length > 0 ? spec.env : undefined
975
+ return {
976
+ name: spec.providerName,
977
+ capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
978
+ inheritsParentContext: false,
979
+ start(request) {
980
+ const promptText = (request.prompt || [])
981
+ .filter(block => block && block.type === 'text' && typeof block.text === 'string')
982
+ .map(block => block.text)
983
+ .join('\n')
984
+ const argv = argvTemplate.map(part => (part === '{prompt}' ? promptText : part))
985
+ let handle = null
986
+ const result = new Promise((resolve) => {
987
+ try {
988
+ handle = subprocess.spawn({
989
+ argv,
990
+ cwd: typeof spec.cwd === 'string' && spec.cwd !== '' ? spec.cwd : process.cwd(),
991
+ stdio: { stdin: 'ignore', stdout: 'collect', stderr: 'collect' },
992
+ graceMs,
993
+ signal: request.signal,
994
+ ...(extraEnv !== undefined ? { env: extraEnv } : {}),
995
+ })
996
+ } catch (error) {
997
+ resolve({
998
+ output: [],
999
+ diagnostic: limitCliDiagnostic(`CLI 启动失败:${String((error && error.message) || error)}`),
1000
+ stopReason: 'error',
1001
+ })
1002
+ return
1003
+ }
1004
+ handle.done.then((outcome) => {
1005
+ if (request.signal.aborted) {
1006
+ resolve({ output: textBlocksFrom(readCollectText(handle.collected?.stdout)), stopReason: 'aborted' })
1007
+ return
1008
+ }
1009
+ const stdoutText = readCollectText(handle.collected?.stdout)
1010
+ const stderrText = readCollectText(handle.collected?.stderr)
1011
+ const exitCode = outcome && typeof outcome.exitCode === 'number' ? outcome.exitCode : null
1012
+ if (exitCode === 0) {
1013
+ resolve({ output: textBlocksFrom(stdoutText), stopReason: 'completed' })
1014
+ return
1015
+ }
1016
+ const reason = exitCode === null
1017
+ ? `进程被信号终止(${String(outcome && outcome.signal)})`
1018
+ : `退出码 ${exitCode}`
1019
+ resolve({
1020
+ output: textBlocksFrom(stdoutText),
1021
+ diagnostic: limitCliDiagnostic(stderrText !== '' ? stderrText : reason),
1022
+ stopReason: 'error',
1023
+ })
1024
+ }).catch((error) => {
1025
+ resolve({ output: [], diagnostic: limitCliDiagnostic(String((error && error.message) || error)), stopReason: 'error' })
1026
+ })
1027
+ })
1028
+ return Promise.resolve({
1029
+ id: `${CLI_GENERIC_ID_PREFIX}${randomUUID()}`,
1030
+ localAgent: undefined,
1031
+ result,
1032
+ dispose: async () => {
1033
+ if (handle) {
1034
+ try { await handle.terminate() } catch { /* process already exiting */ }
1035
+ }
1036
+ },
1037
+ })
1038
+ },
1039
+ }
1040
+ }
1041
+
1042
+ /** Packages of one builtin CLI backend that are not resolvable in this deployment. */
1043
+ export function missingPackagesFor(backend, resolvePackage) {
1044
+ return [backend.packageName, backend.runnerPackage, backend.cliPackage]
1045
+ .filter(name => name !== undefined)
1046
+ .filter(name => !resolvePackage(name).ok)
1047
+ }
1048
+
1049
+ /**
1050
+ * Build an installer that runs `npm install -g <packages>` (global root, so
1051
+ * the CLI binaries land on PATH and the packages stay off the profile's
1052
+ * package.json). Long-running: 5 min timeout, output tail kept.
1053
+ */
1054
+ function createNpmInstaller() {
1055
+ return (packages) => new Promise((resolve) => {
1056
+ // --allow-scripts=<pkg> per package: npm 11 blocks global install scripts
1057
+ // by default, and claude-code's postinstall places its native binary.
1058
+ const allowFlags = packages.map(pkg => `--allow-scripts=${pkg}`)
1059
+ execFile('npm', ['install', '--global', '--legacy-peer-deps', '--no-audit', '--no-fund', ...allowFlags, ...packages], {
1060
+ timeout: 300000,
1061
+ shell: process.platform === 'win32',
1062
+ maxBuffer: 4 * 1024 * 1024,
1063
+ }, (error, stdout, stderr) => {
1064
+ if (error) {
1065
+ resolve({ ok: false, output: ('npm install -g 失败:' + String(stderr || stdout || (error && error.message) || error)).slice(-2000) })
1066
+ return
1067
+ }
1068
+ resolve({ ok: true, output: 'npm install -g ' + packages.join(' ') + ' 完成' })
1069
+ })
1070
+ })
1071
+ }
1072
+
1073
+ /* ========================================================================== */
1074
+ /* Input validation */
1075
+ /* ========================================================================== */
1076
+
1077
+ /**
1078
+ * Validate one upsert payload against the entry shape, the live provider
1079
+ * registry, and the candidate tool names. Throws a user-facing Error on the
1080
+ * first violated rule; returns the warnings that do not block a save.
1081
+ * @param entry - { id, config } RPC payload (untrusted).
1082
+ * @param env - { providers: Map<string, provider>, knownTools: Set<string>, runtimeTools: Set<string>, seedTools: Set<string>, existing: Map<id, config>, allIds: Set<string> }.
1083
+ * @returns string[] of non-blocking warnings (rendered by the panel).
1084
+ */
1085
+ export function validateEntryInput(entry, env) {
1086
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
1087
+ throw new Error('条目必须是对象')
1088
+ }
1089
+ const { id, config } = entry
1090
+ if (typeof id !== 'string' || !ID_PATTERN.test(id)) {
1091
+ throw new Error(`实例 ID 只能包含字母、数字、下划线和中划线(字母或数字开头,最长 64 位):${JSON.stringify(id ?? null)}`)
1092
+ }
1093
+ if (!env.existing.has(id) && env.allIds.has(id)) {
1094
+ throw new Error(`行 id "${id}" 已被补丁文件中其他插件实例占用;每个补丁行的 id 必须唯一,请换一个 ID`)
1095
+ }
1096
+ if (config === null || typeof config !== 'object' || Array.isArray(config)) {
1097
+ throw new Error('config 必须是对象')
1098
+ }
1099
+ const unknownKeys = Object.keys(config).filter(key => !CONFIG_KEYS.includes(key))
1100
+ if (unknownKeys.length > 0) {
1101
+ throw new Error(`config 含未知字段:${unknownKeys.join(', ')}(允许:${CONFIG_KEYS.join(', ')})`)
1102
+ }
1103
+
1104
+ const providerName = config.provider
1105
+ if (typeof providerName !== 'string' || providerName.trim() === '') {
1106
+ throw new Error('provider(执行后端)必填,例如 "spawn"')
1107
+ }
1108
+ const provider = env.providers.get(providerName)
1109
+ if (provider === undefined) {
1110
+ throw new Error(`未知的执行后端 "${providerName}";当前已注册:${[...env.providers.keys()].join(', ') || '(无)'}`)
1111
+ }
1112
+
1113
+ const toolName = config.toolName
1114
+ if (typeof toolName !== 'string' || !TOOLNAME_PATTERN.test(toolName)) {
1115
+ throw new Error(`子智能体名称(模型可见的工具名)必须是 2-48 位小写字母/数字/下划线且字母开头:${JSON.stringify(toolName ?? null)}`)
1116
+ }
1117
+ if (RESERVED_TOOL_NAMES.includes(toolName)) {
1118
+ throw new Error(`子智能体名称 "${toolName}" 是保留名(内置预设已占用),请换一个名字`)
1119
+ }
1120
+ for (const [otherId, otherConfig] of env.existing) {
1121
+ if (otherId !== id && otherConfig?.toolName === toolName) {
1122
+ throw new Error(`子智能体名称 "${toolName}" 已被实例 "${otherId}" 使用;每个实例的名称必须唯一`)
1123
+ }
1124
+ }
1125
+
1126
+ if (config.persona !== undefined) {
1127
+ if (typeof config.persona !== 'string') throw new Error('persona(提示词)必须是字符串')
1128
+ if (config.persona.length > PERSONA_MAX_CHARS) {
1129
+ throw new Error(`persona(提示词)超过 ${PERSONA_MAX_CHARS} 字符上限(当前 ${config.persona.length})`)
1130
+ }
1131
+ if (provider.capabilities.persona === false) {
1132
+ throw new Error(`执行后端 "${providerName}" 不支持 persona(提示词);请改用 spawn 等支持该能力的后端`)
1133
+ }
1134
+ }
1135
+
1136
+ if (config.toolFilter !== undefined) {
1137
+ if (config.toolFilter === null || typeof config.toolFilter !== 'object' || Array.isArray(config.toolFilter)) {
1138
+ throw new Error('toolFilter 必须是 { allow?, deny? } 对象')
1139
+ }
1140
+ const unknownFilterKeys = Object.keys(config.toolFilter).filter(key => key !== 'allow' && key !== 'deny')
1141
+ if (unknownFilterKeys.length > 0) {
1142
+ throw new Error(`toolFilter 含未知字段:${unknownFilterKeys.join(', ')}(允许:allow, deny)`)
1143
+ }
1144
+ const allow = config.toolFilter.allow
1145
+ const deny = config.toolFilter.deny
1146
+ if (allow === undefined && deny === undefined) {
1147
+ throw new Error('toolFilter 不能为空对象:填写 allow 和/或 deny,或直接去掉工具约束')
1148
+ }
1149
+ for (const listName of ['allow', 'deny']) {
1150
+ const list = config.toolFilter[listName]
1151
+ if (list === undefined) continue
1152
+ if (!Array.isArray(list) || list.some(name => typeof name !== 'string')) {
1153
+ throw new Error(`toolFilter.${listName} 必须是字符串数组`)
1154
+ }
1155
+ for (const name of list) {
1156
+ if (!TOOL_REF_PATTERN.test(name)) {
1157
+ throw new Error(`toolFilter.${listName} 中的 "${name}" 不是合法工具名(小写字母/数字/下划线)`)
1158
+ }
1159
+ if (name === 'run_code') {
1160
+ throw new Error('toolFilter 不能约束保留的 Code Mode 传输工具 "run_code"')
1161
+ }
1162
+ if (!env.knownTools.has(name)) {
1163
+ throw new Error(`未知工具 "${name}"(toolFilter.${listName});可选项来自 harness 已注册工具与内置工具名录:${[...env.knownTools].slice(0, 12).join(', ')}…`)
1164
+ }
1165
+ }
1166
+ }
1167
+ const overlap = (allow ?? []).filter(name => (deny ?? []).includes(name))
1168
+ if (overlap.length > 0) {
1169
+ throw new Error(`工具 ${overlap.map(name => `"${name}"`).join(', ')} 同时出现在 allow 与 deny 中;一个名字只能属于一边`)
1170
+ }
1171
+ if (provider.capabilities.toolFilter === false) {
1172
+ throw new Error(`执行后端 "${providerName}" 不支持 toolFilter(工具约束);请改用 spawn 等支持该能力的后端`)
1173
+ }
1174
+ }
1175
+
1176
+ if (config.agentOptions !== undefined) {
1177
+ const options = config.agentOptions
1178
+ if (options === null || typeof options !== 'object' || Array.isArray(options)) {
1179
+ throw new Error('agentOptions 必须是 { provider?, model?, maxTokens? } 对象')
1180
+ }
1181
+ const unknownOptionKeys = Object.keys(options).filter(key => key !== 'provider' && key !== 'model' && key !== 'maxTokens')
1182
+ if (unknownOptionKeys.length > 0) {
1183
+ throw new Error(`agentOptions 含未知字段:${unknownOptionKeys.join(', ')}(允许:provider, model, maxTokens)`)
1184
+ }
1185
+ if (options.provider !== undefined && (typeof options.provider !== 'string' || options.provider.trim() === '')) {
1186
+ throw new Error('agentOptions.provider 留空表示继承父代理,填写时必须是非空字符串')
1187
+ }
1188
+ if (options.model !== undefined && (typeof options.model !== 'string' || options.model.trim() === '' || options.model.length > MODEL_MAX_CHARS)) {
1189
+ throw new Error(`agentOptions.model 留空表示继承父代理,填写时必须是 1-${MODEL_MAX_CHARS} 字符的模型标识`)
1190
+ }
1191
+ if (options.maxTokens !== undefined
1192
+ && (!Number.isInteger(options.maxTokens) || options.maxTokens < 1 || options.maxTokens > MAX_TOKENS_MAX)) {
1193
+ throw new Error(`agentOptions.maxTokens 必须是 1-${MAX_TOKENS_MAX} 的整数`)
1194
+ }
1195
+ }
1196
+
1197
+ if (config.maxDepth !== undefined) {
1198
+ if (config.maxDepth === 'provider-managed') {
1199
+ // Any provider may own its own recursion budget.
1200
+ } else if (!Number.isInteger(config.maxDepth) || config.maxDepth < 0 || config.maxDepth > Number.MAX_SAFE_INTEGER) {
1201
+ throw new Error('maxDepth 必须是非负整数或字符串 "provider-managed"')
1202
+ } else if (provider.capabilities.depthLimit === false) {
1203
+ throw new Error(`执行后端 "${providerName}" 无法执行数值 maxDepth(缺 depthLimit 能力);请改用 "provider-managed" 或换后端`)
1204
+ }
1205
+ }
1206
+
1207
+ if (config.backgroundMode !== undefined) {
1208
+ if (config.backgroundMode !== 'one-shot' && config.backgroundMode !== 'continuable') {
1209
+ throw new Error(`backgroundMode 只能是 "one-shot" 或 "continuable"(当前:${JSON.stringify(config.backgroundMode)})`)
1210
+ }
1211
+ if (config.backgroundMode === 'continuable' && provider.continuable !== true && typeof provider.prepareContinuable !== 'function') {
1212
+ throw new Error(`执行后端 "${providerName}" 不支持后台可持续会话(缺 prepareContinuable 能力);请改用 one-shot 或换后端`)
1213
+ }
1214
+ }
1215
+
1216
+ if (config.enableRunInBackground !== undefined && typeof config.enableRunInBackground !== 'boolean') {
1217
+ throw new Error('enableRunInBackground 必须是布尔值')
1218
+ }
1219
+
1220
+ const warnings = []
1221
+ for (const name of [...(config.toolFilter?.allow ?? []), ...(config.toolFilter?.deny ?? [])]) {
1222
+ if (!env.runtimeTools.has(name) && env.seedTools.has(name)) {
1223
+ warnings.push(`工具 "${name}" 来自内置工具名录但当前进程未注册(通常由预设按会话挂载,子智能体运行时经祖先链可见)`)
1224
+ }
1225
+ }
1226
+ return warnings
1227
+ }
1228
+
1229
+ /* ========================================================================== */
1230
+ /* Plugin apply */
1231
+ /* ========================================================================== */
1232
+
1233
+ /** Build a serial read-modify-write queue (same semantics as the host half). */
1234
+ function makeSerialQueue() {
1235
+ let tail = Promise.resolve()
1236
+ return (operation) => {
1237
+ const run = tail.then(operation, operation)
1238
+ tail = run.catch(() => { })
1239
+ return run
1240
+ }
1241
+ }
1242
+
1243
+ /**
1244
+ * Resolve the profile directory from the config-tree anchor (same rule as the
1245
+ * admin plugin): the loader's baseUrl anchors the profile's package.json.
1246
+ */
1247
+ function profileDirOf(baseUrl) {
1248
+ const anchor = typeof baseUrl === 'string' && baseUrl.startsWith('file:')
1249
+ ? fileURLToPath(baseUrl)
1250
+ : String(baseUrl)
1251
+ if (existsSync(join(anchor, 'package.json'))) return anchor
1252
+ const parent = dirname(anchor)
1253
+ if (existsSync(join(parent, 'package.json'))) return parent
1254
+ throw new Error(`plugin-admin/subagents: no profile package.json beside config anchor ${String(baseUrl)}`)
1255
+ }
1256
+
1257
+ /**
1258
+ * Mount the `subagentAdmin` remote: provide the service, reconcile generic
1259
+ * CLI providers, and return the invocation descriptors for the caller to
1260
+ * register (the typert registry allows ONE registration per package name, so
1261
+ * the unified dsh-plugin-admin descriptor must carry these).
1262
+ * @param ctx - plugin context carrying typert, tools, subagents.
1263
+ * @param enqueueShared - the host admin's serial queue for patch writes.
1264
+ * @returns the `subagentAdmin` invocation descriptor array.
1265
+ */
1266
+ export function applySubagentAdmin(ctx, enqueueShared) {
1267
+ const profileDir = profileDirOf(ctx.baseUrl)
1268
+ const patchPath = join(profileDir, PROFILE_PATCH_FILENAME)
1269
+ const historyPath = join(profileDir, HISTORY_FILENAME)
1270
+ const backupPath = patchPath + BACKUP_SUFFIX
1271
+
1272
+ // Serial queue for read-modify-write cycles. The admin host passes its own
1273
+ // queue: mcpAdmin and subagentAdmin both edit the profile cordis.patch.yml,
1274
+ // so patch writes across the two remotes must serialize against each other.
1275
+ const enqueue = enqueueShared ?? makeSerialQueue()
1276
+
1277
+ /** Atomic write: temp file + rename, so a crash never truncates the patch. */
1278
+ const atomicWrite = (path, text) => {
1279
+ const tmp = path + TMP_SUFFIX
1280
+ writeFileSync(tmp, text, 'utf8')
1281
+ renameSync(tmp, path)
1282
+ }
1283
+
1284
+ /** One-time original backup so the first automated edit is reversible. */
1285
+ const ensureBackup = () => {
1286
+ if (existsSync(patchPath) && !existsSync(backupPath)) {
1287
+ copyFileSync(patchPath, backupPath)
1288
+ }
1289
+ }
1290
+
1291
+ /** Append one journal record; rotate the file when it grows past budget. */
1292
+ const appendJournal = (record) => {
1293
+ try {
1294
+ if (existsSync(historyPath)) {
1295
+ const size = statSync(historyPath).size
1296
+ if (size > JOURNAL_ROTATE_BYTES) {
1297
+ const lines = readFileSync(historyPath, 'utf8').split(/\r?\n/).filter(line => line.trim() !== '')
1298
+ const keep = lines.slice(-JOURNAL_KEEP_LINES)
1299
+ atomicWrite(historyPath, `${keep.join('\n')}\n`)
1300
+ }
1301
+ }
1302
+ appendFileSync(historyPath, `${JSON.stringify(record)}\n`, 'utf8')
1303
+ } catch (error) {
1304
+ // The journal is an audit convenience; a failed append must never fail
1305
+ // the mutation it records. Surface it in the panel via the returned
1306
+ // warning channel instead.
1307
+ ctx.logger.warn?.(`plugin-admin/subagents: journal append failed: ${String(error)}`)
1308
+ }
1309
+ }
1310
+
1311
+ /** Candidate tool names: live global registry ∪ shipped seed. */
1312
+ const collectKnownTools = () => {
1313
+ const runtimeTools = new Set()
1314
+ try {
1315
+ for (const schema of ctx.tools.schemas()) {
1316
+ if (schema && typeof schema.name === 'string') runtimeTools.add(schema.name)
1317
+ }
1318
+ } catch (error) {
1319
+ ctx.logger.warn?.(`plugin-admin/subagents: tools.schemas() unavailable: ${String(error)}`)
1320
+ }
1321
+ const seedTools = new Set(Object.keys(TOOL_SEED))
1322
+ const knownTools = new Set([...runtimeTools, ...seedTools])
1323
+ return { runtimeTools, seedTools, knownTools }
1324
+ }
1325
+
1326
+ /** Live provider table with capability detail for the panel. */
1327
+ const collectProviders = () => {
1328
+ const providers = new Map()
1329
+ let names = []
1330
+ try {
1331
+ names = ctx.subagents.list()
1332
+ } catch (error) {
1333
+ ctx.logger.warn?.(`plugin-admin/subagents: subagents.list() unavailable: ${String(error)}`)
1334
+ return providers
1335
+ }
1336
+ for (const name of names) {
1337
+ try {
1338
+ const provider = ctx.subagents.getProvider(name)
1339
+ if (provider === undefined) continue
1340
+ providers.set(name, {
1341
+ name,
1342
+ capabilities: {
1343
+ outputSchema: provider.capabilities?.outputSchema === true,
1344
+ depthLimit: provider.capabilities?.depthLimit === true,
1345
+ toolFilter: provider.capabilities?.toolFilter === true,
1346
+ persona: provider.capabilities?.persona === true,
1347
+ },
1348
+ continuable: typeof provider.prepareContinuable === 'function',
1349
+ inheritsParentContext: provider.inheritsParentContext === true,
1350
+ })
1351
+ } catch {
1352
+ // A provider half torn down mid-listing: skip it this round.
1353
+ }
1354
+ }
1355
+ return providers
1356
+ }
1357
+
1358
+ /** Resolves npm packages against the profile env, for CLI backend detection. */
1359
+ const resolvePackage = packageResolverFor(profileDir)
1360
+
1361
+ /** Installs missing provider/runner packages globally (npm install -g). */
1362
+ const npmInstall = createNpmInstaller()
1363
+
1364
+ /* ── Generic external-CLI backends: plugin-owned JSON config + live registration.
1365
+ * These intentionally do NOT ride cordis.patch.yml rows: patch rows are
1366
+ * instantiated as bundles, and this plugin is already mounted exactly once. */
1367
+ const cliConfigPath = join(profileDir, CLI_GENERIC_CONFIG_FILENAME)
1368
+ const genericUnregisterMap = new Map()
1369
+ const readGenericCliBackends = () => {
1370
+ if (!existsSync(cliConfigPath)) return []
1371
+ try {
1372
+ const parsed = JSON.parse(readFileSync(cliConfigPath, 'utf8'))
1373
+ if (!parsed || !Array.isArray(parsed.backends)) return []
1374
+ return parsed.backends.filter(item => item !== null && typeof item === 'object' && typeof item.id === 'string')
1375
+ } catch (error) {
1376
+ ctx.logger.warn?.(`plugin-admin/subagents: unreadable ${CLI_GENERIC_CONFIG_FILENAME}: ${String(error)}`)
1377
+ return []
1378
+ }
1379
+ }
1380
+ const writeGenericCliBackends = (backends) => {
1381
+ atomicWrite(cliConfigPath, `${JSON.stringify({ backends }, null, 2)}\n`)
1382
+ }
1383
+ const subprocessService = typeof ctx.get === 'function' ? ctx.get('subprocess') : ctx.subprocess
1384
+ const subprocessReady = !!subprocessService && typeof subprocessService.spawn === 'function'
1385
+ const ensureGenericProvider = (backend, { throwWhenUnavailable = false } = {}) => {
1386
+ if (genericUnregisterMap.has(backend.id)) return true
1387
+ if (!subprocessReady) {
1388
+ const message = 'subprocess 服务不可用,无法挂载通用 CLI 后端'
1389
+ if (throwWhenUnavailable) throw new Error(message)
1390
+ ctx.logger.warn?.(`plugin-admin/subagents: ${message}; skipped "${backend.id}"`)
1391
+ return false
1392
+ }
1393
+ const provider = createCliCommandProvider(subprocessService, backend)
1394
+ genericUnregisterMap.set(backend.id, ctx.subagents.registerProvider(provider))
1395
+ return true
1396
+ }
1397
+ const dropGenericProvider = (id) => {
1398
+ const unregister = genericUnregisterMap.get(id)
1399
+ if (unregister === undefined) return
1400
+ try { unregister() } catch { /* provider already gone */ }
1401
+ genericUnregisterMap.delete(id)
1402
+ }
1403
+ const reconcileGenericProviders = () => {
1404
+ const wanted = readGenericCliBackends()
1405
+ for (const id of [...genericUnregisterMap.keys()]) {
1406
+ if (!wanted.some(item => item.id === id)) dropGenericProvider(id)
1407
+ }
1408
+ for (const backend of wanted) ensureGenericProvider(backend)
1409
+ }
1410
+
1411
+ const cliListInner = async () => {
1412
+ const detected = await detectCliBackends(profileDir)
1413
+ let registeredNames = new Set()
1414
+ try { registeredNames = new Set(ctx.subagents.list()) } catch { /* leave empty */ }
1415
+ const generic = await Promise.all(readGenericCliBackends().map(async (backend) => ({
1416
+ kind: 'generic',
1417
+ id: backend.id,
1418
+ command: backend.command,
1419
+ args: backend.args,
1420
+ providerName: backend.providerName,
1421
+ disposeGraceMs: backend.disposeGraceMs,
1422
+ env: backend.env || {},
1423
+ mounted: true,
1424
+ providerPresent: registeredNames.has(backend.providerName),
1425
+ cli: await probePathCommand(backend.command),
1426
+ })))
1427
+ return { backends: [...detected.backends, ...generic], others: detected.others }
1428
+ }
1429
+
1430
+ const cliUpsertGeneric = async (body) => {
1431
+ const rawConfig = body.config !== null && typeof body.config === 'object' && !Array.isArray(body.config) ? body.config : {}
1432
+ if (typeof rawConfig.command !== 'string' || rawConfig.command.trim() === '') {
1433
+ throw new Error('command 必填(PATH 上的命令名或绝对路径)')
1434
+ }
1435
+ const command = rawConfig.command.trim()
1436
+ const providedId = typeof body.backendId === 'string' && body.backendId.trim() !== '' ? body.backendId.trim() : undefined
1437
+ const id = providedId !== undefined ? providedId : CLI_GENERIC_ID_PREFIX + genericIdBase(command)
1438
+ const existing = readGenericCliBackends()
1439
+ const previous = existing.find(item => item.id === id)
1440
+ const effective = {
1441
+ id,
1442
+ command,
1443
+ args: Array.isArray(rawConfig.args) && rawConfig.args.length > 0 ? rawConfig.args : genericPresetArgs(command),
1444
+ providerName: typeof rawConfig.providerName === 'string' && rawConfig.providerName.trim() !== ''
1445
+ ? rawConfig.providerName.trim()
1446
+ : CLI_GENERIC_ID_PREFIX + genericIdBase(command),
1447
+ disposeGraceMs: rawConfig.disposeGraceMs !== undefined ? rawConfig.disposeGraceMs : 3000,
1448
+ env: rawConfig.env !== undefined ? rawConfig.env : {},
1449
+ }
1450
+ if (rawConfig.cwd !== undefined) effective.cwd = rawConfig.cwd
1451
+ const taken = new Set(existing.filter(item => item.id !== id).map(item => item.providerName))
1452
+ validateGenericCliBackend(effective, { takenProviderNames: taken })
1453
+ let liveNames = []
1454
+ try { liveNames = ctx.subagents.list() } catch { /* leave empty */ }
1455
+ const claimedByThisMount = previous !== undefined && previous.providerName === effective.providerName
1456
+ && genericUnregisterMap.has(id)
1457
+ if (!claimedByThisMount && liveNames.includes(effective.providerName)) {
1458
+ throw new Error(`providerName "${effective.providerName}" 已在运行中注册,请换一个名字`)
1459
+ }
1460
+ if (previous !== undefined && previous.providerName !== effective.providerName) {
1461
+ const referencing = parseManagedEntries(readPatch(profileDir).lines.join('\n'))
1462
+ .filter(item => item.config && item.config.provider === previous.providerName)
1463
+ .map(item => item.id)
1464
+ if (referencing.length > 0) {
1465
+ throw new Error(`providerName "${previous.providerName}" 仍被子智能体实例引用(${referencing.join(', ')});请先改这些实例的执行后端再改名`)
1466
+ }
1467
+ }
1468
+ await enqueue(() => {
1469
+ const current = readGenericCliBackends()
1470
+ const at = current.findIndex(item => item.id === id)
1471
+ if (at === -1) current.push(effective)
1472
+ else current[at] = effective
1473
+ writeGenericCliBackends(current)
1474
+ appendJournal({
1475
+ at: new Date().toISOString(),
1476
+ action: at === -1 ? 'mount' : 'cli-update',
1477
+ id,
1478
+ entry: { kind: 'generic', backend: effective },
1479
+ })
1480
+ })
1481
+ ensureGenericProvider(effective, { throwWhenUnavailable: true })
1482
+ return { ok: true, ...(await cliListInner()) }
1483
+ }
1484
+
1485
+ const cliRemoveGeneric = async (backendId) => {
1486
+ await enqueue(() => {
1487
+ const current = readGenericCliBackends()
1488
+ const previous = current.find(item => item.id === backendId)
1489
+ if (previous === undefined) {
1490
+ throw new Error(`通用 CLI 后端 "${String(backendId)}" 未挂载`)
1491
+ }
1492
+ const referencing = parseManagedEntries(readPatch(profileDir).lines.join('\n'))
1493
+ .filter(item => item.config && item.config.provider === previous.providerName)
1494
+ .map(item => item.id)
1495
+ if (referencing.length > 0) {
1496
+ throw new Error(`仍有子智能体实例在使用后端 "${previous.providerName}"(${referencing.join(', ')});请先删除或改配这些实例再卸载`)
1497
+ }
1498
+ writeGenericCliBackends(current.filter(item => item.id !== backendId))
1499
+ appendJournal({ at: new Date().toISOString(), action: 'unmount', id: backendId, entry: { kind: 'generic' } })
1500
+ })
1501
+ dropGenericProvider(backendId)
1502
+ return { ok: true, ...(await cliListInner()) }
1503
+ }
1504
+
1505
+ const listEntries = async () => {
1506
+ const { lines } = readPatch(profileDir)
1507
+ const providers = collectProviders()
1508
+ const { runtimeTools, seedTools } = collectKnownTools()
1509
+ const entries = parseManagedEntries(lines.join('\n')).map((entry) => {
1510
+ let registered = false
1511
+ try {
1512
+ registered = entry.config?.toolName !== undefined
1513
+ && ctx.tools.get(entry.config.toolName) !== undefined
1514
+ } catch {
1515
+ registered = false
1516
+ }
1517
+ const providerName = entry.config?.provider
1518
+ return {
1519
+ id: entry.id,
1520
+ config: entry.config,
1521
+ raw: entry.raw,
1522
+ live: {
1523
+ toolRegistered: registered,
1524
+ providerPresent: providerName !== undefined && providers.has(providerName),
1525
+ },
1526
+ }
1527
+ })
1528
+ const tools = [...new Set([...runtimeTools, ...seedTools])]
1529
+ .sort()
1530
+ .map((name) => ({
1531
+ name,
1532
+ source: runtimeTools.has(name) && seedTools.has(name) ? 'runtime+seed' : runtimeTools.has(name) ? 'runtime' : 'seed',
1533
+ }))
1534
+
1535
+ // Configured LLM catalog (best-effort): powers the model/provider
1536
+ // dropdowns in the panel. Degrades to empty lists when the `llm` service
1537
+ // is absent, so the free-text fallback still works.
1538
+ const llmProviders = []
1539
+ const llmModels = {}
1540
+ const llm = typeof ctx.get === 'function' ? ctx.get('llm') : undefined
1541
+ if (llm && typeof llm.listProviders === 'function') {
1542
+ try {
1543
+ const providerInfos = llm.listProviders() || []
1544
+ for (const p of providerInfos) {
1545
+ const id = p.id || p.provider
1546
+ if (!id) continue
1547
+ llmProviders.push({ id, name: p.name || id })
1548
+ try {
1549
+ const models = (await llm.listModels(id)) || []
1550
+ llmModels[id] = models.map((m) => ({ id: m.id, name: m.name || m.id }))
1551
+ } catch {
1552
+ llmModels[id] = []
1553
+ }
1554
+ }
1555
+ } catch {
1556
+ // leave catalogs empty
1557
+ }
1558
+ }
1559
+
1560
+ return { profileDir, patchPath, entries, meta: { tools, providers: [...providers.values()], llmProviders, llmModels } }
1561
+ }
1562
+
1563
+ const service = {
1564
+ /** Panel bootstrap payload: entries + live status + picker meta. */
1565
+ async list() {
1566
+ return listEntries()
1567
+ },
1568
+
1569
+ /**
1570
+ * Validate and persist one subagent entry: replace the row with the same
1571
+ * id or append a new row in the profile patch file. The Cordis user-layer
1572
+ * watcher hot-reloads the change into the live tree.
1573
+ * @param entry - { id, config } untrusted RPC payload.
1574
+ * @returns { ok, warnings, entries } with the fresh list.
1575
+ */
1576
+ async upsert(entry) {
1577
+ const payload = entry !== null && typeof entry === 'object' && !Array.isArray(entry) && typeof entry.entry === 'object'
1578
+ ? entry.entry
1579
+ : entry
1580
+ const warnings = enqueue(() => {
1581
+ const { lines } = readPatch(profileDir)
1582
+ const existing = new Map(parseManagedEntries(lines.join('\n')).map(item => [item.id, item.config]))
1583
+ const allIds = new Set(allBlockIds(lines))
1584
+ const { runtimeTools, seedTools, knownTools } = collectKnownTools()
1585
+ const validationWarnings = validateEntryInput(payload, {
1586
+ providers: collectProviders(),
1587
+ knownTools,
1588
+ runtimeTools,
1589
+ seedTools,
1590
+ existing,
1591
+ allIds,
1592
+ })
1593
+ const next = upsertIntoLines(lines, { id: payload.id, config: payload.config })
1594
+ ensureBackup()
1595
+ atomicWrite(patchPath, `${next.join('\n').replace(/\n*$/, '\n')}`)
1596
+ appendJournal({ at: new Date().toISOString(), action: existing.has(payload.id) ? 'update' : 'create', id: payload.id, toolName: payload.config?.toolName, entry: payload })
1597
+ return validationWarnings
1598
+ })
1599
+ return { ok: true, warnings: await warnings, ...await listEntries() }
1600
+ },
1601
+
1602
+ /**
1603
+ * Delete one managed subagent row by id.
1604
+ * @param id - the managed entry id.
1605
+ * @returns { ok, entries } with the fresh list.
1606
+ */
1607
+ async remove(id) {
1608
+ const targetId = id !== null && typeof id === 'object' && !Array.isArray(id) ? id.id : id
1609
+ await enqueue(() => {
1610
+ const { lines } = readPatch(profileDir)
1611
+ const result = removeFromLines(lines, targetId)
1612
+ if (!result.removed) {
1613
+ const known = parseManagedEntries(lines.join('\n')).map(item => item.id)
1614
+ throw new Error(`实例 "${String(targetId)}" 不存在;当前受管实例:${known.join(', ') || '(无)'}`)
1615
+ }
1616
+ ensureBackup()
1617
+ atomicWrite(patchPath, `${result.lines.join('\n').replace(/\n+$/, '\n')}`)
1618
+ appendJournal({ at: new Date().toISOString(), action: 'delete', id: targetId })
1619
+ })
1620
+ return { ok: true, ...await listEntries() }
1621
+ },
1622
+
1623
+ /**
1624
+ * The change journal (配置台账), newest first.
1625
+ * @param limit - max records (default 100, capped 500).
1626
+ */
1627
+ async history(limit) {
1628
+ const requested = limit !== null && typeof limit === 'object' && !Array.isArray(limit) ? limit.limit : limit
1629
+ const max = Number.isInteger(requested) && requested > 0 ? Math.min(requested, 500) : 100
1630
+ if (!existsSync(historyPath)) return { path: historyPath, records: [] }
1631
+ const lines = readFileSync(historyPath, 'utf8').split(/\r?\n/).filter(line => line.trim() !== '')
1632
+ const records = []
1633
+ for (const line of lines) {
1634
+ try {
1635
+ records.push(JSON.parse(line))
1636
+ } catch {
1637
+ records.push({ at: null, action: 'corrupt', id: line.slice(0, 80) })
1638
+ }
1639
+ }
1640
+ return { path: historyPath, records: records.slice(-max).reverse() }
1641
+ },
1642
+
1643
+ /** CLI backend tab payload: detection matrix (builtin + generic) + mounted rows + configs. */
1644
+ async cliList() {
1645
+ return cliListInner()
1646
+ },
1647
+
1648
+ /**
1649
+ * Mount or update one CLI backend. kind 'builtin' (default) manages the
1650
+ * harness provider packages via CLI patch rows; kind 'generic' mounts any
1651
+ * external CLI command via this plugin's generic command provider.
1652
+ * @param payload - { kind?, backendId, config } untrusted RPC payload.
1653
+ * @returns { ok, backends, others } with fresh detection.
1654
+ */
1655
+ async cliUpsert(payload) {
1656
+ const body = payload !== null && typeof payload === 'object' && !Array.isArray(payload) && typeof payload.payload === 'object'
1657
+ ? payload.payload
1658
+ : payload
1659
+ if (body !== null && typeof body === 'object' && body.kind === 'generic') {
1660
+ return cliUpsertGeneric(body)
1661
+ }
1662
+ const backendId = body !== null && typeof body === 'object' ? body.backendId : undefined
1663
+ const backend = CLI_BACKENDS.find(item => item.id === backendId)
1664
+ if (backend === undefined) {
1665
+ throw new Error(`未知的 CLI 后端 "${String(backendId)}";支持:${CLI_BACKENDS.map(item => item.id).join(', ')}`)
1666
+ }
1667
+ const rawConfig = body !== null && typeof body === 'object' && body.config !== undefined ? body.config : {}
1668
+ validateCliConfig(backend, rawConfig)
1669
+ const effective = { ...backend.defaultConfig, ...rawConfig }
1670
+ await enqueue(async () => {
1671
+ const { lines } = readPatch(profileDir)
1672
+ const mountedRows = new Map(parseCliBackends(lines.join('\n')).map(row => [row.backendId, row.config]))
1673
+ const previous = mountedRows.get(backend.id)
1674
+ if (previous === undefined && !resolvePackage(backend.packageName).ok) {
1675
+ throw new Error(`provider 包 "${backend.packageName}" 在当前部署中不可解析,无法挂载;请先安装该包后重新检测`)
1676
+ }
1677
+ if (previous !== undefined && previous.providerName !== undefined && previous.providerName !== effective.providerName) {
1678
+ const referencing = parseManagedEntries(lines.join('\n'))
1679
+ .filter(item => item.config && item.config.provider === previous.providerName)
1680
+ .map(item => item.id)
1681
+ if (referencing.length > 0) {
1682
+ throw new Error(`providerName "${previous.providerName}" 仍被子智能体实例引用(${referencing.join(', ')});请先改这些实例的执行后端再改名`)
1683
+ }
1684
+ }
1685
+ const next = upsertCliIntoLines(lines, backend, effective)
1686
+ ensureBackup()
1687
+ atomicWrite(patchPath, `${next.join('\n').replace(/\n*$/, '\n')}`)
1688
+ appendJournal({
1689
+ at: new Date().toISOString(),
1690
+ action: previous !== undefined ? 'cli-update' : 'mount',
1691
+ id: backend.id,
1692
+ entry: { backendId: backend.id, config: effective },
1693
+ })
1694
+ })
1695
+ return { ok: true, ...(await detectCliBackends(profileDir)) }
1696
+ },
1697
+
1698
+ /**
1699
+ * Unmount one CLI backend by id. Generic backends (id prefixed "cli-")
1700
+ * drop the live-registered command provider; builtin ids delete the CLI
1701
+ * patch row. Both refuse while any managed subagent instance still
1702
+ * references the backend's provider name.
1703
+ * @param id - backendId, bare or wrapped ({ id }).
1704
+ * @returns { ok, backends, others } with fresh detection.
1705
+ */
1706
+ async cliRemove(id) {
1707
+ const body = id !== null && typeof id === 'object' && !Array.isArray(id) ? id : { id }
1708
+ const backendId = body.id
1709
+ if (typeof backendId === 'string' && backendId.startsWith(CLI_GENERIC_ID_PREFIX)) {
1710
+ return cliRemoveGeneric(backendId)
1711
+ }
1712
+ await enqueue(() => {
1713
+ const backend = CLI_BACKENDS.find(item => item.id === backendId)
1714
+ if (backend === undefined) {
1715
+ throw new Error(`未知的 CLI 后端 "${String(backendId)}";支持:${CLI_BACKENDS.map(item => item.id).join(', ')}`)
1716
+ }
1717
+ const { lines } = readPatch(profileDir)
1718
+ const row = parseCliBackends(lines.join('\n')).find(item => item.backendId === backend.id)
1719
+ if (row === undefined) {
1720
+ throw new Error(`CLI 后端 "${backend.label}"(${backend.id})未挂载`)
1721
+ }
1722
+ const providerName = row.config.providerName !== undefined ? row.config.providerName : backend.defaultConfig.providerName
1723
+ const referencing = parseManagedEntries(lines.join('\n'))
1724
+ .filter(item => item.config && item.config.provider === providerName)
1725
+ .map(item => item.id)
1726
+ if (referencing.length > 0) {
1727
+ throw new Error(`仍有子智能体实例在使用后端 "${providerName}"(${referencing.join(', ')});请先删除或改配这些实例再卸载`)
1728
+ }
1729
+ const result = removeCliFromLines(lines, backend.id)
1730
+ if (!result.removed) {
1731
+ throw new Error(`CLI 后端 "${backend.label}"(${backend.id})未挂载`)
1732
+ }
1733
+ ensureBackup()
1734
+ atomicWrite(patchPath, `${result.lines.join('\n').replace(/\n+$/, '\n')}`)
1735
+ appendJournal({ at: new Date().toISOString(), action: 'unmount', id: backend.id })
1736
+ })
1737
+ return { ok: true, ...(await cliListInner()) }
1738
+ },
1739
+
1740
+ /**
1741
+ * Install the missing provider/runner packages of one builtin CLI backend
1742
+ * globally (npm install -g). A no-op when everything already resolves.
1743
+ * Detection is re-run afterwards so the card flips to mountable.
1744
+ * @param backendId - builtin backend id, bare or wrapped ({ backendId }).
1745
+ * @returns { ok, output, backends, others }.
1746
+ */
1747
+ async cliInstall(backendId) {
1748
+ const body = backendId !== null && typeof backendId === 'object' && !Array.isArray(backendId) ? backendId : { backendId }
1749
+ const id = body.backendId
1750
+ const backend = CLI_BACKENDS.find(item => item.id === id)
1751
+ if (backend === undefined) {
1752
+ throw new Error(`未知的 CLI 后端 "${String(id)}";支持:${CLI_BACKENDS.map(item => item.id).join(', ')}`)
1753
+ }
1754
+ const missing = missingPackagesFor(backend, resolvePackage)
1755
+ if (missing.length === 0) {
1756
+ return { ok: true, output: '依赖已齐,无需安装', ...(await cliListInner()) }
1757
+ }
1758
+ // Install separately: the runner (e.g. @openai/codex) is public and
1759
+ // fixes 'CLI 依赖'; the provider package may carry unpublished internal
1760
+ // deps (@deepseek-ai/dsh-tasks etc.) that make a registry install
1761
+ // impossible — report that instead of failing the whole step.
1762
+ const outputs = []
1763
+ const ordered = [backend.runnerPackage, backend.cliPackage, backend.packageName]
1764
+ .filter(name => name !== undefined && missing.includes(name))
1765
+ for (const name of ordered) {
1766
+ const step = await npmInstall([name])
1767
+ outputs.push(step.output)
1768
+ }
1769
+ const stillMissing = missingPackagesFor(backend, resolvePackage)
1770
+ const output = outputs.filter(Boolean).join('\n')
1771
+ + (stillMissing.length > 0
1772
+ ? '\n仍有包安装后未解析到:' + stillMissing.join('、') + ';请点「重新检测」,若仍为 ✗ 则该包在所用 registry 上可能不可用(可尝试 --registry=https://registry.npmjs.org)'
1773
+ : '')
1774
+ return { ok: true, output, ...(await cliListInner()) }
1775
+ },
1776
+ }
1777
+
1778
+ // Generic CLI providers: reconcile at boot; drop the live registrations when
1779
+ // this plugin instance is torn down (HMR / plugin removal).
1780
+ ctx.effect(() => {
1781
+ try { reconcileGenericProviders() } catch (error) {
1782
+ ctx.logger.warn?.(`plugin-admin/subagents: generic CLI provider reconcile failed: ${String(error)}`)
1783
+ }
1784
+ return () => {
1785
+ for (const id of [...genericUnregisterMap.keys()]) dropGenericProvider(id)
1786
+ }
1787
+ }, 'plugin-admin/subagents: generic cli providers')
1788
+
1789
+ const binding = Object.freeze({ service, serviceKey: SERVICE_KEY, namespace: NAMESPACE })
1790
+ Object.defineProperty(service, 'typertRemote', { value: binding, enumerable: false })
1791
+ ctx.provide(SERVICE_KEY, service)
1792
+
1793
+ return subagentInvocations()
1794
+ }
1795
+
1796
+ /**
1797
+ * The `subagentAdmin` invocation descriptors. The typert registry allows ONE
1798
+ * registration per package name, so these are returned to the host half
1799
+ * (dsh-plugin-admin registers a single unified descriptor for all its
1800
+ * namespaces) instead of being registered here.
1801
+ * @returns the invocation descriptor array for the unified registration.
1802
+ */
1803
+ export function subagentInvocations() {
1804
+ return [
1805
+ {
1806
+ id: `${DESCRIPTOR_PACKAGE}/subagent/list`,
1807
+ service: SERVICE_KEY,
1808
+ namespace: NAMESPACE,
1809
+ method: 'list',
1810
+ invocation: { kind: 'direct' },
1811
+ parameters: [],
1812
+ result: { mode: 'src-json' },
1813
+ },
1814
+ {
1815
+ id: `${DESCRIPTOR_PACKAGE}/subagent/upsert`,
1816
+ service: SERVICE_KEY,
1817
+ namespace: NAMESPACE,
1818
+ method: 'upsert',
1819
+ invocation: { kind: 'direct' },
1820
+ parameters: [{ name: 'entry', wire: 'entry', source: 'json', codec: { mode: 'src-json' } }],
1821
+ result: { mode: 'src-json' },
1822
+ },
1823
+ {
1824
+ id: `${DESCRIPTOR_PACKAGE}/subagent/remove`,
1825
+ service: SERVICE_KEY,
1826
+ namespace: NAMESPACE,
1827
+ method: 'remove',
1828
+ invocation: { kind: 'direct' },
1829
+ parameters: [{ name: 'id', wire: 'id', source: 'json', codec: { mode: 'src-json' } }],
1830
+ result: { mode: 'src-json' },
1831
+ },
1832
+ {
1833
+ id: `${DESCRIPTOR_PACKAGE}/subagent/history`,
1834
+ service: SERVICE_KEY,
1835
+ namespace: NAMESPACE,
1836
+ method: 'history',
1837
+ invocation: { kind: 'direct' },
1838
+ parameters: [{ name: 'limit', wire: 'limit', source: 'json', codec: { mode: 'src-json' } }],
1839
+ result: { mode: 'src-json' },
1840
+ },
1841
+ {
1842
+ id: `${DESCRIPTOR_PACKAGE}/subagent/cliList`,
1843
+ service: SERVICE_KEY,
1844
+ namespace: NAMESPACE,
1845
+ method: 'cliList',
1846
+ invocation: { kind: 'direct' },
1847
+ parameters: [],
1848
+ result: { mode: 'src-json' },
1849
+ },
1850
+ {
1851
+ id: `${DESCRIPTOR_PACKAGE}/subagent/cliUpsert`,
1852
+ service: SERVICE_KEY,
1853
+ namespace: NAMESPACE,
1854
+ method: 'cliUpsert',
1855
+ invocation: { kind: 'direct' },
1856
+ parameters: [{ name: 'payload', wire: 'payload', source: 'json', codec: { mode: 'src-json' } }],
1857
+ result: { mode: 'src-json' },
1858
+ },
1859
+ {
1860
+ id: `${DESCRIPTOR_PACKAGE}/subagent/cliRemove`,
1861
+ service: SERVICE_KEY,
1862
+ namespace: NAMESPACE,
1863
+ method: 'cliRemove',
1864
+ invocation: { kind: 'direct' },
1865
+ parameters: [{ name: 'id', wire: 'id', source: 'json', codec: { mode: 'src-json' } }],
1866
+ result: { mode: 'src-json' },
1867
+ },
1868
+ {
1869
+ id: `${DESCRIPTOR_PACKAGE}/subagent/cliInstall`,
1870
+ service: SERVICE_KEY,
1871
+ namespace: NAMESPACE,
1872
+ method: 'cliInstall',
1873
+ invocation: { kind: 'direct' },
1874
+ parameters: [{ name: 'backendId', wire: 'backendId', source: 'json', codec: { mode: 'src-json' } }],
1875
+ result: { mode: 'src-json' },
1876
+ },
1877
+ ]
1878
+ }