dsh-subagent-profile 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.mjs ADDED
@@ -0,0 +1,1239 @@
1
+ // index.mjs — dsh-subagent-profile host half (formal plugin bundle).
2
+ // Converted from prototype/subagent-profile/host.plugin.js (the dynamic-plugin
3
+ // `code.host` body) with import slimming: the inline foldConsumedWork /
4
+ // accountsForClaim / lastAssistantContent / uuid / AbortController-shim are
5
+ // replaced by package imports or platform primitives where semantics match
6
+ // exactly, and the harness-only APIs (registerTool/defineTool/handle) are
7
+ // mapped to their bundle equivalents or guarded. See the import mapping table
8
+ // in README.md.
9
+
10
+ import { randomUUID } from 'node:crypto';
11
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs';
12
+ import { homedir } from 'node:os';
13
+ import { basename, dirname, join, relative } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { foldConsumedWork } from '@deepseek-ai/dsh-agent';
16
+ import {
17
+ appendDelegatedPolicyOverrides,
18
+ assertSubagentMaxDepth,
19
+ captureDelegatedPolicyOverrides,
20
+ finalAssistantOutput,
21
+ resolveChildAgentOptions,
22
+ resolveChildDepth,
23
+ } from '@deepseek-ai/dsh-subagent';
24
+ import { createUserMessage } from '@deepseek-ai/dsh-llm';
25
+ import { defineTool } from '@deepseek-ai/dsh-tools';
26
+
27
+ export const name = 'dsh-subagent-profile';
28
+ export const inject = ['subagents', 'tools', 'agents'];
29
+
30
+ // Resolve the DSH home directory (env override wins, platform fallback) — the
31
+ // same policy as the dsh-persona-ref bundle: user profiles persist to
32
+ // ~/.dsh/subagent-profiles.json, stable across harness working directories.
33
+ function dshHome() {
34
+ const raw = process.env.DSH_HOME;
35
+ if (typeof raw === 'string' && raw.trim() !== '') {
36
+ const trimmed = raw.trim();
37
+ if (trimmed === '~') return homedir();
38
+ if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) return join(homedir(), trimmed.slice(2));
39
+ return trimmed;
40
+ }
41
+ return join(homedir(), '.dsh');
42
+ }
43
+
44
+ // --- bundled agent-preset self-install --------------------------------------
45
+ // On host startup the plugin syncs the bundled `presets/` tree into the DSH
46
+ // agent-presets discovery root (~/.dsh/.agent-presets) so the "orchestrator"
47
+ // mode appears in the new-session picker without manual copying — the same
48
+ // self-install pattern as the shipped dsh-liangshen bundle. The sync is
49
+ // per-directory and idempotent (byte-identical trees are skipped; target files
50
+ // the bundle no longer ships are pruned); directories the plugin does not own
51
+ // are never touched. node:fs cpSync is avoided deliberately: on Node 22 for
52
+ // Windows, fs.cpSync({ recursive: true }) can crash the process when a source
53
+ // path contains non-ASCII (CJK home dir, nodejs/node#54476), so the copy is
54
+ // per-entry, preserving source mtimes.
55
+
56
+ // Absolute path of the bundled preset tree inside this package.
57
+ function bundledPresetsRoot() {
58
+ return join(dirname(fileURLToPath(import.meta.url)), 'presets');
59
+ }
60
+
61
+ const MTIME_TOLERANCE_MS = 1000;
62
+
63
+ function filesUnder(root) {
64
+ const out = [];
65
+ const walk = (dir) => {
66
+ for (const entry of readdirSync(dir)) {
67
+ const path = join(dir, entry);
68
+ if (statSync(path).isDirectory()) walk(path);
69
+ else out.push(path);
70
+ }
71
+ };
72
+ walk(root);
73
+ return out;
74
+ }
75
+
76
+ // File identity is bytes; size/mtime are only a fast negative check.
77
+ function sameFile(a, b) {
78
+ const sa = statSync(a);
79
+ const sb = statSync(b);
80
+ if (sa.size !== sb.size) return false;
81
+ if (Math.abs(sa.mtimeMs - sb.mtimeMs) > MTIME_TOLERANCE_MS) return false;
82
+ return readFileSync(a).equals(readFileSync(b));
83
+ }
84
+
85
+ function copyTreeSync(sourceDir, targetDir) {
86
+ mkdirSync(targetDir, { recursive: true });
87
+ for (const entry of readdirSync(sourceDir)) {
88
+ const source = join(sourceDir, entry);
89
+ const target = join(targetDir, entry);
90
+ const st = statSync(source);
91
+ if (st.isDirectory()) copyTreeSync(source, target);
92
+ else {
93
+ copyFileSync(source, target);
94
+ utimesSync(target, st.atime, st.mtime);
95
+ }
96
+ }
97
+ }
98
+
99
+ // Remove target files not in `keep`, then only the directories emptied by it.
100
+ function pruneExtras(root, keep) {
101
+ const parents = new Set();
102
+ for (const file of filesUnder(root)) {
103
+ if (!keep.has(relative(root, file))) {
104
+ parents.add(dirname(file));
105
+ rmSync(file, { force: true });
106
+ }
107
+ }
108
+ for (const start of parents) {
109
+ let dir = start;
110
+ while (dir !== undefined && relative(root, dir) !== '') {
111
+ if (existsSync(dir) && readdirSync(dir).length === 0) {
112
+ rmSync(dir, { recursive: true, force: true });
113
+ dir = dirname(dir);
114
+ } else dir = undefined;
115
+ }
116
+ }
117
+ }
118
+
119
+ // Copy `sourceDir` into `targetDir` idempotently; returns 'synced' or 'current'.
120
+ function syncOnePreset(sourceDir, targetDir) {
121
+ const sourceFiles = filesUnder(sourceDir);
122
+ const sourceSet = new Set(sourceFiles.map((f) => relative(sourceDir, f)));
123
+ if (existsSync(targetDir) && !statSync(targetDir).isDirectory()) {
124
+ rmSync(targetDir, { recursive: true, force: true });
125
+ }
126
+ if (!existsSync(targetDir)) {
127
+ copyTreeSync(sourceDir, targetDir);
128
+ pruneExtras(targetDir, sourceSet);
129
+ return 'synced';
130
+ }
131
+ let dirty = false;
132
+ for (const file of sourceFiles) {
133
+ const dest = join(targetDir, relative(sourceDir, file));
134
+ if (!existsSync(dest) || !sameFile(file, dest)) { dirty = true; break; }
135
+ }
136
+ if (!dirty) {
137
+ for (const file of filesUnder(targetDir)) {
138
+ if (!sourceSet.has(relative(targetDir, file))) { dirty = true; break; }
139
+ }
140
+ }
141
+ if (!dirty) return 'current';
142
+ pruneExtras(targetDir, sourceSet);
143
+ copyTreeSync(sourceDir, targetDir);
144
+ pruneExtras(targetDir, sourceSet);
145
+ return 'synced';
146
+ }
147
+
148
+ // Sync every preset directory under `presets/` into the target discovery root.
149
+ function syncBundledPresets(targetRoot) {
150
+ const result = { synced: [], current: [], failed: [] };
151
+ const sourceRoot = bundledPresetsRoot();
152
+ mkdirSync(targetRoot, { recursive: true });
153
+ if (existsSync(sourceRoot)) {
154
+ for (const entry of readdirSync(sourceRoot)) {
155
+ const source = join(sourceRoot, entry);
156
+ if (!statSync(source).isDirectory()) continue;
157
+ const id = basename(source);
158
+ try {
159
+ const outcome = syncOnePreset(source, join(targetRoot, id));
160
+ (outcome === 'synced' ? result.synced : result.current).push(id);
161
+ } catch (error) {
162
+ result.failed.push({ id, error: error instanceof Error ? error.message : String(error) });
163
+ }
164
+ }
165
+ }
166
+ return result;
167
+ }
168
+
169
+ // Only the loopback interfaces may drive the settings HTTP routes.
170
+ const LOOPBACKS = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
171
+
172
+ // The plugin's enable/disable switch. Persisted beside the profile list so a
173
+ // user can turn the dispatch tool off without uninstalling the bundle. Default
174
+ // is enabled; a missing/unreadable file falls back to enabled.
175
+ function stateFile() {
176
+ return join(dshHome(), 'subagent-profiles.state.json');
177
+ }
178
+ function loadEnabled() {
179
+ try {
180
+ if (existsSync(stateFile())) {
181
+ const parsed = JSON.parse(readFileSync(stateFile(), 'utf8'));
182
+ return parsed && parsed.enabled !== false;
183
+ }
184
+ } catch {
185
+ // fall through to enabled
186
+ }
187
+ return true;
188
+ }
189
+ function persistEnabled(enabled) {
190
+ try {
191
+ writeFileSync(stateFile(), JSON.stringify({ enabled }, null, 2), 'utf8');
192
+ } catch {
193
+ // best effort — the in-memory state still drives this process
194
+ }
195
+ }
196
+
197
+ // --- module-level helpers (kept inline; the packages do not export them) ---
198
+
199
+ function textFrom(blocks) {
200
+ return (Array.isArray(blocks) ? blocks : [])
201
+ .filter((block) => block && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string')
202
+ .map((block) => block.text)
203
+ .join('');
204
+ }
205
+
206
+ // Shipped toStopReason: map a turn-end reason to the seam's terminal vocabulary.
207
+ function toStopReason(reason) {
208
+ switch (reason?.kind) {
209
+ case 'completed': return 'completed';
210
+ case 'max-tokens': return 'max-tokens';
211
+ case 'aborted': return 'aborted';
212
+ case 'blocked': return 'refusal';
213
+ default: return 'error';
214
+ }
215
+ }
216
+
217
+ // readResult: shipped shape. The terminal turn reason comes from the imported
218
+ // foldConsumedWork; the selected output comes from the imported
219
+ // finalAssistantOutput (last non-empty assistant message, else joined
220
+ // text-delta chunks, else undefined -> []).
221
+ function readResult(child, boundary, cancelled) {
222
+ const own = child.session.events.slice(boundary);
223
+ const end = foldConsumedWork(own).end;
224
+ const recorded = toStopReason(end?.data.reason);
225
+ const stopReason = cancelled && recorded !== 'completed' ? 'aborted' : recorded;
226
+ return { output: finalAssistantOutput(own) ?? [], stopReason };
227
+ }
228
+
229
+ // Shipped stopReasonError + withPartialText wording (dsh-tool-subagent L55-75).
230
+ function stopReasonError(result) {
231
+ switch (result.stopReason) {
232
+ case 'completed': return;
233
+ case 'aborted': return 'dispatch: subagent run was cancelled';
234
+ case 'error': return 'dispatch: subagent run failed';
235
+ case 'max-tokens': return 'dispatch: subagent run hit its token limit before finishing';
236
+ case 'refusal': return 'dispatch: subagent declined the task';
237
+ default: return `dispatch: subagent run ended abnormally (${String(result.stopReason)})`;
238
+ }
239
+ }
240
+
241
+ function withPartialText(error, output) {
242
+ const text = (Array.isArray(output) ? output : [])
243
+ .filter((block) => block && block.type === 'text')
244
+ .map((block) => block.text)
245
+ .join('');
246
+ return text.length === 0 ? error : `${error}\nPartial output before the run ended:\n${text}`;
247
+ }
248
+
249
+ // F5: conservative delegation caps for maxTokens / maxDepth.
250
+ const MAX_TOKENS = 65536;
251
+ const MAX_DEPTH = 3;
252
+
253
+ // F5: runtime-derived cost guard. Reads the optional `llm` service (undefined =>
254
+ // guard skipped) and validates provider / model / reasoningEffort against the
255
+ // live provider directory, plus the maxTokens/maxDepth caps. Used by both the
256
+ // provider's authoritative check and the dispatch tool's pre-check.
257
+ async function assertCostGuard(parent, profile) {
258
+ const llm = parent.ctx.get('llm');
259
+ if (llm === undefined) return;
260
+ if (typeof profile.provider === 'string' && profile.provider.length > 0) {
261
+ const providers = await llm.listProviders();
262
+ if (!(providers ?? []).some((provider) => provider && provider.id === profile.provider)) {
263
+ throw new Error(`dispatch: provider "${profile.provider}" is not a registered provider`);
264
+ }
265
+ }
266
+ const effectiveProvider = profile.provider !== undefined ? profile.provider : parent.options.provider;
267
+ const effectiveModel = profile.model !== undefined ? profile.model : parent.options.model;
268
+ if (typeof profile.model === 'string' && profile.model.length > 0) {
269
+ // resolveModelInfo does not reject unknown models (catalog membership is
270
+ // advisory), so validate against the advertised catalog instead. An EMPTY
271
+ // catalog (adapter without discovery) cannot be verified and is skipped; a
272
+ // non-empty catalog that does not advertise the model fails loud. An
273
+ // unverifiable lookup (listModels(undefined) when no provider is known)
274
+ // becomes a clean fail-loud error instead of leaking "undefined".
275
+ let models;
276
+ try {
277
+ models = await llm.listModels(effectiveProvider);
278
+ } catch (error) {
279
+ throw new Error(`dispatch: cannot validate model "${profile.model}" without a provider: ${error instanceof Error ? error.message : String(error)}`);
280
+ }
281
+ const listed = models ?? [];
282
+ const known = listed.length > 0 && listed.some((model) => model && (model.id === profile.model || model.name === profile.model));
283
+ // Throw only when the catalog is non-empty AND the model is not in it; an
284
+ // empty catalog (adapter without discovery) is skipped, not rejected.
285
+ if (listed.length > 0 && !known) {
286
+ throw new Error(`dispatch: model "${profile.model}" is not advertised by provider "${String(effectiveProvider)}"`);
287
+ }
288
+ }
289
+ if (typeof profile.reasoningEffort === 'string' && profile.reasoningEffort.length > 0) {
290
+ try {
291
+ await llm.resolveCallConfig({ provider: effectiveProvider, model: effectiveModel, reasoningEffort: profile.reasoningEffort });
292
+ } catch (error) {
293
+ throw new Error(`dispatch: reasoningEffort "${profile.reasoningEffort}" is not supported by provider "${String(effectiveProvider)}" model "${String(effectiveModel)}": ${error instanceof Error ? error.message : String(error)}`);
294
+ }
295
+ }
296
+ if (typeof profile.maxTokens === 'number' && profile.maxTokens > MAX_TOKENS) {
297
+ throw new Error(`dispatch: maxTokens ${profile.maxTokens} exceeds the delegation cap ${MAX_TOKENS}`);
298
+ }
299
+ if (typeof profile.maxDepth === 'number' && profile.maxDepth > MAX_DEPTH) {
300
+ throw new Error(`dispatch: maxDepth ${profile.maxDepth} exceeds the delegation cap ${MAX_DEPTH}`);
301
+ }
302
+ }
303
+
304
+ // Settle one background one-shot run into a job outcome with the same
305
+ // observability metadata the foreground path reports. Non-completed stop reasons
306
+ // become failed (aborted => killed, shipped vocabulary) with partial output
307
+ // attached; hard failures never reject the job.
308
+ async function settleStart(start, signal, meta) {
309
+ let run;
310
+ try {
311
+ run = await start;
312
+ const result = await run.result;
313
+ const failure = stopReasonError(result);
314
+ if (failure !== undefined) {
315
+ return { status: result.stopReason === 'aborted' ? 'killed' : 'failed', detail: withPartialText(failure, result.output), ...meta };
316
+ }
317
+ return { status: 'completed', output: textFrom(result.output), ...meta };
318
+ } catch (error) {
319
+ return signal.aborted ? { status: 'killed', ...meta } : { status: 'failed', detail: String(error), ...meta };
320
+ } finally {
321
+ // Release the child handle no matter how the result settled — run.result
322
+ // rejecting must not leak the subagent (same discipline as the foreground
323
+ // try/finally).
324
+ if (run !== undefined) await run.dispose().catch(() => {});
325
+ }
326
+ }
327
+
328
+ // Verbatim from the shipped SUBAGENT_DELEGATION_CONTEXT.
329
+ const DELEGATION_CONTEXT = 'You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it.';
330
+
331
+ export async function apply(ctx) {
332
+ // 0. Enable/disable switch: default on, toggled at runtime by the settings
333
+ // page and persisted across restarts. When off, the dispatch tool is
334
+ // unregistered so it disappears from the model's tool list.
335
+ let enabled = loadEnabled();
336
+
337
+ // 1. Profile registry (per-instance state; a bundle row is process-level, so
338
+ // this Map is the singleton store, exactly like the dynamic plugin's).
339
+ // Builtin seeds carry `builtin: true` so reset/remove can identify them and
340
+ // a modified builtin stays distinguishable from a pure user profile.
341
+ // Descriptions are semantic: one-line positioning + when to use, to help
342
+ // the model choose. reasoningEffort levels verified against the
343
+ // llm-deepseek adapter (off/high/max; see resolveModel gating on
344
+ // connection.defaults.thinking — this deployment leaves thinking unset,
345
+ // so the full set is advertised for deepseek-v4-flash).
346
+ const BUILTIN_SEEDS = [
347
+ { id: 'swap-standard', name: '标准编码', description: '切换到 standard 预设的完整编码工具集。当父会话不是 standard、但子任务需要完整编码能力时用。', preset: 'standard', builtin: true },
348
+ { id: 'researcher', name: '调研检索', description: '关闭深度推理省 token,继承父工具。适合查资料、汇总、背景调研,不适合改代码。', reasoningEffort: 'off', persona: 'You are a research subagent: search, read, and summarize only. Do not modify code or files.', builtin: true }
349
+ ];
350
+
351
+ // Tool-name → 中文说明 map, shown beside the raw tool name in the toolFilter
352
+ // picker. Tools absent here fall back to their raw name.
353
+ const TOOL_ZH = {
354
+ 'bash': '终端命令',
355
+ 'pwsh': 'PowerShell 命令',
356
+ 'read': '读取文件',
357
+ 'write': '写入文件',
358
+ 'edit': '编辑文件',
359
+ 'grep': '搜索文件内容',
360
+ 'glob': '查找文件',
361
+ 'web_search': '网页搜索',
362
+ 'browser_navigate': '浏览器打开网址',
363
+ 'browser_snapshot': '浏览器页面快照',
364
+ 'browser_click': '浏览器点击',
365
+ 'browser_type': '浏览器输入',
366
+ 'browser_scroll': '浏览器滚动',
367
+ 'browser_back': '浏览器后退',
368
+ 'browser_forward': '浏览器前进',
369
+ 'browser_press': '浏览器按键',
370
+ 'browser_reload': '浏览器刷新',
371
+ 'browser_wait': '浏览器等待',
372
+ 'browser_get_text': '读取页面文本',
373
+ 'dispatch': '派发子 Agent',
374
+ 'subagent': '派生子 Agent',
375
+ 'subagent_fork': '派生子 Agent(继承上下文)',
376
+ 'send_message': '给子 Agent 发消息',
377
+ 'interrupt_agent': '中断子 Agent',
378
+ 'list_agents': '列出子 Agent',
379
+ 'todo_write': '任务清单',
380
+ 'create_goal': '创建目标',
381
+ 'get_goal': '查看目标',
382
+ 'update_goal': '更新目标',
383
+ 'workflow': '编排多 Agent 工作流',
384
+ 'ralph': 'Ralph 迭代',
385
+ 'ask_user_question': '询问用户',
386
+ 'skill': '加载技能',
387
+ 'describe_image': '描述图片',
388
+ 'read_image': '读取图片',
389
+ 'modlens_read_image': '读取图片(modlens)',
390
+ 'ssh_list': '列出 SSH 主机',
391
+ 'ssh_exec': 'SSH 执行命令',
392
+ 'ssh_upload': 'SSH 上传',
393
+ 'ssh_download': 'SSH 下载',
394
+ 'ssh_tunnel': 'SSH 隧道',
395
+ 'ssh_cluster': 'SSH 集群执行',
396
+ 'exit_plan_mode': '退出计划模式',
397
+ 'incident_resolved': '标记事故已解决',
398
+ 'dsh_rollback': '回滚 DSH',
399
+ 'dsh_snapshot': 'DSH 快照',
400
+ 'job_list': '列出后台任务',
401
+ 'job_output': '读取后台任务输出',
402
+ 'job_kill': '终止后台任务',
403
+ 'str_replace_editor': '文本编辑',
404
+ 'cordis_inspect_list': '列出 Cordis 服务',
405
+ 'cordis_inspect_query': '查询 Cordis 服务',
406
+ 'cordis_inspect_self': '查看自身 Cordis 服务',
407
+ 'cordis_define': '定义 Cordis 服务',
408
+ 'cordis_run': '运行 Cordis 服务',
409
+ 'cordis_stop': '停止 Cordis 服务',
410
+ 'cordis_undefine': '取消定义 Cordis 服务',
411
+ 'run_code': '运行代码',
412
+ };
413
+
414
+ // Tool-name → 功能分类 map,覆盖 DSH 官方核心工具(固定集合)。插件工具
415
+ // 走前缀提取(见 categoryOf),自建预设用 preset 名。
416
+ const TOOL_CATEGORY = {
417
+ 'read': '文件', 'write': '文件', 'edit': '文件', 'grep': '文件', 'glob': '文件', 'str_replace_editor': '文件',
418
+ 'bash': '终端', 'pwsh': '终端',
419
+ 'web_search': '网络',
420
+ 'todo_write': '任务', 'create_goal': '任务', 'get_goal': '任务', 'update_goal': '任务',
421
+ 'subagent': '子 Agent', 'subagent_fork': '子 Agent', 'send_message': '子 Agent', 'interrupt_agent': '子 Agent', 'list_agents': '子 Agent',
422
+ 'workflow': '工作流', 'ralph': '工作流',
423
+ 'ask_user_question': '交互', 'skill': '交互',
424
+ 'read_image': '图片', 'describe_image': '图片',
425
+ 'cordis_inspect_list': 'Cordis', 'cordis_inspect_query': 'Cordis', 'cordis_inspect_self': 'Cordis',
426
+ 'cordis_define': 'Cordis', 'cordis_run': 'Cordis', 'cordis_stop': 'Cordis', 'cordis_undefine': 'Cordis',
427
+ 'exit_plan_mode': '计划',
428
+ };
429
+
430
+ const profiles = new Map(BUILTIN_SEEDS.map((p) => [p.id, { ...p }]));
431
+
432
+ // Builtin ids the user has deleted (soft delete via persisted tombstone). The
433
+ // Map keeps working entries; this Set records tombstones so they survive
434
+ // restarts and a later reset can clear them.
435
+ const deletedBuiltins = new Set();
436
+
437
+ // F6: the target-preset whitelist is derived from the runtime roster, not
438
+ // hard-coded: system-trust presets when agentPresets exists, else the
439
+ // shipped fallback names.
440
+ const FALLBACK_WHITELIST = ['standard', 'code', 'minimal'];
441
+ async function resolveWhitelist(agentCtx) {
442
+ const agentPresets = agentCtx.get('agentPresets');
443
+ if (agentPresets === undefined) return FALLBACK_WHITELIST;
444
+ const presets = await agentPresets.list();
445
+ return (presets ?? []).filter((preset) => preset && preset.trust === 'system').map((preset) => preset.id);
446
+ }
447
+
448
+ function resolveProfile(id) {
449
+ const found = profiles.get(id);
450
+ if (found === undefined) throw new Error(`dispatch: unknown profile "${id}"`);
451
+ if (found.enabled === false) throw new Error(`dispatch: profile "${id}" is disabled`);
452
+ return found;
453
+ }
454
+
455
+ // 1b. User profile persistence. The settings service cannot serve this
456
+ // plugin (its write path hard-requires register(ns, schema) + a schemastery
457
+ // schema), so user profiles are persisted to ~/.dsh/subagent-profiles.json
458
+ // through node:fs — a bundle has node globals, unlike the dynamic-plugin
459
+ // sandbox that needed the optional `fs` service. Loaded once at startup; the
460
+ // add/remove HTTP routes rewrite the file. Persistence is an enhancement, not
461
+ // a hard dependency: any failure only warns and the builtin seeds work.
462
+ const profilesFile = join(dshHome(), 'subagent-profiles.json');
463
+ function loadProfiles() {
464
+ if (!existsSync(profilesFile)) return;
465
+ try {
466
+ const parsed = JSON.parse(readFileSync(profilesFile, 'utf8'));
467
+ if (!Array.isArray(parsed)) return;
468
+ let loaded = 0;
469
+ for (const entry of parsed) {
470
+ if (!entry || typeof entry.id !== 'string' || entry.id.length === 0) continue;
471
+ if (entry.deleted === true) {
472
+ if (entry.builtin === true) {
473
+ profiles.delete(entry.id);
474
+ deletedBuiltins.add(entry.id);
475
+ }
476
+ continue;
477
+ }
478
+ const existing = profiles.get(entry.id);
479
+ if (existing !== undefined && existing.builtin === true) {
480
+ profiles.set(entry.id, { ...entry, builtin: true, persisted: true });
481
+ } else {
482
+ profiles.set(entry.id, { ...entry, persisted: true });
483
+ }
484
+ loaded++;
485
+ }
486
+ // Silent success: report how many persisted profiles came in (skipped
487
+ // when none — a missing/empty file is the normal first boot).
488
+ if (loaded > 0) ctx.logger.info(`[dsh-subagent-profile] loaded ${loaded} persisted profile(s)`);
489
+ } catch (error) {
490
+ ctx.logger.warn('[dsh-subagent-profile] persisted profile load failed:', error instanceof Error ? error.message : String(error));
491
+ }
492
+ }
493
+ loadProfiles();
494
+
495
+ // 1c. Self-install the bundled "orchestrator" agent preset into the DSH
496
+ // agent-presets root so the mode appears in the new-session picker without
497
+ // manual copying (mirrors the shipped dsh-liangshen self-install). Idempotent:
498
+ // byte-identical trees are skipped; a bundle change rewrites the preset — the
499
+ // intended upgrade path. Fail-soft: the dispatch tool and settings page keep
500
+ // working even if the write is denied.
501
+ try {
502
+ const presetRoot = join(dshHome(), '.agent-presets');
503
+ const sync = syncBundledPresets(presetRoot);
504
+ for (const { id, error } of sync.failed) ctx.logger.warn(`[dsh-subagent-profile] preset ${id} sync failed: ${error}`);
505
+ if (sync.synced.length > 0) ctx.logger.info(`[dsh-subagent-profile] presets synced into ${presetRoot}: ${sync.synced.join(', ')}`);
506
+ } catch (error) {
507
+ ctx.logger.warn('[dsh-subagent-profile] preset sync failed:', error instanceof Error ? error.message : String(error));
508
+ }
509
+
510
+ /** Persist every `persisted: true` profile plus builtin-delete tombstones — fail-soft. */
511
+ function persistProfiles() {
512
+ try {
513
+ const entries = [];
514
+ for (const profile of profiles.values()) {
515
+ if (profile.persisted !== true) continue;
516
+ const clean = {};
517
+ for (const [key, value] of Object.entries(profile)) {
518
+ if (value === undefined || key === 'persisted') continue;
519
+ clean[key] = value;
520
+ }
521
+ entries.push(clean);
522
+ }
523
+ for (const id of deletedBuiltins) {
524
+ entries.push({ id, builtin: true, deleted: true });
525
+ }
526
+ writeFileSync(profilesFile, JSON.stringify(entries, null, 2), 'utf8');
527
+ } catch (error) {
528
+ ctx.logger.warn('[dsh-subagent-profile] persisted profile write failed:', error instanceof Error ? error.message : String(error));
529
+ }
530
+ }
531
+
532
+ // 1c. HTTP loopback routes for the Client settings UI (webServer.register ↔
533
+ // client fetch; JSON only). webServer is optional — a headless deployment
534
+ // keeps the dispatch tool and drops only the settings page. webServer's
535
+ // activation (listen) is async and may not be ready when this plugin's
536
+ // inject deps resolve, so register inside an inject sub-scope that waits for
537
+ // it (ctx.get would read undefined at apply time).
538
+ ctx.inject(['webServer'], (scope) => {
539
+ const json = (res, code, data) => {
540
+ res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
541
+ res.end(JSON.stringify(data));
542
+ };
543
+ const readBody = (req) => new Promise((resolve, reject) => {
544
+ let data = '';
545
+ let size = 0;
546
+ req.on('data', (chunk) => {
547
+ size += chunk.length;
548
+ if (size > 1 << 20) { reject(new Error('请求体过大')); req.destroy(); return; }
549
+ data += chunk;
550
+ });
551
+ req.on('end', () => {
552
+ try { resolve(data === '' ? {} : JSON.parse(data)); } catch { reject(new Error('请求体不是合法 JSON')); }
553
+ });
554
+ req.on('error', reject);
555
+ });
556
+ const listClean = () => [...profiles.values()].map((profile) => {
557
+ const clean = {};
558
+ for (const [key, value] of Object.entries(profile)) if (value !== undefined && key !== 'persisted') clean[key] = value;
559
+ // The internal `persisted` flag is stripped above; expose a UI-facing
560
+ // "modified" signal so the reset panel can label a changed builtin.
561
+ if (profile.builtin === true && profile.persisted === true) clean.modified = true;
562
+ return clean;
563
+ });
564
+ const handler = async (req, res) => {
565
+ const remote = req.socket?.remoteAddress;
566
+ if (!LOOPBACKS.has(remote)) return json(res, 403, { ok: false, error: '仅限本机访问' });
567
+ const url = new URL(req.url ?? '/', 'http://localhost');
568
+ const sub = (url.pathname.replace(/^\/subagent-profiles/, '') || '/').replace(/\/+$/, '') || '/';
569
+ try {
570
+ if (req.method === 'GET' && (sub === '/' || sub === '/list')) {
571
+ return json(res, 200, { ok: true, profiles: listClean() });
572
+ }
573
+ if (req.method === 'GET' && sub === '/options') {
574
+ const models = [];
575
+ const efforts = {};
576
+ const presets = [];
577
+ // Model directory + per-model reasoning-effort levels. The `llm`
578
+ // service is optional (headless): a failure only empties the lists,
579
+ // never breaks the settings page.
580
+ const llm = ctx.get('llm');
581
+ if (llm !== undefined) {
582
+ try {
583
+ const providers = await llm.listProviders();
584
+ for (const provider of (providers ?? [])) {
585
+ const providerId = provider && provider.id;
586
+ if (typeof providerId !== 'string') continue;
587
+ let modelList = [];
588
+ try { modelList = await llm.listModels(providerId); } catch { /* skip this provider's catalog */ }
589
+ for (const model of (modelList ?? [])) {
590
+ if (!model || typeof model.id !== 'string') continue;
591
+ models.push({
592
+ provider: providerId,
593
+ providerName: provider.name ?? providerId,
594
+ id: model.id,
595
+ name: model.name ?? model.id
596
+ });
597
+ try {
598
+ const info = await llm.resolveModelInfo(providerId, model.id);
599
+ const effortsList = info && info.reasoning && Array.isArray(info.reasoning.efforts) ? info.reasoning.efforts : [];
600
+ efforts[model.id] = effortsList.map((effort) => ({
601
+ id: effort.id,
602
+ name: effort.name ?? effort.id,
603
+ ...(effort.description !== undefined ? { description: effort.description } : {})
604
+ }));
605
+ } catch { /* exact-model lookup may reject; skip its efforts */ }
606
+ }
607
+ }
608
+ } catch { /* llm directory unavailable; leave options empty */ }
609
+ }
610
+ // System-trust presets (agentPresets is optional; fail-soft).
611
+ const agentPresets = ctx.get('agentPresets');
612
+ if (agentPresets !== undefined) {
613
+ try {
614
+ const list = await agentPresets.list();
615
+ for (const preset of (list ?? [])) {
616
+ if (preset && preset.trust === 'system') {
617
+ presets.push({ id: preset.id, name: preset.name ?? preset.id });
618
+ }
619
+ }
620
+ } catch { /* presets roster unavailable; leave empty */ }
621
+ }
622
+ // Full tool directory = global layer (deployment plugins) + every
623
+ // preset's standing scope (the agent.cordis.yml tool rows). Each tool
624
+ // is tagged with its source: 'global' or the preset id — the grouping
625
+ // is fully dynamic, derived from the runtime's preset roster.
626
+ let tools = [];
627
+ try {
628
+ const seen = new Set();
629
+ const OFFICIAL_PRESETS = ['standard', 'code', 'minimal', 'cordis'];
630
+ const layerOf = (source) => {
631
+ if (source === 'global') return 'plugin';
632
+ if (OFFICIAL_PRESETS.includes(source)) return 'core';
633
+ return 'custom';
634
+ };
635
+ const groupOf = (name, source) => {
636
+ const layer = layerOf(source);
637
+ if (layer === 'core') return TOOL_CATEGORY[name] ?? '其他';
638
+ if (layer === 'plugin') return name.includes('_') ? name.split('_')[0] : name;
639
+ return source;
640
+ };
641
+ const push = (schemas, source) => {
642
+ for (const s of (Array.isArray(schemas) ? schemas : [])) {
643
+ if (!s || typeof s.name !== 'string' || s.name === 'run_code' || seen.has(s.name)) continue;
644
+ seen.add(s.name);
645
+ tools.push({ name: s.name, description: typeof s.description === 'string' ? s.description : '', zh: TOOL_ZH[s.name] ?? '', source, layer: layerOf(source), group: groupOf(s.name, source) });
646
+ }
647
+ };
648
+ if (ctx.tools && typeof ctx.tools.schemas === 'function') {
649
+ push(ctx.tools.schemas(), 'global');
650
+ const agentPresets = ctx.get('agentPresets');
651
+ if (agentPresets !== undefined && typeof agentPresets.list === 'function' && typeof agentPresets.standingKeyFor === 'function') {
652
+ const presets = await agentPresets.list();
653
+ for (const preset of (presets ?? [])) {
654
+ if (!preset || typeof preset.id !== 'string') continue;
655
+ try {
656
+ push(ctx.tools.schemas(await agentPresets.standingKeyFor(preset.id)), preset.id);
657
+ } catch { /* one preset's standing scope unavailable; skip */ }
658
+ }
659
+ }
660
+ }
661
+ } catch (error) {
662
+ ctx.logger.warn('[dsh-subagent-profile] tools directory failed:', error instanceof Error ? error.message : String(error));
663
+ }
664
+ return json(res, 200, { ok: true, enabled, models, efforts, presets, tools });
665
+ }
666
+ if (req.method === 'POST' && sub === '/set-enabled') {
667
+ const body = await readBody(req);
668
+ const next = !!(body && body.enabled === true);
669
+ enabled = next;
670
+ persistEnabled(next);
671
+ syncTool();
672
+ return json(res, 200, { ok: true, enabled });
673
+ }
674
+ if (req.method === 'POST' && sub === '/add') {
675
+ const body = await readBody(req);
676
+ const profile = body && typeof body === 'object' ? body : {};
677
+ if (typeof profile.id !== 'string' || profile.id.length === 0) {
678
+ return json(res, 400, { ok: false, error: 'subagent-profiles: profile id must be a non-empty string' });
679
+ }
680
+ const existing = profiles.get(profile.id);
681
+ const seed = BUILTIN_SEEDS.find((s) => s.id === profile.id);
682
+ const isBuiltin = (existing !== undefined && existing.builtin === true) || seed !== undefined;
683
+ // Merge (not replace): start from the existing profile — or its seed
684
+ // when it was deleted — so fields not present in the form (e.g. a
685
+ // builtin's persona/preset) survive an edit or a re-add.
686
+ const clean = { ...(existing ?? seed ?? {}) };
687
+ clean.id = profile.id;
688
+ for (const key of ['name', 'description', 'preset', 'provider', 'model', 'reasoningEffort', 'persona', 'enabled']) {
689
+ if (profile[key] === undefined) continue; // 未传:保留 existing 原值
690
+ if (profile[key] === '' || profile[key] === null) { delete clean[key]; continue; } // 空:清除字段
691
+ clean[key] = profile[key];
692
+ }
693
+ // toolFilter 特殊处理:前端改成多选下拉后总是传数组,空数组 = 清除
694
+ if (profile.toolFilter !== undefined) {
695
+ const tf = profile.toolFilter;
696
+ const allow = Array.isArray(tf.allow) ? tf.allow.map((s) => String(s).trim()).filter(Boolean) : [];
697
+ const deny = Array.isArray(tf.deny) ? tf.deny.map((s) => String(s).trim()).filter(Boolean) : [];
698
+ if (allow.length > 0 || deny.length > 0) clean.toolFilter = { ...(allow.length > 0 ? { allow } : {}), ...(deny.length > 0 ? { deny } : {}) };
699
+ else delete clean.toolFilter;
700
+ }
701
+ if (clean.enabled !== undefined) clean.enabled = clean.enabled === false ? false : true;
702
+ profiles.set(profile.id, { ...clean, ...(isBuiltin ? { builtin: true } : {}), persisted: true });
703
+ deletedBuiltins.delete(profile.id);
704
+ persistProfiles();
705
+ return json(res, 200, { ok: true, id: profile.id });
706
+ }
707
+ if (req.method === 'POST' && sub === '/remove') {
708
+ const body = await readBody(req);
709
+ const id = body && typeof body === 'object' && typeof body.id === 'string' ? body.id : '';
710
+ const existing = profiles.get(id);
711
+ if (existing === undefined) {
712
+ return json(res, 404, { ok: false, error: `subagent-profiles: profile "${id}" does not exist` });
713
+ }
714
+ profiles.delete(id);
715
+ if (existing.builtin === true) deletedBuiltins.add(id);
716
+ persistProfiles();
717
+ return json(res, 200, { ok: true, id });
718
+ }
719
+ if (req.method === 'POST' && sub === '/reset') {
720
+ const body = await readBody(req);
721
+ const id = body && typeof body === 'object' && typeof body.id === 'string' ? body.id : '';
722
+ const seed = BUILTIN_SEEDS.find((s) => s.id === id);
723
+ if (seed === undefined) {
724
+ return json(res, 404, { ok: false, error: `subagent-profiles: profile "${id}" is not a builtin (nothing to reset)` });
725
+ }
726
+ profiles.set(id, { ...seed });
727
+ deletedBuiltins.delete(id);
728
+ persistProfiles();
729
+ return json(res, 200, { ok: true, id });
730
+ }
731
+ if (req.method === 'POST' && sub === '/reset-all') {
732
+ for (const seed of BUILTIN_SEEDS) {
733
+ profiles.set(seed.id, { ...seed });
734
+ deletedBuiltins.delete(seed.id);
735
+ }
736
+ persistProfiles();
737
+ return json(res, 200, { ok: true, count: BUILTIN_SEEDS.length });
738
+ }
739
+ if (req.method === 'POST' && sub === '/set-profile-enabled') {
740
+ const body = await readBody(req);
741
+ const id = body && typeof body === 'object' && typeof body.id === 'string' ? body.id : '';
742
+ const existing = profiles.get(id);
743
+ if (existing === undefined) {
744
+ return json(res, 404, { ok: false, error: `subagent-profiles: profile "${id}" does not exist` });
745
+ }
746
+ existing.enabled = body && body.enabled === false ? false : true;
747
+ // Persist unconditionally (not just for builtins): a runtime-registered
748
+ // profile's enable/disable must also survive a restart.
749
+ existing.persisted = true;
750
+ persistProfiles();
751
+ return json(res, 200, { ok: true, id, enabled: existing.enabled });
752
+ }
753
+ json(res, 404, { ok: false, error: `未知路由 ${sub}` });
754
+ } catch (error) {
755
+ json(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) });
756
+ }
757
+ };
758
+ scope.effect(() => {
759
+ const disposeRoutes = scope.webServer.register({ kind: 'prefix', path: '/subagent-profiles', handler });
760
+ return () => disposeRoutes();
761
+ }, 'dsh-subagent-profile: settings routes');
762
+ });
763
+
764
+ // C: subagent-profiles service over the same closure Map — lets the outside
765
+ // world enumerate and extend the registry without touching internals.
766
+ ctx.provide('subagent-profiles', {
767
+ register(profile) {
768
+ if (!profile || typeof profile.id !== 'string' || profile.id.length === 0) {
769
+ throw new Error('subagent-profiles: profile id must be a non-empty string');
770
+ }
771
+ if (profiles.has(profile.id)) {
772
+ throw new Error(`subagent-profiles: profile "${profile.id}" is already registered`);
773
+ }
774
+ const registered = { ...profile };
775
+ profiles.set(profile.id, registered);
776
+ return () => {
777
+ if (profiles.get(profile.id) === registered) profiles.delete(profile.id);
778
+ };
779
+ },
780
+ get(id) {
781
+ return profiles.get(id);
782
+ },
783
+ list() {
784
+ return [...profiles.values()];
785
+ },
786
+ resolve(id) {
787
+ return resolveProfile(id);
788
+ }
789
+ });
790
+
791
+ // C: directory section rendering the available profiles (systemPrompt's
792
+ // section `text` accepts a function, as the shipped tool-subagent proves).
793
+ const pluginSystemPrompt = ctx.get('systemPrompt');
794
+ if (pluginSystemPrompt !== undefined) {
795
+ pluginSystemPrompt.section({
796
+ name: 'dispatch:profiles',
797
+ order: 116.5,
798
+ text: () => {
799
+ if (!enabled) return '';
800
+ const rows = [...profiles.values()]
801
+ .filter((p) => p.enabled !== false)
802
+ .map((p) => `- ${p.id}: ${p.description}${p.preset !== undefined ? ` (preset: ${p.preset})` : ''}`);
803
+ return rows.length === 0 ? '' : `Available dispatch profiles (dispatch.profile):\n${rows.join('\n')}`;
804
+ }
805
+ });
806
+ // Announce the self-installed orchestrator preset so the current agent
807
+ // knows the mode exists and can point the user to it.
808
+ pluginSystemPrompt.section({
809
+ name: 'orchestrator:mode',
810
+ order: 117,
811
+ text: '本机已安装 dsh-subagent-profile 插件的「编排者模式」agent preset:新建会话的预设选择器中可选「编排者模式」。该模式把 Agent 定位为主协调者——拆解任务后按场景用 dispatch(内置 swap-standard=标准编码、researcher=调研检索,可在「子 Agent 方案」设置页自定义)与 subagent/subagent_fork/workflow 委派给子 Agent,再整合结果。preset 文件由插件维护于 ~/.dsh/.agent-presets,安装/升级时自动同步;用户提到「编排者模式 / orchestrator / 主协调模式」时即指本预设,请据此协作。'
812
+ });
813
+ }
814
+
815
+ // 3. `profile` subagent provider.
816
+ const disposeProvider = ctx.subagents.registerProvider({
817
+ name: 'profile',
818
+ capabilities: { outputSchema: false, depthLimit: true, toolFilter: true, persona: true },
819
+ inheritsParentContext: false,
820
+ async start(request) {
821
+ if (!enabled) {
822
+ throw new Error('dispatch: the subagent-profile plugin is disabled (re-enable it in 设置 → 子 Agent 方案)');
823
+ }
824
+ const profile = request.profile;
825
+ if (profile === undefined) {
826
+ throw new Error('dispatch: request.profile is missing (the dispatch tool must resolve a profile before starting)');
827
+ }
828
+ const parent = request.parent;
829
+ // F9: capture the delegation policy synchronously, before the first
830
+ // await — a later parent switch belongs to the parent's future, not to
831
+ // this child (shipped captureDelegatedPolicyOverrides). Passed to setup
832
+ // through the closure.
833
+ const delegated = captureDelegatedPolicyOverrides(parent);
834
+ // F6: authoritative preset whitelist check against the runtime roster.
835
+ const whitelist = new Set(await resolveWhitelist(parent.ctx));
836
+ if (typeof profile.preset === 'string' && profile.preset !== 'inherit' && !whitelist.has(profile.preset)) {
837
+ throw new Error(`dispatch: preset "${profile.preset}" is not in the target-preset whitelist`);
838
+ }
839
+ // F5: authoritative cost guard (runtime-derived; skipped when llm is absent).
840
+ await assertCostGuard(parent, profile);
841
+ // Delegation depth: shipped helpers — assert the cap value, then resolve
842
+ // the child depth (parent floor + 1) and enforce the cap.
843
+ assertSubagentMaxDepth(profile.maxDepth);
844
+ const childDepth = resolveChildDepth(parent, profile.maxDepth);
845
+ const childId = randomUUID();
846
+ const parentAgentPresets = parent.ctx.get('agentPresets');
847
+ const parentComposed = parentAgentPresets !== undefined ? parentAgentPresets.composedPreset(parent.ctx) : undefined;
848
+ const swapPreset = typeof profile.preset === 'string' && profile.preset !== 'inherit' && profile.preset !== parentComposed;
849
+ // F8: agentPreset is recorded only when a preset roster exists
850
+ // (non-rosterless), otherwise omitted entirely. This meta is CUSTOM —
851
+ // not the shipped childSessionMeta — because a swap records
852
+ // profile.preset instead of the parent's composedPreset.
853
+ const meta = {
854
+ ...(parent.session.header.cwd !== undefined ? { cwd: parent.session.header.cwd } : {}),
855
+ ...(parentAgentPresets !== undefined
856
+ ? swapPreset
857
+ ? { agentPreset: profile.preset }
858
+ : parentComposed !== undefined
859
+ ? { agentPreset: parentComposed }
860
+ : {}
861
+ : {}),
862
+ parentSession: parent.session.header.id,
863
+ origin: 'subagent',
864
+ delegationDepth: childDepth
865
+ };
866
+ // agentOptions: shipped resolveChildAgentOptions — parent route inherited
867
+ // unless the profile overrides provider/model/maxTokens, stamped with the
868
+ // child's own delegation depth.
869
+ const agentOptions = resolveChildAgentOptions(parent, {
870
+ ...(profile.provider !== undefined ? { provider: profile.provider } : {}),
871
+ ...(profile.model !== undefined ? { model: profile.model } : {}),
872
+ ...(profile.maxTokens !== undefined ? { maxTokens: profile.maxTokens } : {})
873
+ }, childDepth);
874
+ if (request.signal !== undefined && request.signal.aborted) {
875
+ throw new Error('dispatch: subagent request was aborted before child publication');
876
+ }
877
+ const handle = await parent.ctx.agents.create({
878
+ sessionId: childId,
879
+ meta,
880
+ agentOptions,
881
+ signal: request.signal,
882
+ setup: async (childCtx) => {
883
+ // ① Preset composition: explicit swap mounts the target preset;
884
+ // otherwise compose from the parent. E: rosterless + explicit
885
+ // swap fails loud instead of silently degrading. This is CUSTOM —
886
+ // not the shipped applyChildComposition, which only composes from
887
+ // the parent.
888
+ const childPresets = childCtx.get('agentPresets');
889
+ if (swapPreset) {
890
+ if (childPresets === undefined) {
891
+ throw new Error('dispatch: cannot swap preset in a rosterless deployment');
892
+ }
893
+ await childPresets.mount(childCtx, profile.preset);
894
+ } else if (childPresets !== undefined) {
895
+ childPresets.composeFrom(childCtx, parent.ctx);
896
+ }
897
+ // ② Tool intersection (safety gate 1): parent set ∩ child set, minus
898
+ // run_code, minus deny, then narrowed by allow when present.
899
+ const parentNames = new Set(parent.ctx.tools.schemas(parent).map((schema) => schema.name));
900
+ const childNames = childCtx.tools.schemas(childCtx.agent).map((schema) => schema.name);
901
+ let effective = childNames.filter((name) =>
902
+ parentNames.has(name) &&
903
+ name !== 'run_code' &&
904
+ !(profile.toolFilter !== undefined && profile.toolFilter.deny !== undefined && profile.toolFilter.deny.includes(name))
905
+ );
906
+ if (profile.toolFilter !== undefined && Array.isArray(profile.toolFilter.allow)) {
907
+ effective = effective.filter((name) => profile.toolFilter.allow.includes(name));
908
+ }
909
+ // F4: shipped restrict does NOT throw on allow:[] — fail loud here so
910
+ // the empty-intersection case is explicit (throw => setupAndPublish
911
+ // rolls the creation back).
912
+ if (effective.length === 0) {
913
+ throw new Error('dispatch: child tool intersection is empty (zero tools)');
914
+ }
915
+ // restrict throws on unknown/scope-local/reserved allow sets: wrap
916
+ // in a clean error and rethrow to trigger creation rollback.
917
+ try {
918
+ childCtx.tools.restrict({ allow: effective });
919
+ } catch (error) {
920
+ throw new Error(`dispatch: child tool restriction failed: ${error instanceof Error ? error.message : String(error)}`);
921
+ }
922
+ // ③ Delegation scope declaration (when systemPrompt is available).
923
+ const systemPrompt = childCtx.get('systemPrompt');
924
+ if (systemPrompt !== undefined) {
925
+ systemPrompt.context({ name: 'subagent:delegation', order: 120, text: DELEGATION_CONTEXT });
926
+ }
927
+ // ④ Persona shadow (overrides deployment:persona at order 0).
928
+ if (profile.persona !== undefined && systemPrompt !== undefined) {
929
+ systemPrompt.section({ name: 'deployment:persona', order: 0, text: profile.persona });
930
+ }
931
+ // ⑤ Reasoning-effort injection into every child request.
932
+ if (profile.reasoningEffort !== undefined) {
933
+ childCtx.on('agent/request', async (_payload, next) => {
934
+ const resolved = await next();
935
+ return { ...resolved, reasoningEffort: profile.reasoningEffort };
936
+ });
937
+ }
938
+ // ⑥ Descriptor append inside the child's first turn.
939
+ let appended = false;
940
+ childCtx.on('agent/pre-step', async ({ agent }, next) => {
941
+ const decision = await next();
942
+ if (!appended && decision.kind === 'enter') {
943
+ appended = true;
944
+ agent.session.append('subagent/descriptor', request.descriptor);
945
+ }
946
+ return decision;
947
+ });
948
+ // ⑦ Delegation policy appends (shipped helper: sandbox/mode when the
949
+ // parent has an explicit override, approval/policy pinned 'never').
950
+ appendDelegatedPolicyOverrides(childCtx.agent.session, delegated);
951
+ }
952
+ });
953
+ // F3: post-publication cancellation wiring (drivePublishedRun): the
954
+ // caller signal cancels the child and the result closure skips the
955
+ // followup when already cancelled.
956
+ const child = handle.agent;
957
+ const boundary = child.session.events.length;
958
+ const flags = { cancelled: false };
959
+ const onAbort = () => {
960
+ flags.cancelled = true;
961
+ child.cancel({ kind: 'parent' });
962
+ };
963
+ const signal = request.signal;
964
+ if (signal !== undefined) {
965
+ signal.addEventListener('abort', onAbort, { once: true });
966
+ if (signal.aborted) onAbort();
967
+ }
968
+ const result = (async () => {
969
+ try {
970
+ if (!flags.cancelled) {
971
+ child.followup(createUserMessage({ content: request.prompt, source: { kind: 'user' } }));
972
+ await child.whenIdle();
973
+ }
974
+ const settled = readResult(child, boundary, flags.cancelled);
975
+ // Decision-level log at result settlement (readResult, before return).
976
+ ctx.logger.info('[dsh-subagent-profile] child:', JSON.stringify({ childId, preset: profile.preset ?? 'inherit', swapPreset, stopReason: settled.stopReason }));
977
+ return settled;
978
+ } finally {
979
+ if (signal !== undefined) signal.removeEventListener('abort', onAbort);
980
+ }
981
+ })();
982
+ return {
983
+ id: childId,
984
+ localAgent: child,
985
+ result,
986
+ async dispose() {
987
+ if (signal !== undefined) signal.removeEventListener('abort', onAbort);
988
+ flags.cancelled = true;
989
+ const settled = await Promise.allSettled([handle.dispose(), result]);
990
+ if (settled[0].status === 'rejected') throw settled[0].reason;
991
+ }
992
+ };
993
+ },
994
+ async prepareContinuable() {
995
+ return {};
996
+ }
997
+ });
998
+ if (typeof disposeProvider === 'function') ctx.effect(() => disposeProvider);
999
+
1000
+ // 4. `dispatch` tool — bundle registration: the dynamic-plugin harness pair
1001
+ // (harness.defineTool/harness.registerTool) does not exist in a bundle, so
1002
+ // this uses the shipped ctx.tools.register + imported defineTool. The tool
1003
+ // is registered dynamically so the settings switch can unregister it at
1004
+ // runtime (disappearing from the model's tool list) without a restart.
1005
+ const dispatchTool = defineTool({
1006
+ name: 'dispatch',
1007
+ description: 'Dispatch a subtask to a derived subagent, optionally overriding its preset, model, provider, reasoning effort, persona, tool whitelist, token budget, or recursion depth. Foreground waits for the result; run_in_background: true starts a background job (single turn); continuable: true starts a durable subagent whose conversation stays available for later turns via the send_message tool.',
1008
+ parameters: {
1009
+ profile: { type: 'string', description: 'Optional profile id from the profile registry (built-ins: swap-standard, researcher, plus any you define in the settings page); omit to inherit the parent preset and tools as-is.' },
1010
+ preset: { type: 'string', description: 'Explicit target preset override; must be a system-trust preset of this runtime.' },
1011
+ model: { type: 'string', description: 'Explicit model override for the child.' },
1012
+ provider: { type: 'string', description: 'Explicit provider override for the child.' },
1013
+ reasoningEffort: { type: 'string', description: 'Explicit reasoning-effort override injected into every child request.' },
1014
+ persona: { type: 'string', description: 'Persona text shadowing the child deployment:persona section.' },
1015
+ toolFilter: {
1016
+ type: 'object',
1017
+ // F1: DSL object parameters reject unknown keys by default, so the
1018
+ // toolFilter object must close its schema or defineTool throws at
1019
+ // apply time and the plugin fails to load.
1020
+ additionalProperties: false,
1021
+ description: 'Extra tool whitelist intersection for the child (intersected with the parent tool set).',
1022
+ properties: {
1023
+ allow: { type: 'array', items: { type: 'string' }, description: 'When present, only these tool names are kept.' },
1024
+ deny: { type: 'array', items: { type: 'string' }, description: 'These tool names are always removed.' }
1025
+ }
1026
+ },
1027
+ maxTokens: { type: 'number', description: 'Explicit max-tokens budget for the child.' },
1028
+ maxDepth: { type: 'number', description: 'Absolute delegation-depth cap for this child.' },
1029
+ run_in_background: { type: 'boolean', description: '异步 one-shot:走 jobs.start 包 start(),返回 jobId;仍单轮即弃,非 continuable' },
1030
+ continuable: { type: 'boolean', description: 'Start a durable continuable subagent instead of a one-shot: returns a subagentId immediately and keeps the child conversation available for later turns via the send_message tool. Defaults to false.' },
1031
+ prompt: { type: 'string', required: true, description: 'The complete, self-contained task for the child (it does not see this conversation).' }
1032
+ },
1033
+ output: {
1034
+ schema: {
1035
+ // D: observability metadata on every result. OneOf covers the
1036
+ // background variant (kind/jobId) and the foreground variant (output),
1037
+ // both closed and both carrying the effective delegation values.
1038
+ oneOf: [
1039
+ {
1040
+ type: 'object',
1041
+ additionalProperties: false,
1042
+ properties: {
1043
+ kind: { type: 'string', required: true, const: 'background' },
1044
+ jobId: { type: 'string', required: true },
1045
+ profile: { type: 'string' },
1046
+ preset: { type: 'string' },
1047
+ provider: { type: 'string' },
1048
+ model: { type: 'string' },
1049
+ reasoningEffort: { type: 'string' }
1050
+ }
1051
+ },
1052
+ {
1053
+ type: 'object',
1054
+ additionalProperties: false,
1055
+ properties: {
1056
+ kind: { type: 'string', required: true, const: 'continuable' },
1057
+ subagentId: { type: 'string', required: true },
1058
+ profile: { type: 'string' },
1059
+ preset: { type: 'string' },
1060
+ provider: { type: 'string' },
1061
+ model: { type: 'string' },
1062
+ reasoningEffort: { type: 'string' }
1063
+ }
1064
+ },
1065
+ {
1066
+ type: 'object',
1067
+ additionalProperties: false,
1068
+ properties: {
1069
+ output: { type: 'string', required: true },
1070
+ profile: { type: 'string' },
1071
+ preset: { type: 'string' },
1072
+ provider: { type: 'string' },
1073
+ model: { type: 'string' },
1074
+ reasoningEffort: { type: 'string' }
1075
+ }
1076
+ }
1077
+ ]
1078
+ },
1079
+ render: (_args, value) => [{
1080
+ type: 'text',
1081
+ text: value.kind === 'background'
1082
+ ? `[dispatch] background job ${value.jobId} · profile=${value.profile} · preset=${value.preset} · provider=${value.provider} · model=${value.model} · reasoningEffort=${value.reasoningEffort}`
1083
+ : value.kind === 'continuable'
1084
+ ? `[dispatch] started subagent ${value.subagentId} · profile=${value.profile} · preset=${value.preset} · provider=${value.provider} · model=${value.model} · reasoningEffort=${value.reasoningEffort}`
1085
+ : `[dispatch] profile=${value.profile} · preset=${value.preset} · provider=${value.provider} · model=${value.model} · reasoningEffort=${value.reasoningEffort}\n\n${value.output}`
1086
+ }]
1087
+ },
1088
+ isConcurrencySafe: () => true,
1089
+ async execute(args, exec) {
1090
+ const parent = exec.agent;
1091
+ if (!parent) throw new Error('dispatch requires calling agent');
1092
+ // Resolve the base profile (side channel), then overlay explicit args.
1093
+ const base = args.profile !== undefined ? resolveProfile(args.profile) : {};
1094
+ const merged = { ...base };
1095
+ for (const key of ['preset', 'model', 'provider', 'reasoningEffort', 'persona', 'toolFilter', 'maxTokens', 'maxDepth']) {
1096
+ if (args[key] !== undefined) merged[key] = args[key];
1097
+ }
1098
+ // Pre-check: explicit concrete preset must be in the runtime-derived
1099
+ // whitelist (F6); a preset equal to the parent's composed preset is
1100
+ // rewritten to 'inherit' (no swap).
1101
+ if (typeof merged.preset === 'string' && merged.preset !== 'inherit') {
1102
+ const whitelist = new Set(await resolveWhitelist(parent.ctx));
1103
+ if (!whitelist.has(merged.preset)) {
1104
+ throw new Error(`dispatch: preset "${merged.preset}" is not in the target-preset whitelist`);
1105
+ }
1106
+ const parentPresets = parent.ctx.get('agentPresets');
1107
+ const parentComposed = parentPresets !== undefined ? parentPresets.composedPreset(parent.ctx) : undefined;
1108
+ if (merged.preset === parentComposed) merged.preset = 'inherit';
1109
+ }
1110
+ // F5: cost guard (runtime-derived; skipped when llm is absent).
1111
+ await assertCostGuard(parent, merged);
1112
+ // D: effective delegation values for observability.
1113
+ const meta = {
1114
+ profile: args.profile ?? '(inline)',
1115
+ preset: merged.preset ?? 'inherit',
1116
+ provider: merged.provider ?? parent.options.provider ?? '(parent)',
1117
+ model: merged.model ?? parent.options.model ?? '(parent)',
1118
+ reasoningEffort: merged.reasoningEffort ?? '(default)'
1119
+ };
1120
+ const request = {
1121
+ label: String(args.prompt ?? '').slice(0, 60),
1122
+ prompt: [{ type: 'text', text: args.prompt }],
1123
+ parent,
1124
+ signal: exec.signal,
1125
+ profile: merged,
1126
+ ...(merged.persona !== undefined ? { persona: merged.persona } : {}),
1127
+ ...(merged.toolFilter !== undefined ? { toolFilter: merged.toolFilter } : {}),
1128
+ ...(merged.maxDepth !== undefined ? { maxDepth: merged.maxDepth } : {})
1129
+ };
1130
+ // Decision-level log: resolved effective delegation inputs, after the
1131
+ // cost guard and after request assembly, before dispatch.
1132
+ ctx.logger.info('[dsh-subagent-profile] dispatch:', JSON.stringify({
1133
+ profile: args.profile ?? '(inline)',
1134
+ preset: merged.preset ?? 'inherit',
1135
+ provider: merged.provider ?? parent.options.provider ?? '(parent)',
1136
+ model: merged.model ?? parent.options.model ?? '(parent)',
1137
+ reasoningEffort: merged.reasoningEffort ?? '(default)',
1138
+ maxDepth: merged.maxDepth ?? null,
1139
+ background: args.run_in_background === true,
1140
+ continuable: args.continuable === true
1141
+ }));
1142
+ // Continuable (durable) path — startContinuable publishes a persistent
1143
+ // child and returns its durable id; the official send_message tool drives
1144
+ // later turns. Treated first so a caller asking for both background and
1145
+ // continuable gets the continuable child.
1146
+ if (args.continuable === true) {
1147
+ if (args.run_in_background === true) {
1148
+ ctx.logger.warn('[dsh-subagent-profile] dispatch: both continuable and run_in_background are true; continuable takes precedence');
1149
+ }
1150
+ // 已知降级:continuable 标准路径不支持 preset swap 和 reasoningEffort(subagent 包的 SubagentStartRequest 无 preset 字段、AgentOptions 无 reasoningEffort 字段)
1151
+ if (merged.preset !== undefined && merged.preset !== 'inherit') {
1152
+ ctx.logger.warn(`[dsh-subagent-profile] continuable mode cannot swap preset; ignoring "${merged.preset}" (child inherits the parent preset)`);
1153
+ }
1154
+ if (merged.reasoningEffort !== undefined) {
1155
+ ctx.logger.warn(`[dsh-subagent-profile] continuable mode cannot set reasoningEffort; ignoring "${merged.reasoningEffort}"`);
1156
+ }
1157
+ const hasAgentOptions = merged.provider !== undefined || merged.model !== undefined || merged.maxTokens !== undefined;
1158
+ const { childId } = await ctx.subagents.startContinuable({
1159
+ provider: 'profile',
1160
+ label: String(args.prompt ?? '').slice(0, 60),
1161
+ request: {
1162
+ prompt: [{ type: 'text', text: args.prompt }],
1163
+ parent,
1164
+ ...(hasAgentOptions ? { agentOptions: {
1165
+ ...(merged.provider !== undefined ? { provider: merged.provider } : {}),
1166
+ ...(merged.model !== undefined ? { model: merged.model } : {}),
1167
+ ...(merged.maxTokens !== undefined ? { maxTokens: merged.maxTokens } : {})
1168
+ } } : {}),
1169
+ ...(merged.persona !== undefined ? { persona: merged.persona } : {}),
1170
+ ...(merged.toolFilter !== undefined ? { toolFilter: merged.toolFilter } : {}),
1171
+ ...(merged.maxDepth !== undefined ? { maxDepth: merged.maxDepth } : {})
1172
+ },
1173
+ signal: exec.signal
1174
+ });
1175
+ // Continuable drops the profile's preset swap and reasoningEffort (the
1176
+ // child inherits the parent preset), so the observability meta must
1177
+ // report what actually took effect, not the requested-but-ignored values.
1178
+ return { kind: 'continuable', subagentId: childId, profile: meta.profile, preset: 'inherit', provider: meta.provider, model: meta.model };
1179
+ }
1180
+ // A: background one-shot (job) path — jobs.start wraps start() with a
1181
+ // native AbortController (a Node global in a bundle; the dynamic-plugin
1182
+ // sandbox needed the hand-rolled shim instead); still one turn, not
1183
+ // continuable.
1184
+ if (args.run_in_background === true) {
1185
+ const jobs = ctx.get('jobs');
1186
+ if (jobs === undefined) {
1187
+ throw new Error('dispatch: background jobs unavailable (load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs)');
1188
+ }
1189
+ const jobId = jobs.start({
1190
+ kind: 'subagent',
1191
+ label: String(args.prompt ?? '').slice(0, 60),
1192
+ owner: parent,
1193
+ run: () => {
1194
+ const controller = new AbortController();
1195
+ return {
1196
+ cancel: (reason) => controller.abort(reason ?? 'dispatch: background subagent task killed'),
1197
+ done: settleStart(ctx.subagents.start('profile', { ...request, signal: controller.signal }), controller.signal, meta)
1198
+ };
1199
+ }
1200
+ });
1201
+ return { kind: 'background', jobId, ...meta };
1202
+ }
1203
+ // Foreground: collect, always release the handle (E: dispose even when
1204
+ // run.result rejects), then fail loud on a non-completed stop reason.
1205
+ const run = await ctx.subagents.start('profile', request);
1206
+ let result;
1207
+ try {
1208
+ result = await run.result;
1209
+ } finally {
1210
+ await run.dispose().catch(() => {});
1211
+ }
1212
+ // F2: a non-'completed' stop reason is a failure; attach the child's
1213
+ // partial output text (withPartialText style).
1214
+ const failure = stopReasonError(result);
1215
+ if (failure !== undefined) throw new Error(withPartialText(failure, result.output));
1216
+ return { output: textFrom(result.output), ...meta };
1217
+ }
1218
+ });
1219
+ // Register the tool only while enabled; unregister it the moment the switch
1220
+ // turns off so it disappears from the model's tool list without a restart.
1221
+ let disposeTool;
1222
+ function syncTool() {
1223
+ if (enabled && disposeTool === undefined) {
1224
+ disposeTool = ctx.tools.register(dispatchTool);
1225
+ } else if (!enabled && disposeTool !== undefined) {
1226
+ const dispose = disposeTool;
1227
+ disposeTool = undefined;
1228
+ dispose();
1229
+ }
1230
+ }
1231
+ syncTool();
1232
+ ctx.effect(() => () => {
1233
+ if (disposeTool !== undefined) {
1234
+ const dispose = disposeTool;
1235
+ disposeTool = undefined;
1236
+ dispose();
1237
+ }
1238
+ });
1239
+ }