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.
- package/README.md +23 -13
- package/lib/client.js +1592 -60
- package/lib/index.js +17 -2
- package/lib/subagent-admin.js +1878 -0
- package/lib/tool-seed.js +67 -0
- package/package.json +6 -3
|
@@ -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
|
+
}
|