dsh-remote-agent 0.0.0-stage → 0.4.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.
Files changed (70) hide show
  1. package/CHANGELOG.md +700 -0
  2. package/CONTRIBUTING.md +358 -0
  3. package/KNOWN-ISSUES.md +601 -0
  4. package/LICENSE +21 -0
  5. package/README.en.md +353 -0
  6. package/README.md +317 -2
  7. package/SECURITY.md +348 -0
  8. package/bin/dsh-remote-relay.js +257 -0
  9. package/bin/dsh-remote-setup.js +440 -0
  10. package/bin//345/217/214/345/207/273/350/277/220/350/241/214-/351/205/215/347/275/256/345/220/221/345/257/274.cmd +28 -0
  11. package/cordis.patch.yml +87 -0
  12. package/deploy/relay-vps/Caddyfile +24 -0
  13. package/deploy/relay-vps/README.md +132 -0
  14. package/deploy/relay-vps/dsh-remote-relay.service +47 -0
  15. package/deploy/relay-vps/nginx.conf +49 -0
  16. package/deploy/relay-vps/relay.env.example +20 -0
  17. package/docs/dispatch-automation-eval.md +140 -0
  18. package/docs/screenshots/panel-main.png +0 -0
  19. package/docs/screenshots/panel-task-detail.png +0 -0
  20. package/docs/verification-agent-e2e.md +425 -0
  21. package/docs/verification-repos-routing-real.md +521 -0
  22. package/docs/verification-resume-real.md +254 -0
  23. package/docs/worker-setup-pitfalls.md +266 -0
  24. package/lib/artifacts.js +451 -0
  25. package/lib/client.js +1752 -0
  26. package/lib/e2e.js +304 -0
  27. package/lib/glob.js +225 -0
  28. package/lib/hub/audit-log.js +161 -0
  29. package/lib/hub/hub-util.js +114 -0
  30. package/lib/hub/index.js +500 -0
  31. package/lib/hub/node-registry.js +943 -0
  32. package/lib/hub/pull-manager.js +781 -0
  33. package/lib/hub/registry-handshake.js +403 -0
  34. package/lib/hub/registry-ingest.js +346 -0
  35. package/lib/hub/relay-link.js +228 -0
  36. package/lib/hub/subagent-provider.js +424 -0
  37. package/lib/hub/templates.js +693 -0
  38. package/lib/hub/tools.js +1059 -0
  39. package/lib/hub/ui-routes.js +856 -0
  40. package/lib/hub/views.js +351 -0
  41. package/lib/identity.js +271 -0
  42. package/lib/index.js +272 -0
  43. package/lib/node/artifact-service.js +231 -0
  44. package/lib/node/diff.js +124 -0
  45. package/lib/node/exec.js +536 -0
  46. package/lib/node/hub-pin.js +71 -0
  47. package/lib/node/index.js +1022 -0
  48. package/lib/node/repos.js +134 -0
  49. package/lib/node/resource.js +63 -0
  50. package/lib/node/runner.js +792 -0
  51. package/lib/node/task-engine.js +712 -0
  52. package/lib/node/task-store.js +273 -0
  53. package/lib/node/transport.js +763 -0
  54. package/lib/pairing.js +186 -0
  55. package/lib/protocol.js +414 -0
  56. package/lib/relay-config.js +317 -0
  57. package/lib/relay.js +732 -0
  58. package/lib/setup-routes.js +313 -0
  59. package/lib/setup.js +522 -0
  60. package/lib/ws.js +466 -0
  61. package/package.json +103 -4
  62. package/profiles/README.md +573 -0
  63. package/profiles/hub.cordis.yml +84 -0
  64. package/profiles/worker.cordis.yml +60 -0
  65. package/scripts/check-doc-drift.mjs +785 -0
  66. package/scripts/check-packaging.mjs +130 -0
  67. package/scripts/diag-inject.mjs +243 -0
  68. package/scripts/e2e-live-check.mjs +181 -0
  69. package/scripts/relay-init.ps1 +286 -0
  70. package/scripts/relay-init.sh +199 -0
@@ -0,0 +1,792 @@
1
+ /**
2
+ * 副机侧的 agent 会话桥:把一条远程任务变成副机本地的一次真实 DSH agent 运行。
3
+ *
4
+ * 为什么不是"在副机跑一条 shell 命令":
5
+ * 用户锁定的形态是 **B** —— 副机拥有独立 Agent 循环、独立模型上下文,
6
+ * 任务在副机本地持久运行,断连不终止。所以任务执行者必须是副机自己的 dsh。
7
+ *
8
+ * 两种后端,按可用性自动选择,对上层暴露同一个 run():
9
+ *
10
+ * 1. **in-process**(首选):worker 插件运行在副机 dsh 进程里,直接调
11
+ * `ctx.agentLoop.createAgent()` 起一个独立会话,把任务书 `followup()` 进它的
12
+ * inbox,`whenIdle()` 等它停稳,再从会话事件里取最终回答。
13
+ * 好处:没有第二个 dsh 进程、共享副机的模型配置与凭证(凭证本就在副机本地,不外传)。
14
+ *
15
+ * 2. **headless**(回退):worker 以独立进程运行(没有 dsh 上下文)时,spawn
16
+ * `dsh --profile <p> headless --json "<task>"`,解析 NDJSON 事件流取最终回答。
17
+ */
18
+ import { spawn } from 'node:child_process'
19
+ import fs from 'node:fs'
20
+ import { PLATFORM, detectPlatform } from './exec.js'
21
+
22
+ /**
23
+ * 本插件向副机 agent 投递任务书时的**消息来源身份**。
24
+ *
25
+ * ═══════════════════════════════════════════════════════════════════════════
26
+ * ⚠ 这个常量曾经写错,导致 agent 模式"假完成"(2026-10-15 定位)
27
+ * ═══════════════════════════════════════════════════════════════════════════
28
+ *
29
+ * 旧值:`{ kind: 'plugin', plugin: 'dsh-remote-agent' }`
30
+ *
31
+ * DSH 的 `MessageSourceMap` 是一张**封闭的联合类型表**,合法 kind 有 17 种:
32
+ *
33
+ * user · model · tool · system-prompt · model-selection · user-approval ·
34
+ * ptc-mode · tool-registry · user-rpc · agent-message · subagent-settled ·
35
+ * skill-invocation · cordis-host-runner · goal · schedule ·
36
+ * compact-checkpoint · session-reference · user-question-reply
37
+ *
38
+ * **里面没有 `plugin`。** 传一个不认识的 kind 时,`followup` 表面上成功
39
+ * (消息进 inbox、驱动被唤醒),但 turn 一启动就抛错 —— 而 DSH 的 `kick()`
40
+ * 里是 `catch (_error) {}`,**错误被静默吞掉**。结果:
41
+ *
42
+ * `whenIdle()` 在 ~20ms 内返回,`liveText` 为空,任务被标成 completed,
43
+ * 产物为 0 —— 而**没有任何错误信息**(连日志都没有)。
44
+ *
45
+ * `cordis-host-runner` 正是为"cordis 宿主里的 runner 主动驱动一个 agent"
46
+ * 这个场景准备的 kind,语义与我们完全对应。
47
+ *
48
+ * ── 教训 ────────────────────────────────────────────────────────────────
49
+ * 往 DSH 的封闭联合类型里塞自造值**不会立刻报错**,而是在某个被
50
+ * `catch {}` 包住的深层位置静默失效。改这类常量前先查 `MessageSourceMap`
51
+ * 的完整声明(用 cordis_inspect 的 `listService` 查 `sessions`)。
52
+ */
53
+ const PLUGIN_SOURCE = Object.freeze({ kind: 'cordis-host-runner' })
54
+
55
+ /**
56
+ * 从 dsh headless 的 NDJSON 事件流里提取最终回答。
57
+ * 只认能确定识别的形状,认不出就保留原始尾部,绝不编造。
58
+ */
59
+ export function extractFinalFromNdjson(text) {
60
+ const lines = text.split(/\r?\n/).filter((l) => l.trim() !== '')
61
+ let final = ''
62
+ const toolCalls = []
63
+ for (const line of lines) {
64
+ let ev
65
+ try {
66
+ ev = JSON.parse(line)
67
+ } catch {
68
+ continue
69
+ }
70
+ if (!ev || typeof ev !== 'object') continue
71
+ const t = String(ev.type ?? ev.kind ?? '')
72
+ if (t === 'assistant/message' || t === 'assistant.message' || t === 'message') {
73
+ const blocks = ev.message?.content ?? ev.content ?? ev.blocks
74
+ if (Array.isArray(blocks)) {
75
+ const txt = blocks.filter((b) => b && b.type === 'text').map((b) => b.text ?? '').join('')
76
+ if (txt) final = txt
77
+ } else if (typeof ev.text === 'string') {
78
+ final = ev.text
79
+ }
80
+ } else if (t === 'tool/call' || t === 'tool.call') {
81
+ toolCalls.push({ name: ev.name ?? ev.toolName ?? 'unknown', at: Date.now() })
82
+ } else if (typeof ev.final === 'string') {
83
+ final = ev.final
84
+ }
85
+ }
86
+ return { final, toolCalls, eventCount: lines.length }
87
+ }
88
+
89
+ /** 在副机 PATH 上找 dsh 可执行文件。 */
90
+ export function resolveDshBin(explicit) {
91
+ const candidates = [explicit, process.env.DSH_BIN, process.env.DSH_REMOTE_DSH_BIN].filter(Boolean)
92
+ for (const c of candidates) {
93
+ if (fs.existsSync(c)) return c
94
+ }
95
+ return process.platform === 'win32' ? 'dsh.cmd' : 'dsh'
96
+ }
97
+
98
+ /** 从事件流里找 sessionId(记录在任务里,方便人在副机本地回溯这次会话)。 */
99
+ export function extractSessionId(text) {
100
+ const m = text.match(/"sessionId"\s*:\s*"([^"]+)"/)
101
+ return m ? m[1] : null
102
+ }
103
+
104
+ /** 从 agent 会话里抽取助手文本与工具调用名。 */
105
+ function readSessionOutcome(session) {
106
+ const textParts = []
107
+ const toolCalls = []
108
+ if (!session || typeof session.ownEvents !== 'function') return { textParts, toolCalls }
109
+ let events
110
+ try {
111
+ events = session.ownEvents()
112
+ } catch {
113
+ return { textParts, toolCalls }
114
+ }
115
+ for (const ev of events) {
116
+ if (ev.type === 'assistant/message') {
117
+ const blocks = ev.data?.message?.content ?? []
118
+ const txt = blocks.filter((b) => b?.type === 'text').map((b) => b.text ?? '').join('')
119
+ if (txt) textParts.push(txt)
120
+ } else if (ev.type === 'tool/call') {
121
+ toolCalls.push({ name: ev.data?.name ?? 'unknown', at: ev.time ?? Date.now() })
122
+ }
123
+ }
124
+ return { textParts, toolCalls }
125
+ }
126
+
127
+ /**
128
+ * headless 后端:spawn 一个副机本地 dsh 进程跑任务。
129
+ */
130
+ export class HeadlessRunner {
131
+ constructor({ dshBin, profile, platform = detectPlatform(), extraArgs = [] } = {}) {
132
+ this.dshBin = resolveDshBin(dshBin)
133
+ this.profile = profile
134
+ this.platform = platform
135
+ this.extraArgs = extraArgs
136
+ this.children = new Set()
137
+ }
138
+
139
+ get kind() {
140
+ return 'headless'
141
+ }
142
+
143
+ /**
144
+ * @param {object} o
145
+ * @param {string} o.goal 任务书
146
+ * @param {string} o.cwd 工作区(副机绝对路径)
147
+ * @param {number} o.timeoutMs
148
+ * @param {AbortSignal} [o.signal]
149
+ * @param {(chunk:string, stream:string)=>void} [o.onData]
150
+ */
151
+ run(o) {
152
+ const { goal, cwd, timeoutMs, signal, onData } = o
153
+
154
+ // ═══════════════════════════════════════════════════════════════════
155
+ // headless 后端不支持主机指定模型 —— 明确报错,不静默忽略
156
+ // ═══════════════════════════════════════════════════════════════════
157
+ //
158
+ // `dsh headless` 的 run() 是 `const selection = defaultModel.currentSelection()`
159
+ // —— 模型**硬编码**为副机自己的默认选择,CLI 没有模型 flag(已对照
160
+ // app.asar 里的 dsh-headless 源码核实,2026-10-15)。
161
+ //
162
+ // 静默忽略是最坏的回退:主机以为派给了指定模型,副机实际跑的是默认模型
163
+ // —— 结果对得上、行为对不上,排查方向直接跑偏。所以这里如实失败,
164
+ // 让 hub 端看到明确的错误信息。
165
+ if (o.model && (o.model.model || o.model.provider)) {
166
+ return Promise.resolve({
167
+ ok: false,
168
+ exitCode: null,
169
+ final: '',
170
+ stderr:
171
+ 'headless 后端不支持指定模型(dsh headless 固定使用副机默认模型)。'
172
+ + '请在副机使用 in-process 后端(默认),或不指定 model 参数。',
173
+ toolCalls: [],
174
+ durationMs: 0,
175
+ sessionId: null,
176
+ modelUnsupported: true,
177
+ })
178
+ }
179
+
180
+ // ═══════════════════════════════════════════════════════════════════════
181
+ // 命令构造:**绝不能把 goal 拼进命令行**
182
+ // ═══════════════════════════════════════════════════════════════════════
183
+ //
184
+ // 旧实现:
185
+ // args.push('headless', '--json', ...this.extraArgs, goal)
186
+ // spawn(this.dshBin, args, { shell: this.platform === PLATFORM.WINDOWS })
187
+ //
188
+ // 两个问题叠加成一个**命令注入**向量(2026-10-15 独立评审发现):
189
+ //
190
+ // 1. `shell: true` 时 Node 把 args **用空格拼接**成一个命令行字符串,
191
+ // 交给 `cmd.exe /c` —— **不做任何转义**(Windows 上尤其如此)。
192
+ // 2. `goal` 是 `buildTaskBrief()` 生成的**多行 Markdown 任务书**,
193
+ // 含换行、`#`、`-`、引号,且内容来自远端**不可信**(任务书由 hub 侧
194
+ // 的调用方给出,中间还经过协议传输)。
195
+ //
196
+ // 后果:任务书里出现 `&`、`|`、`"` 或换行时,命令行会被 cmd 截断/拆开 ——
197
+ // 轻则调用损坏(表现为"任务莫名失败"),重则**执行任务书里夹带的第二条命令**。
198
+ //
199
+ // ── 修法 ──────────────────────────────────────────────────────────────
200
+ // ① **去掉 `shell: true`** —— `dshBin` 已由 `resolveDshBin()` 解析成绝对
201
+ // 路径,不需要 shell 参与解析。这样 args 走真正的 argv 传递,
202
+ // 元字符不再有特殊含义。
203
+ // ② **goal 从 stdin 传**,不进 argv —— 这是纵深防御:即使将来有人把
204
+ // `shell: true` 加回来,goal 也不在命令行上。
205
+ // (`--json` 模式下 dsh headless 从 stdin 读任务书;见 bin 的用法。)
206
+ //
207
+ // 注意 `--` 分隔符:它告诉 argv 解析器"后面全是位置参数",
208
+ // 防止 goal 以 `-` 开头时被当成选项。
209
+ const args = []
210
+ if (this.profile) args.push('--profile', this.profile)
211
+ args.push('headless', '--json', ...this.extraArgs, '-')
212
+ const useStdinForGoal = true
213
+
214
+ const startedAt = Date.now()
215
+ return new Promise((resolve) => {
216
+ let child
217
+ try {
218
+ child = spawn(this.dshBin, args, {
219
+ cwd: cwd && fs.existsSync(cwd) ? cwd : undefined,
220
+ env: { ...process.env },
221
+ windowsHide: true,
222
+ // stdin 要可写(goal 从那里进),stdout/stderr 照旧管道读。
223
+ stdio: ['pipe', 'pipe', 'pipe'],
224
+ // ⚠ 永远 false。见上面的说明 —— 这一行曾经是注入向量的成因之一。
225
+ shell: false,
226
+ })
227
+ } catch (err) {
228
+ resolve({
229
+ ok: false,
230
+ exitCode: null,
231
+ final: '',
232
+ stderr: String(err?.message ?? err),
233
+ toolCalls: [],
234
+ durationMs: 0,
235
+ sessionId: null,
236
+ spawnError: true,
237
+ })
238
+ return
239
+ }
240
+
241
+ this.children.add(child)
242
+ const outChunks = []
243
+ const errChunks = []
244
+ let settled = false
245
+ let timedOut = false
246
+
247
+ // 把任务书写进 stdin(argv 里传的是 `-`)。
248
+ // dsh headless 的文档明示:`task` 参数的 `-` 表示读 stdin。
249
+ // 这样 goal 完全不经过命令行 —— 元字符(`&` `|` `"` 换行)没有特殊含义。
250
+ //
251
+ // 写失败要处理:子进程可能立刻退出(例如 profile 不存在),此时 stdin
252
+ // 已关闭,write 会触发 EPIPE。吞掉它即可 —— 真正的失败会由 exit code
253
+ // 与 stderr 反映。
254
+ try {
255
+ if (useStdinForGoal && child.stdin) {
256
+ child.stdin.on('error', () => { /* EPIPE:子进程提前退出,由 exit 反映 */ })
257
+ child.stdin.end(String(goal ?? ''), 'utf8')
258
+ }
259
+ } catch {
260
+ /* 同上 */
261
+ }
262
+
263
+ const killTree = () => {
264
+ try {
265
+ if (this.platform === PLATFORM.WINDOWS) {
266
+ spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' })
267
+ } else {
268
+ try {
269
+ process.kill(-child.pid, 'SIGKILL')
270
+ } catch {
271
+ child.kill('SIGKILL')
272
+ }
273
+ }
274
+ } catch {
275
+ try {
276
+ child.kill('SIGKILL')
277
+ } catch {
278
+ /* 已退出 */
279
+ }
280
+ }
281
+ }
282
+
283
+ const timer = timeoutMs > 0
284
+ ? setTimeout(() => {
285
+ timedOut = true
286
+ killTree()
287
+ }, timeoutMs)
288
+ : undefined
289
+ if (timer?.unref) timer.unref()
290
+
291
+ const onAbort = () => killTree()
292
+ if (signal) {
293
+ if (signal.aborted) onAbort()
294
+ else signal.addEventListener('abort', onAbort, { once: true })
295
+ }
296
+
297
+ child.stdout?.on('data', (b) => {
298
+ const s = b.toString('utf8')
299
+ outChunks.push(s)
300
+ if (typeof onData === 'function') onData(s, 'stdout')
301
+ })
302
+ child.stderr?.on('data', (b) => {
303
+ const s = b.toString('utf8')
304
+ errChunks.push(s)
305
+ if (typeof onData === 'function') onData(s, 'stderr')
306
+ })
307
+
308
+ const finish = (code) => {
309
+ if (settled) return
310
+ settled = true
311
+ this.children.delete(child)
312
+ if (timer) clearTimeout(timer)
313
+ if (signal) signal.removeEventListener('abort', onAbort)
314
+ const stdout = outChunks.join('')
315
+ const parsed = extractFinalFromNdjson(stdout)
316
+ resolve({
317
+ ok: code === 0 && !timedOut,
318
+ exitCode: code,
319
+ timedOut,
320
+ final: parsed.final,
321
+ rawTail: stdout.slice(-4000),
322
+ stderr: errChunks.join('').slice(-8000),
323
+ toolCalls: parsed.toolCalls,
324
+ eventCount: parsed.eventCount,
325
+ durationMs: Date.now() - startedAt,
326
+ sessionId: extractSessionId(stdout),
327
+ })
328
+ }
329
+
330
+ child.on('error', (err) => {
331
+ errChunks.push(String(err?.message ?? err))
332
+ finish(null)
333
+ })
334
+ child.on('close', (code) => finish(code))
335
+ })
336
+ }
337
+
338
+ killAll() {
339
+ for (const c of this.children) {
340
+ try {
341
+ c.kill('SIGKILL')
342
+ } catch {
343
+ /* 已退出 */
344
+ }
345
+ }
346
+ this.children.clear()
347
+ }
348
+ }
349
+
350
+ /**
351
+ * 把"副机默认模型选择"与"主机指定的模型选择"合成 createAgent 的 agentOptions。
352
+ *
353
+ * 合成规则(主机指定的字段优先):
354
+ * - 两边都是 {provider, model} 成对 → 直接用主机的
355
+ * - 主机只给了 model → provider 用副机默认的("副机默认 provider + 主机指定的模型")
356
+ * - 主机只给了 provider → 无效(provider 必须配 model),退回默认
357
+ * - 主机什么都没给 → 副机默认(与 2026-10-15 修复后的行为一致)
358
+ * - 默认也没有 → undefined(DSH 自己的默认行为)
359
+ *
360
+ * reasoningEffort:主机给了就用主机的;没给继承默认。
361
+ *
362
+ * 纯函数,无副作用 —— 便于单测穷举合成矩阵。
363
+ */
364
+ export function buildAgentOptions(defaultSel, requested) {
365
+ const cleanPair = (sel) => {
366
+ if (!sel || typeof sel !== 'object') return undefined
367
+ const o = {}
368
+ if (typeof sel.provider === 'string' && sel.provider !== '') o.provider = sel.provider
369
+ if (typeof sel.model === 'string' && sel.model !== '') o.model = sel.model
370
+ if (sel.reasoningEffort !== undefined && sel.reasoningEffort !== null) {
371
+ o.reasoningEffort = sel.reasoningEffort
372
+ }
373
+ // provider 与 model 必须成对 —— 只给一个是无效选择,不如不给。
374
+ return o.provider && o.model ? o : undefined
375
+ }
376
+
377
+ const base = cleanPair(defaultSel) ?? {}
378
+ const req = requested && typeof requested === 'object' ? requested : {}
379
+
380
+ const model = typeof req.model === 'string' && req.model !== '' ? req.model : base.model
381
+ const provider =
382
+ typeof req.provider === 'string' && req.provider !== ''
383
+ ? req.provider
384
+ : typeof req.model === 'string' && req.model !== ''
385
+ ? base.provider // 只指定模型 → 借副机默认 provider
386
+ : base.provider
387
+
388
+ if (!provider || !model) return base.provider && base.model ? base : undefined
389
+
390
+ const out = { provider, model }
391
+ const effort =
392
+ typeof req.reasoningEffort === 'string' && req.reasoningEffort !== ''
393
+ ? req.reasoningEffort
394
+ : base.reasoningEffort
395
+ if (effort) out.reasoningEffort = effort
396
+ return out
397
+ }
398
+
399
+ /**
400
+ * in-process 后端:在副机 dsh 进程里起一个真实 agent 会话。
401
+ *
402
+ * 用 ctx.agentLoop.createAgent() 创建带独立 id 的会话,把任务书 followup 进 inbox,
403
+ * 用 whenIdle() 等它停稳。这比 headless 少一个进程,且直接复用副机的
404
+ * 模型配置 / 凭证 / 插件 —— 凭证一步都没有离开副机。
405
+ */
406
+ export class InProcessRunner {
407
+ /**
408
+ * @param {object} o
409
+ * @param {import('@deepseek-ai/cordis').Context} o.ctx 副机 dsh 的根上下文
410
+ * @param {string} o.nodeId
411
+ * @param {string} [o.preset] 指定 agent preset(可选)
412
+ */
413
+ constructor({ ctx, nodeId, preset }) {
414
+ this.ctx = ctx
415
+ this.nodeId = nodeId
416
+ this.preset = preset
417
+ this.active = new Map() // taskId → { handle, controller }
418
+ }
419
+
420
+ get kind() {
421
+ return 'in-process'
422
+ }
423
+
424
+ /**
425
+ * 这两个服务是否可用。
426
+ *
427
+ * 必须用 `ctx.get(name)` 而不是 `ctx.agentLoop` —— 在 cordis 里访问一个
428
+ * **未在 inject 中声明**的服务属性会**抛错**(`cannot get property "agentLoop"
429
+ * without inject`),而不是返回 undefined。所以可选链 `ctx?.agentLoop` 挡不住它,
430
+ * 反而让"探测可用性"这一步自己炸掉:实测在 headless profile(无 agentLoop)里
431
+ * 启动 worker 会得到 `worker disabled after startup error cannot get property
432
+ * "agentLoop" without inject` —— 一个本该优雅降级的地方变成了启动失败。
433
+ */
434
+ get available() {
435
+ const ctx = this.ctx
436
+ if (!ctx || typeof ctx.get !== 'function') return false
437
+ try {
438
+ return Boolean(ctx.get('agentLoop') && ctx.get('sessions'))
439
+ } catch {
440
+ // get() 本身在某些组合下也可能抛;探测失败一律当作"不可用",交给 headless 后端
441
+ return false
442
+ }
443
+ }
444
+
445
+ /**
446
+ * @param {object} o
447
+ * @param {string} o.taskId
448
+ * @param {string} o.goal
449
+ * @param {string} o.cwd
450
+ * @param {number} o.timeoutMs
451
+ * @param {AbortSignal} [o.signal]
452
+ * @param {(evt:object)=>void} [o.onEvent] 会话事件回调(用于进度)
453
+ */
454
+ async run(o) {
455
+ const { taskId, goal, cwd, timeoutMs, signal, onEvent } = o
456
+ const startedAt = Date.now()
457
+
458
+ if (!this.available) {
459
+ return {
460
+ ok: false,
461
+ exitCode: null,
462
+ final: '',
463
+ stderr: 'agentLoop/sessions service unavailable in this dsh composition',
464
+ toolCalls: [],
465
+ durationMs: 0,
466
+ sessionId: null,
467
+ unavailable: true,
468
+ }
469
+ }
470
+
471
+ const controller = new AbortController()
472
+ const onOuterAbort = () => controller.abort()
473
+ if (signal) {
474
+ if (signal.aborted) controller.abort()
475
+ else signal.addEventListener('abort', onOuterAbort, { once: true })
476
+ }
477
+
478
+ let timer
479
+ let timedOut = false
480
+ const timeoutPromise = new Promise((resolve) => {
481
+ if (!(timeoutMs > 0)) return
482
+ timer = setTimeout(() => {
483
+ timedOut = true
484
+ controller.abort()
485
+ resolve({ timedOut: true })
486
+ }, timeoutMs)
487
+ if (timer.unref) timer.unref()
488
+ })
489
+
490
+ const sessionIdFor = `session-remote-${String(taskId).replace(/[^A-Za-z0-9-]/g, '').slice(0, 48)}`
491
+ let handle
492
+ let disposeListener
493
+ let disposeAgentErrorListener
494
+ const liveText = []
495
+ /**
496
+ * agent 后端的**按需**流程日志。
497
+ *
498
+ * ═══════════════════════════════════════════════════════════════════════
499
+ * 这个东西救过一次大忙,但默认必须是关的(2026-10-15 两次修正)
500
+ * ═══════════════════════════════════════════════════════════════════════
501
+ *
502
+ * **为什么留着**:它定位了「agent 模式 20ms 假完成、零输出、零报错」——
503
+ * 一个 DSH 用 `catch (_error) {}` 吞掉错误、因而完全不可观测的故障
504
+ * (根因是消息 source 类型非法,见 CHANGELOG)。没有它这条路径是黑的。
505
+ *
506
+ * **为什么默认关**:第一版写死 `<temp>/dsh-remote-agent-runner.log`、
507
+ * 无条件 `appendFileSync`,有两个问题:
508
+ * 1. **无限增长** —— 长期运行的副机会把这个文件刷到任意大小
509
+ * 2. **泄漏信息** —— 记录里含 taskId、goal 长度、cwd、错误正文
510
+ *
511
+ * **怎么开**:设环境变量 `DSH_REMOTE_AGENT_TRACE=1`(或 `true`/`yes`)。
512
+ * 排查时临时开、排查完关掉。同时受 `MAX_TRACE_BYTES` 约束:
513
+ * 超过就整文件轮转一次(只留最近的一段),不会无限长。
514
+ */
515
+ const TRACE_ON = /^(1|true|yes|on)$/i.test(String(process.env.DSH_REMOTE_AGENT_TRACE ?? ''))
516
+ const traceFile = `${process.env.TEMP || process.env.TMP || '/tmp'}/dsh-remote-agent-runner.log`
517
+ const MAX_TRACE_BYTES = 2 * 1024 * 1024
518
+ const dlog = (msg, extra) => {
519
+ if (!TRACE_ON) return
520
+ try {
521
+ // 轮转:超上限就换成只写开头一行标记,避免无限增长。
522
+ try {
523
+ const st = fs.statSync(traceFile)
524
+ if (st.size > MAX_TRACE_BYTES) {
525
+ fs.writeFileSync(traceFile, `${new Date().toISOString()} [trace rotated, previous exceeded ${MAX_TRACE_BYTES} bytes]\n`, 'utf8')
526
+ }
527
+ } catch {
528
+ /* 文件不存在 = 首次写,无需轮转 */
529
+ }
530
+ fs.appendFileSync(traceFile, `${new Date().toISOString()} ${msg}${extra ? ' ' + JSON.stringify(extra) : ''}\n`, 'utf8')
531
+ } catch {
532
+ /* 诊断不影响主流程 */
533
+ }
534
+ }
535
+ dlog('InProcessRunner.run ENTER', {
536
+ taskId,
537
+ goalLen: String(goal ?? '').length,
538
+ cwd,
539
+ timeoutMs,
540
+ available: this.available,
541
+ hasAgentLoop: Boolean(this.ctx?.get?.('agentLoop')),
542
+ hasSessions: Boolean(this.ctx?.get?.('sessions')),
543
+ // 注意:agentDefaultModel 缺了**不算不可用** —— 它只是"能提供默认模型"。
544
+ // 缺了会走"不指定模型"的路径,而那条路会在 system prompt 组装时报
545
+ // `{{model}} has no value`(见下面 createAgent 处的说明)。
546
+ // 所以这里记下来,便于区分"服务缺失"与"部署没配默认模型"。
547
+ hasAgentDefaultModel: Boolean(this.ctx?.get?.('agentDefaultModel')),
548
+ })
549
+
550
+ try {
551
+ // 用 ctx.get() 取服务,不用属性访问 —— 见 available getter 的说明
552
+ const agentLoop = this.ctx.get('agentLoop')
553
+ const sessions = this.ctx.get('sessions')
554
+
555
+ // ═══════════════════════════════════════════════════════════════════
556
+ // 必须显式带上模型选择 —— 否则 turn 1 就抛错(2026-10-15 定位)
557
+ // ═══════════════════════════════════════════════════════════════════
558
+ //
559
+ // 旧实现传 `agentOptions: undefined`,即"不指定模型"。结果是
560
+ // system prompt 组装时报:
561
+ // prompt variable "{{model}}" has no value for this assembly
562
+ // (section "deployment:persona-prefix")
563
+ //
564
+ // 即:persona 前缀段用了 `{{model}}` 变量,而**没有选定模型时它没有值**。
565
+ // 而 DSH 的 `kick()` 是 `catch (_error) {}` —— 错误被吞、phase 转 idle、
566
+ // `whenIdle()` 立即返回,任务被标 completed 且产物为空,**全程无报错**。
567
+ //
568
+ // 正解:从 `agentDefaultModel` 取部署的默认选择填进去,再叠加主机在
569
+ // 派单时指定的 `model`(remote_task 的可选参数)。合成规则见
570
+ // buildAgentOptions —— 主机指定的字段优先,缺的字段借副机默认补齐。
571
+ // `currentSelection()` 的文档:"Read the current default model selection.
572
+ // returns a detached provider, model, and optional reasoning selection."
573
+ // 这个服务是**可选**的(用 ctx.get 探测),取不到时只靠主机指定 ——
574
+ // 若主机也没指定,则回到"不传"路径(部署配置问题,不是本插件能补救的)。
575
+ const agentOptions = buildAgentOptions(
576
+ (() => {
577
+ try {
578
+ const dm = typeof this.ctx.get === 'function' ? this.ctx.get('agentDefaultModel') : undefined
579
+ return dm && typeof dm.currentSelection === 'function' ? dm.currentSelection() : undefined
580
+ } catch {
581
+ return undefined
582
+ }
583
+ })(),
584
+ o.model,
585
+ )
586
+
587
+ dlog('before createAgent', { sessionIdFor, cwd, agentOptions })
588
+ handle = await agentLoop.createAgent(this.ctx, {
589
+ sessionId: sessionIdFor,
590
+ meta: { cwd },
591
+ signal: controller.signal,
592
+ agentOptions,
593
+ })
594
+ const agent = handle.agent
595
+ const session = agent.session ?? sessions.get(agent.id)
596
+ dlog('after createAgent', {
597
+ agentId: agent?.id ?? null,
598
+ hasSession: Boolean(session),
599
+ sessionStatus: session?.status ?? null,
600
+ handleKeys: (() => { try { return Object.keys(handle ?? {}).slice(0, 10) } catch { return null } })(),
601
+ })
602
+
603
+ this.active.set(taskId, { handle, controller })
604
+
605
+ // 观察事件:给 hub 送进度,并在会话日志之外保留一份实时文本(防日志投影差异)
606
+ if (typeof this.ctx.on === 'function') {
607
+ disposeListener = this.ctx.on('session/event', (s, event) => {
608
+ if (!s || s.id !== agent.id) return
609
+ if (event.type === 'assistant/message') {
610
+ const blocks = event.data?.message?.content ?? []
611
+ const txt = blocks.filter((b) => b?.type === 'text').map((b) => b.text ?? '').join('')
612
+ if (txt) {
613
+ liveText.push(txt)
614
+ if (typeof onEvent === 'function') onEvent({ kind: 'assistant', text: txt.slice(0, 2000) })
615
+ }
616
+ } else if (event.type === 'tool/call') {
617
+ if (typeof onEvent === 'function') onEvent({ kind: 'tool', name: event.data?.name ?? 'unknown' })
618
+ }
619
+ })
620
+ }
621
+ // ⚠ 必须单独监听 `agent/error` —— 否则 turn 内部的错误**完全不可见**。
622
+ //
623
+ // DSH 的 `kick()` 里是 `try { while (await this.turn()) } catch (_error) {}`:
624
+ // 任何 turn 错误都被吞掉,只通过 `agent/error` 事件暴露(见 Agent 的
625
+ // `throwError()`)。我们曾因为漏了这条监听,把一个"消息 source 类型非法"
626
+ // 的错误查了很久 —— 表现只是"任务 20ms 完成、无输出、无报错"。
627
+ //
628
+ // 这里把错误记进诊断日志,并塞进 liveText,让它在 handoff 摘要里也可见。
629
+ //
630
+ // ⚠ 错误对象的形状不受我们控制:第一版只取 `err?.message`,结果记到的是
631
+ // `[object Object]`(DSH 发的 payload 是嵌套对象,没有顶层 message)。
632
+ // 所以这里做**深度序列化**:先试常见字段,再退到 JSON.stringify,
633
+ // 最后兜底到 String()。任何一步都不能抛。
634
+ const describeError = (e) => {
635
+ if (e === null || e === undefined) return String(e)
636
+ if (typeof e === 'string') return e
637
+ if (typeof e !== 'object') return String(e)
638
+ // 常见字段优先(FiberError / CordisError / LlmFailure 之类)
639
+ for (const k of ['message', 'reason', 'error', 'failure', 'code', 'name']) {
640
+ const v = e[k]
641
+ if (typeof v === 'string' && v !== '') {
642
+ return k === 'message' ? v : `${k}=${v}`
643
+ }
644
+ }
645
+ // 深度:把可枚举字段拼出来(含嵌套一层)
646
+ try {
647
+ const flat = {}
648
+ for (const k of Object.keys(e)) {
649
+ const v = e[k]
650
+ if (v === undefined) continue
651
+ if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') flat[k] = v
652
+ else if (v === null) flat[k] = null
653
+ else if (typeof v === 'object') {
654
+ // 嵌套对象再取一层常见字段,避免无限深
655
+ const inner = {}
656
+ for (const k2 of ['message', 'code', 'name', 'kind', 'status', 'failure']) {
657
+ const v2 = v[k2]
658
+ if (typeof v2 === 'string' || typeof v2 === 'number' || typeof v2 === 'boolean') inner[k2] = v2
659
+ }
660
+ flat[k] = Object.keys(inner).length > 0 ? inner : String(v)
661
+ } else flat[k] = String(v)
662
+ }
663
+ const s = JSON.stringify(flat)
664
+ if (s && s !== '{}') return s
665
+ } catch {
666
+ /* 循环引用等 */
667
+ }
668
+ try {
669
+ return JSON.stringify(e)
670
+ } catch {
671
+ return String(e)
672
+ }
673
+ }
674
+
675
+ if (typeof this.ctx.on === 'function') {
676
+ disposeAgentErrorListener = this.ctx.on('agent/error', (err, agentInfo) => {
677
+ try {
678
+ const a = agentInfo?.agent ?? agentInfo
679
+ if (a && a.id !== undefined && agent.id !== undefined && a.id !== agent.id) return
680
+ const msg = describeError(err)
681
+ dlog('agent/error', { msg: msg.slice(0, 1200), rawKeys: (() => { try { return Object.keys(err ?? {}) } catch { return null } })() })
682
+ liveText.push(`[agent 错误] ${msg}`)
683
+ if (typeof onEvent === 'function') onEvent({ kind: 'error', text: msg.slice(0, 2000) })
684
+ } catch {
685
+ /* 诊断不影响主流程 */
686
+ }
687
+ })
688
+ }
689
+
690
+ // 投递任务书:followup 排队下一轮并唤醒驱动器。
691
+ // 必须带 plugin source —— 省略 source 会被当成人类输入,这是不能继承的权限。
692
+ dlog('before followup', { hasFollowup: typeof agent.followup === 'function' })
693
+ agent.followup({
694
+ content: [{ type: 'text', text: goal }],
695
+ source: PLUGIN_SOURCE,
696
+ })
697
+ dlog('after followup, before whenIdle', { hasWhenIdle: typeof agent.whenIdle === 'function' })
698
+
699
+ const raced = await Promise.race([
700
+ agent.whenIdle().then(() => ({ timedOut: false })),
701
+ timeoutPromise,
702
+ ])
703
+ if (raced.timedOut === true) timedOut = true
704
+ dlog('whenIdle resolved', { timedOut, liveTextLen: liveText.join('').length, elapsedMs: Date.now() - startedAt })
705
+
706
+ // 会话日志优先;日志取不到就用实时累积
707
+ const fromLog = readSessionOutcome(session)
708
+ const finalText = fromLog.textParts.length > 0 ? fromLog.textParts.join('\n\n').trim() : liveText.join('\n\n').trim()
709
+ dlog('outcome read', {
710
+ fromLogParts: fromLog.textParts.length,
711
+ fromLogToolCalls: fromLog.toolCalls.length,
712
+ finalLen: finalText.length,
713
+ finalHead: finalText.slice(0, 200),
714
+ })
715
+
716
+ return {
717
+ ok: !timedOut,
718
+ exitCode: timedOut ? null : 0,
719
+ timedOut,
720
+ final: finalText,
721
+ stderr: '',
722
+ toolCalls: fromLog.toolCalls,
723
+ durationMs: Date.now() - startedAt,
724
+ sessionId: agent.id,
725
+ }
726
+ } catch (err) {
727
+ return {
728
+ ok: false,
729
+ exitCode: null,
730
+ final: liveText.join('\n\n').trim(),
731
+ stderr: String(err?.message ?? err),
732
+ toolCalls: [],
733
+ durationMs: Date.now() - startedAt,
734
+ sessionId: handle?.agent?.id ?? sessionIdFor,
735
+ }
736
+ } finally {
737
+ if (timer) clearTimeout(timer)
738
+ if (signal) signal.removeEventListener('abort', onOuterAbort)
739
+ this.active.delete(taskId)
740
+ try {
741
+ disposeListener?.()
742
+ } catch {
743
+ /* 上下文已卸载 */
744
+ }
745
+ try {
746
+ disposeAgentErrorListener?.()
747
+ } catch {
748
+ /* 同上 */
749
+ }
750
+ try {
751
+ await handle?.dispose()
752
+ } catch {
753
+ /* agent 可能已自行结束 */
754
+ }
755
+ }
756
+ }
757
+
758
+ /** 中断一个在跑的任务(hub 发 task/cancel 时调用)。 */
759
+ async cancel(taskId) {
760
+ const rec = this.active.get(taskId)
761
+ if (!rec) return false
762
+ try {
763
+ rec.handle.agent.cancel({ kind: 'user' })
764
+ } catch {
765
+ /* agent 可能已停 */
766
+ }
767
+ try {
768
+ rec.controller.abort()
769
+ } catch {
770
+ /* 已中止 */
771
+ }
772
+ return true
773
+ }
774
+
775
+ /** 插件卸载:中断全部在跑任务(任务记录留在磁盘上,重启后可查)。 */
776
+ async disposeAll() {
777
+ for (const [taskId] of this.active) {
778
+ await this.cancel(taskId)
779
+ }
780
+ this.active.clear()
781
+ }
782
+ }
783
+
784
+ /**
785
+ * 按可用性挑一个后端。
786
+ * in-process 优先 —— 少一个进程,且模型凭证不用经过任何额外路径。
787
+ */
788
+ export function selectRunner({ ctx, nodeId, profile, dshBin, preferHeadless = false }) {
789
+ const inProc = new InProcessRunner({ ctx, nodeId })
790
+ if (!preferHeadless && inProc.available) return inProc
791
+ return new HeadlessRunner({ dshBin, profile })
792
+ }