@dsh-plugins/dsh-llm-hub 0.7.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/CHANGELOG.md +374 -0
- package/LICENSE +21 -0
- package/README.en.md +266 -0
- package/README.md +260 -0
- package/cordis.patch.yml +55 -0
- package/lib/client.js +862 -0
- package/lib/harness.js +436 -0
- package/lib/index.js +1481 -0
- package/package.json +70 -0
package/lib/harness.js
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-llm-hub —— harness 委派半(host)。
|
|
3
|
+
*
|
|
4
|
+
* 把**本机已经装好**的外部 agent CLI 注册成 DSH 的子代理提供方,于是会话里可以
|
|
5
|
+
* 用 `subagent_codex` / `subagent_claude_code` / `subagent_antigravity` 把一段独立
|
|
6
|
+
* 任务甩出去,各自烧各自的订阅额度(ChatGPT / Claude / Google AI)。
|
|
7
|
+
*
|
|
8
|
+
* ## 为什么长在本插件里
|
|
9
|
+
*
|
|
10
|
+
* host 半已经在做「探测 → 判定 → 只做减法」这件事(模型可用性)。harness 是同一条
|
|
11
|
+
* 线的延伸:探测对象从「网关能不能连」换成「CLI 装没装」,判定结果从「把 provider
|
|
12
|
+
* 从路由表里摘掉」换成「注册 / 不注册 provider」。
|
|
13
|
+
*
|
|
14
|
+
* ## 装了才出现
|
|
15
|
+
*
|
|
16
|
+
* 三个 provider **只在对应可执行文件真的存在时才注册**。`dsh-tool-subagent` 遇到
|
|
17
|
+
* 缺失的 provider 只打一条 info(`provider "x" not registered yet; the "..." tool
|
|
18
|
+
* will register when it appears`),工具行会延迟到 provider 出现才注册 —— 所以
|
|
19
|
+
* 别人机器上没装 codex,`subagent_codex` 就静默不出现,DSH 照常启动。
|
|
20
|
+
* 这就是「检查人家电脑装没装」的全部实现:**不注册即不存在**,不需要 UI 开关。
|
|
21
|
+
*
|
|
22
|
+
* ## 为什么不自己扫 PATH
|
|
23
|
+
*
|
|
24
|
+
* `ctx.subprocess.resolveExecutable` 是内核自己的解析器,与子进程真正执行时用的是
|
|
25
|
+
* 同一套 PATH 视图。自己扫 PATH 会在两处翻车:登录 shell 的 alias(本机 `agy` 就是
|
|
26
|
+
* 个带 `--dangerously-skip-permissions` 的 alias,`command -v` 返回的是 alias 定义
|
|
27
|
+
* 而不是路径),以及 launchd 起的宿主进程根本读不到 `.zshrc` 里的 PATH。
|
|
28
|
+
*
|
|
29
|
+
* ## 为什么内核符号是**动态** import
|
|
30
|
+
*
|
|
31
|
+
* 本包零运行时依赖,`@deepseek-ai/dsh-subagent` / `-session` 由宿主提供 —— 在 profile
|
|
32
|
+
* 里经 `.dsh-module-fallback` 解析到宿主的 node_modules(2026-09-17 实测
|
|
33
|
+
* `import.meta.resolve` 可解析),但在**仓库目录里解析不到**。顶部静态 import 会因此
|
|
34
|
+
* 两头挨打:仓库内 `node --test` 直接 MODULE_NOT_FOUND,而线上一旦某个宿主版本少了
|
|
35
|
+
* 其中一个导出,整个插件加载失败 —— 连余额和模型发现一起没了。惰性载入把这个风险
|
|
36
|
+
* 关在 harness 这一段里:载不到就只是不启用它。
|
|
37
|
+
*
|
|
38
|
+
* @module dsh-llm-hub/harness
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { randomUUID } from 'node:crypto'
|
|
42
|
+
|
|
43
|
+
/** 收集子进程 stdout 的上限;超过就只留头部(宁可截断也要有答案)。 */
|
|
44
|
+
const MAX_STDOUT_BYTES = 2 * 1024 * 1024
|
|
45
|
+
/** 出错诊断保留的 stderr 尾巴。agy 的 glog 在日志目录不可写时能喷 300+ 行,不能全留。 */
|
|
46
|
+
const MAX_STDERR_TAIL_BYTES = 8 * 1024
|
|
47
|
+
/** SIGTERM 到 SIGKILL 之间的宽限。 */
|
|
48
|
+
const DISPOSE_GRACE_MS = 3000
|
|
49
|
+
/** agy 的 `--print-timeout`,与它自己的默认值(5m)一致。 */
|
|
50
|
+
const AGY_PRINT_TIMEOUT = '300s'
|
|
51
|
+
/** ANSI 转义序列。CLI 即使不在 TTY 下也可能上色。 */
|
|
52
|
+
const ANSI_PATTERN = /\[[0-9;?]*[ -/]*[@-~]/g
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 本机可能装着的外部 harness。
|
|
56
|
+
*
|
|
57
|
+
* 三个 CLI 的非交互模式都已实测:**stdout 是干净的最终答案,日志全在 stderr**
|
|
58
|
+
* (2026-09-17 实测 codex 0.153.4 / claude 2.1.252 / agy 1.2.5,三个都只输出
|
|
59
|
+
* `PONG\n`)。所以不需要解析各家的 stream-json,收 stdout 即可。
|
|
60
|
+
*/
|
|
61
|
+
const HARNESSES = [
|
|
62
|
+
{
|
|
63
|
+
id: 'codex',
|
|
64
|
+
bin: 'codex',
|
|
65
|
+
displayName: 'Codex',
|
|
66
|
+
// `--skip-git-repo-check`:父会话的 cwd 不一定是 git 仓库,缺这个 flag 会直接拒跑。
|
|
67
|
+
args: (task) => ['exec', '--skip-git-repo-check', task]
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
id: 'claude-code',
|
|
71
|
+
bin: 'claude',
|
|
72
|
+
displayName: 'Claude Code',
|
|
73
|
+
args: (task) => ['-p', task]
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: 'antigravity',
|
|
77
|
+
bin: 'agy',
|
|
78
|
+
displayName: 'Antigravity',
|
|
79
|
+
// `--dangerously-skip-permissions` 不是图方便:headless print 模式下 agy 会
|
|
80
|
+
// **自动拒绝** `command` 权限,于是任何碰文件或命令的任务都静默返回空答案。
|
|
81
|
+
args: (task) => ['-p', task, '--dangerously-skip-permissions', '--print-timeout', AGY_PRINT_TIMEOUT]
|
|
82
|
+
}
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 空能力声明 —— 与内核的 `NO_START_CAPABILITIES` 等价,这里内联。
|
|
87
|
+
*
|
|
88
|
+
* 内联而不是 import:**探测与注册属于启动路径**,不该因为内核模块解析失败而整段
|
|
89
|
+
* 跳过(那样用户会看到「harness 一个都没探到」,而真因是 import)。真正需要内核的
|
|
90
|
+
* 只有 `start()` 里那三个非平凡函数(结算竞态、run 句柄、cwd 解析),它们惰性载入。
|
|
91
|
+
*
|
|
92
|
+
* 将来内核新增能力字段时这里会落后一个字段 —— 但缺失字段读出来是 `undefined`,与
|
|
93
|
+
* `false` 同义(「不支持」),所以落后的方向是安全的:只会少声明能力,不会多声明。
|
|
94
|
+
*/
|
|
95
|
+
const NO_CAPABILITIES = Object.freeze({
|
|
96
|
+
agentOptions: false,
|
|
97
|
+
outputSchema: false,
|
|
98
|
+
depthLimit: false,
|
|
99
|
+
toolFilter: false,
|
|
100
|
+
persona: false
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
/** 惰性载入的内核符号;`loadKernel` 填,之后全模块共用。 */
|
|
104
|
+
let kernel
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 载入本模块用到的内核符号。
|
|
108
|
+
*
|
|
109
|
+
* 载入失败直接抛 —— 调用方(`applyHarness`)会把它降级成一条 warn,harness 不启用,
|
|
110
|
+
* 插件其余部分不受影响。
|
|
111
|
+
* @returns 内核符号集合。
|
|
112
|
+
*/
|
|
113
|
+
async function loadKernel() {
|
|
114
|
+
if (kernel !== undefined) return kernel
|
|
115
|
+
const [subagent, session] = await Promise.all([
|
|
116
|
+
import('@deepseek-ai/dsh-subagent'),
|
|
117
|
+
import('@deepseek-ai/dsh-session')
|
|
118
|
+
])
|
|
119
|
+
const missing = ['resolveChildCwd', 'settleRunResult', 'subprocessRunHandle']
|
|
120
|
+
.filter((key) => subagent[key] === undefined)
|
|
121
|
+
if (missing.length > 0) throw new Error(`dsh-subagent 缺少导出:${missing.join(', ')}`)
|
|
122
|
+
if (session.SessionId === undefined) throw new Error('dsh-session 缺少导出:SessionId')
|
|
123
|
+
kernel = {
|
|
124
|
+
resolveChildCwd: subagent.resolveChildCwd,
|
|
125
|
+
settleRunResult: subagent.settleRunResult,
|
|
126
|
+
subprocessRunHandle: subagent.subprocessRunHandle,
|
|
127
|
+
SessionId: session.SessionId
|
|
128
|
+
}
|
|
129
|
+
return kernel
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 剥掉 ANSI 转义。
|
|
134
|
+
* @param text - 原始文本。
|
|
135
|
+
* @returns 去色后的文本。
|
|
136
|
+
*/
|
|
137
|
+
export function stripAnsi(text) {
|
|
138
|
+
return text.replace(ANSI_PATTERN, '')
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 按字节预算截取字符串头部,不切坏多字节字符。
|
|
143
|
+
* @param text - 原始文本。
|
|
144
|
+
* @param budget - 字节预算。
|
|
145
|
+
* @returns 截取后的文本。
|
|
146
|
+
*/
|
|
147
|
+
function headToByteBudget(text, budget) {
|
|
148
|
+
if (Buffer.byteLength(text, 'utf8') <= budget) return text
|
|
149
|
+
return Buffer.from(text, 'utf8').subarray(0, budget).toString('utf8').replace(/�$/, '')
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* 只保留 stderr 的尾部。
|
|
154
|
+
* @param tail - 已保留的尾部。
|
|
155
|
+
* @param chunk - 新到的块。
|
|
156
|
+
* @returns 合并后仍在预算内的尾部。
|
|
157
|
+
*/
|
|
158
|
+
function retainStderrTail(tail, chunk) {
|
|
159
|
+
const merged = tail + chunk
|
|
160
|
+
if (Buffer.byteLength(merged, 'utf8') <= MAX_STDERR_TAIL_BYTES) return merged
|
|
161
|
+
const buffer = Buffer.from(merged, 'utf8')
|
|
162
|
+
return buffer.subarray(buffer.length - MAX_STDERR_TAIL_BYTES).toString('utf8').replace(/^�/, '')
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* 把 prompt 折成一段纯文本任务;非文本块丢弃(这些 CLI 只吃文本)。
|
|
167
|
+
* @param prompt - 内核给的 prompt 块序列。
|
|
168
|
+
* @returns 拼好的任务文本。
|
|
169
|
+
*/
|
|
170
|
+
export function textTask(prompt) {
|
|
171
|
+
const parts = []
|
|
172
|
+
for (const block of prompt) if (block?.type === 'text' && typeof block.text === 'string') parts.push(block.text)
|
|
173
|
+
return parts.join('\n\n').trim()
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 组装一次运行的 argv。
|
|
178
|
+
* @param harness - harness 定义。
|
|
179
|
+
* @param executable - 已解析的可执行文件路径。
|
|
180
|
+
* @param task - 任务文本。
|
|
181
|
+
* @returns 完整 argv。
|
|
182
|
+
*/
|
|
183
|
+
export function harnessArgv(harness, executable, task) {
|
|
184
|
+
return [executable, ...harness.args(task)]
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* 有 stderr 尾巴就作为诊断后缀附上。
|
|
189
|
+
* @param stderrTail - 保留的 stderr 尾部。
|
|
190
|
+
* @returns 诊断后缀,或空串。
|
|
191
|
+
*/
|
|
192
|
+
function diagnosticSuffix(stderrTail) {
|
|
193
|
+
const trimmed = stderrTail.trim()
|
|
194
|
+
return trimmed.length === 0 ? '' : `\nstderr 尾部:\n${trimmed}`
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* 把一次退出折成结果,或抛出带诊断的失败。
|
|
199
|
+
*
|
|
200
|
+
* 独立成函数是为了能单测 —— 判定链的每个分支都对应一种真实故障,而它们都不会有
|
|
201
|
+
* 编译错误,只会让父 agent 拿到错的东西。
|
|
202
|
+
* @param prefix - 诊断前缀。
|
|
203
|
+
* @param bin - CLI 名字。
|
|
204
|
+
* @param exitCode - 退出码(被信号终止时为 null)。
|
|
205
|
+
* @param stdout - 收到的原始 stdout。
|
|
206
|
+
* @param stderrTail - 保留的 stderr 尾部。
|
|
207
|
+
* @returns 终态结果。
|
|
208
|
+
*/
|
|
209
|
+
export function printOutcome(prefix, bin, exitCode, stdout, stderrTail) {
|
|
210
|
+
const answer = stripAnsi(stdout).trim()
|
|
211
|
+
if (exitCode !== 0) {
|
|
212
|
+
const how = exitCode === null ? '被信号终止' : `以 ${String(exitCode)} 退出`
|
|
213
|
+
throw new Error(`${prefix}: ${bin} ${how}`
|
|
214
|
+
+ (answer.length > 0 ? `,已打印:\n${answer}` : '')
|
|
215
|
+
+ diagnosticSuffix(stderrTail))
|
|
216
|
+
}
|
|
217
|
+
// 退出码 0 但一个字都没打印:不能报 completed,否则父 agent 拿到空答案却以为成功。
|
|
218
|
+
// agy 在缺 `--dangerously-skip-permissions` 时就是这个形状(权限被自动拒,静默空输出)。
|
|
219
|
+
if (answer.length === 0) {
|
|
220
|
+
throw new Error(`${prefix}: ${bin} 正常退出但没有输出答案${diagnosticSuffix(stderrTail)}`)
|
|
221
|
+
}
|
|
222
|
+
return { output: [{ type: 'text', text: answer }], stopReason: 'completed' }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* 关掉子进程并等整棵进程树消失。
|
|
227
|
+
* @param child - 子进程句柄。
|
|
228
|
+
*/
|
|
229
|
+
async function disposeChild(child) {
|
|
230
|
+
if (child.pid <= 0) {
|
|
231
|
+
await child.done.catch(() => {})
|
|
232
|
+
return
|
|
233
|
+
}
|
|
234
|
+
child.terminate()
|
|
235
|
+
await child.waitForExit().catch(() => {})
|
|
236
|
+
await child.done.catch(() => {})
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* 起一次 CLI 运行并发布它的 one-shot run。
|
|
241
|
+
* @param harness - harness 定义(HARNESSES 中一项)。
|
|
242
|
+
* @param request - 内核解析好的委派请求。
|
|
243
|
+
* @param deps - 可执行文件路径、cwd、环境、spawn 与日志回调。
|
|
244
|
+
* @returns 已发布的 run 句柄。
|
|
245
|
+
*/
|
|
246
|
+
async function startHarnessRun(harness, request, deps) {
|
|
247
|
+
const k = await loadKernel()
|
|
248
|
+
const prefix = `dsh-llm-hub/${harness.id}`
|
|
249
|
+
const task = textTask(request.prompt)
|
|
250
|
+
if (task.length === 0) throw new Error(`${prefix}: 委派任务为空`)
|
|
251
|
+
if (request.signal.aborted) throw new Error(`${prefix}: 请求在 ${harness.bin} 启动前已取消`)
|
|
252
|
+
|
|
253
|
+
const child = deps.spawn({
|
|
254
|
+
argv: harnessArgv(harness, deps.executable, task),
|
|
255
|
+
cwd: deps.cwd,
|
|
256
|
+
// stdin 给 /dev/null:三个 CLI 都会读 stdin 追加输入,不关掉会等到 EOF 才动。
|
|
257
|
+
stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' },
|
|
258
|
+
graceMs: DISPOSE_GRACE_MS,
|
|
259
|
+
env: deps.env
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
// 第二道取消闸:pre-spawn 检查与这里之间请求可能已被取消,而此时进程已经起来了,
|
|
263
|
+
// 必须先把它收干净再抛,否则留下一棵孤儿进程树。
|
|
264
|
+
if (request.signal.aborted) {
|
|
265
|
+
const failure = new Error(`${prefix}: 请求在 ${harness.bin} 启动过程中被取消`)
|
|
266
|
+
await disposeChild(child)
|
|
267
|
+
throw failure
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const stdout = child.stdout
|
|
271
|
+
if (stdout === undefined) {
|
|
272
|
+
await disposeChild(child)
|
|
273
|
+
throw new Error(`${prefix}: spawn 没有给出可读的 stdout`)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
let captured = ''
|
|
277
|
+
let capturedBytes = 0
|
|
278
|
+
let stderrTail = ''
|
|
279
|
+
stdout.setEncoding('utf8')
|
|
280
|
+
stdout.on('data', (chunk) => {
|
|
281
|
+
if (capturedBytes >= MAX_STDOUT_BYTES) return
|
|
282
|
+
const remaining = MAX_STDOUT_BYTES - capturedBytes
|
|
283
|
+
const keep = Buffer.byteLength(chunk, 'utf8') > remaining ? headToByteBudget(chunk, remaining) : chunk
|
|
284
|
+
capturedBytes += Buffer.byteLength(keep, 'utf8')
|
|
285
|
+
captured += keep
|
|
286
|
+
})
|
|
287
|
+
// 流上的错误不能让宿主进程崩。
|
|
288
|
+
stdout.on('error', () => {})
|
|
289
|
+
const stderr = child.stderr
|
|
290
|
+
if (stderr !== undefined) {
|
|
291
|
+
stderr.setEncoding('utf8')
|
|
292
|
+
stderr.on('data', (chunk) => { stderrTail = retainStderrTail(stderrTail, chunk) })
|
|
293
|
+
stderr.on('error', () => {})
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** 取消或失败抢到结算时,把能捞的文本交出去。 */
|
|
297
|
+
const collectOutput = () => {
|
|
298
|
+
const answer = stripAnsi(captured).trim()
|
|
299
|
+
return answer.length > 0 ? [{ type: 'text', text: answer }] : []
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const runAbort = new AbortController()
|
|
303
|
+
const requestCancel = () => {
|
|
304
|
+
if (runAbort.signal.aborted) return
|
|
305
|
+
runAbort.abort(new Error(`${prefix}: 运行被本地取消`))
|
|
306
|
+
if (child.pid > 0) child.terminate()
|
|
307
|
+
}
|
|
308
|
+
const onAbort = () => { requestCancel() }
|
|
309
|
+
request.signal.addEventListener('abort', onAbort, { once: true })
|
|
310
|
+
|
|
311
|
+
const result = k.settleRunResult({
|
|
312
|
+
attempt: async () => {
|
|
313
|
+
const outcome = await child.done
|
|
314
|
+
return printOutcome(prefix, harness.bin, outcome.exitCode, captured, stderrTail)
|
|
315
|
+
},
|
|
316
|
+
collectOutput,
|
|
317
|
+
cancelled: () => runAbort.signal.aborted,
|
|
318
|
+
onError: deps.onError,
|
|
319
|
+
signal: request.signal,
|
|
320
|
+
onAbort
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
return k.subprocessRunHandle({
|
|
324
|
+
id: k.SessionId(randomUUID()),
|
|
325
|
+
result,
|
|
326
|
+
signal: request.signal,
|
|
327
|
+
onAbort,
|
|
328
|
+
requestCancel,
|
|
329
|
+
teardown: () => disposeChild(child)
|
|
330
|
+
})
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* 造一个 harness 的子代理提供方。
|
|
335
|
+
*
|
|
336
|
+
* `capabilities` 全 false、`inheritsParentContext` false:子进程在另一个运行时里,
|
|
337
|
+
* 父侧的 persona / 工具过滤 / 深度上限 / 结构化输出都管不到它,所以一个都不声明 ——
|
|
338
|
+
* 内核会在 start 之前就拒掉需要这些能力的请求,而不是接了再悄悄忽略。
|
|
339
|
+
* @param ctx - 插件上下文(带 subprocess 与 logger)。
|
|
340
|
+
* @param harness - harness 定义。
|
|
341
|
+
* @param executable - 已解析的可执行文件绝对路径。
|
|
342
|
+
* @returns 可直接 registerProvider 的提供方。
|
|
343
|
+
*/
|
|
344
|
+
export function createProvider(ctx, harness, executable) {
|
|
345
|
+
const prefix = `dsh-llm-hub/${harness.id}`
|
|
346
|
+
return {
|
|
347
|
+
name: harness.id,
|
|
348
|
+
capabilities: NO_CAPABILITIES,
|
|
349
|
+
inheritsParentContext: false,
|
|
350
|
+
async start(request) {
|
|
351
|
+
const k = await loadKernel()
|
|
352
|
+
const parentCwd = request.parent?.session?.header?.cwd
|
|
353
|
+
if (parentCwd === undefined) {
|
|
354
|
+
throw new Error(`${prefix}: 父会话没有工作目录,无法为子进程确定 cwd`)
|
|
355
|
+
}
|
|
356
|
+
return startHarnessRun(harness, request, {
|
|
357
|
+
executable,
|
|
358
|
+
cwd: k.resolveChildCwd(prefix, undefined, parentCwd),
|
|
359
|
+
env: {},
|
|
360
|
+
spawn: (spec) => ctx.subprocess.spawn(spec),
|
|
361
|
+
onError: (error, stopReason) => {
|
|
362
|
+
ctx.logger?.warn?.(`${prefix}: 子运行失败(${stopReason}):${error.message}`)
|
|
363
|
+
}
|
|
364
|
+
})
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* 上一次探测的结果。`harnessReport()` 读它 —— 宿主的 `info` 级日志不落盘,没有这个
|
|
371
|
+
* 快照就没法从外部确认「探到了什么、注册了几个」,只能靠在会话里试一次委派。
|
|
372
|
+
*/
|
|
373
|
+
let lastReport = { probedAt: 0, entries: [] }
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* 读上一次 harness 探测快照。
|
|
377
|
+
* @returns 探测时刻与每个 harness 的状态。
|
|
378
|
+
*/
|
|
379
|
+
export function harnessReport() {
|
|
380
|
+
return lastReport
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* 探测本机 harness 并把探到的注册成子代理提供方。
|
|
385
|
+
*
|
|
386
|
+
* 在 `apply` 里以**局部注入**调用:`subagents` / `subprocess` 缺席时整段跳过,不影响
|
|
387
|
+
* 本插件的余额与模型发现。探测是一次性的 —— 这些 CLI 装完不会自己消失,装上之后重载
|
|
388
|
+
* 插件(或重启 profile)即可被认出来。
|
|
389
|
+
* @param ctx - 插件上下文(需带 subagents 与 subprocess)。
|
|
390
|
+
* @returns 每个 harness 的探测与注册状态。
|
|
391
|
+
*/
|
|
392
|
+
export async function applyHarness(ctx) {
|
|
393
|
+
const entries = []
|
|
394
|
+
for (const harness of HARNESSES) {
|
|
395
|
+
const entry = { id: harness.id, bin: harness.bin, displayName: harness.displayName, installed: false, executable: null, registered: false, note: '' }
|
|
396
|
+
let executable
|
|
397
|
+
try {
|
|
398
|
+
executable = await ctx.subprocess.resolveExecutable(harness.bin, {}, undefined)
|
|
399
|
+
} catch (error) {
|
|
400
|
+
// 没装就是没装 —— 不注册,对应的 subagent_* 工具自然不会出现。
|
|
401
|
+
entry.note = `未找到可执行文件(${error?.message ?? error})`
|
|
402
|
+
entries.push(entry)
|
|
403
|
+
continue
|
|
404
|
+
}
|
|
405
|
+
if (typeof executable !== 'string' || executable.length === 0) {
|
|
406
|
+
entry.note = '解析器返回了空路径'
|
|
407
|
+
entries.push(entry)
|
|
408
|
+
continue
|
|
409
|
+
}
|
|
410
|
+
entry.installed = true
|
|
411
|
+
entry.executable = executable
|
|
412
|
+
try {
|
|
413
|
+
ctx.subagents.registerProvider(createProvider(ctx, harness, executable))
|
|
414
|
+
entry.registered = true
|
|
415
|
+
ctx.logger?.info?.(`dsh-llm-hub: 注册 harness 子代理 ${harness.id} → ${executable}`)
|
|
416
|
+
} catch (error) {
|
|
417
|
+
// 同名 provider 已被别的插件占了(例如官方的 dsh-subagent-codex)——
|
|
418
|
+
// 让先到的赢,只记一行,不抢。
|
|
419
|
+
entry.note = `提供方已存在,跳过(${error?.message ?? error})`
|
|
420
|
+
ctx.logger?.info?.(`dsh-llm-hub: ${harness.id} ${entry.note}`)
|
|
421
|
+
}
|
|
422
|
+
entries.push(entry)
|
|
423
|
+
}
|
|
424
|
+
const registered = entries.filter((entry) => entry.registered).map((entry) => entry.id)
|
|
425
|
+
if (registered.length === 0) {
|
|
426
|
+
ctx.logger?.info?.('dsh-llm-hub: 未注册任何 harness 子代理(本机没装,或提供方已被占用)')
|
|
427
|
+
}
|
|
428
|
+
lastReport = { probedAt: Date.now(), entries }
|
|
429
|
+
return entries
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** 供测试与 client 半复用的 harness 清单(只读)。 */
|
|
433
|
+
export const HARNESS_CATALOG = HARNESSES.map(({ id, bin, displayName }) => ({ id, bin, displayName }))
|
|
434
|
+
|
|
435
|
+
/** 供测试复用的 harness 定义(含 args 组装器)。 */
|
|
436
|
+
export const HARNESS_DEFINITIONS = HARNESSES
|