dsh-subagent-profile 0.1.0 → 0.3.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 CHANGED
@@ -1,503 +1,29 @@
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';
1
+ // index.mjs — dsh-subagent-profile 宿主侧正式插件 bundle(只做装配)。
2
+ // 源自原型动态插件 `code.host` 主体;宿主导入面(registerTool/defineTool/
3
+ // handle)收敛在 lib/core/shims.mjs,装配块按模块拆分驻留 lib/core/(catalog /
4
+ // presets-sync / profiles-store / cost-guard / whitelist / intersection /
5
+ // delegation / pure / shims / http-routes / profile-provider / dispatch-tool)。
6
+ // apply 的装配辅助函数(syncBundledPresetsToHome / provideProfileService /
7
+ // registerSystemPromptSections / registerSettingsRoutes)保持 section 文本与
8
+ // 门控逐字不变;`enabled` 始终经 getter 注入,门控读取当前值。
9
+
10
+ import { join } from 'node:path';
11
+ import { syncBundledPresets } from './lib/core/presets-sync.mjs';
12
+ import { dshHome, createProfileStore } from './lib/core/profiles-store.mjs';
13
+ import { createHttpRoutes } from './lib/core/http-routes.mjs';
14
+ import { createProfileProvider } from './lib/core/profile-provider.mjs';
15
+ import { createDispatchTool } from './lib/core/dispatch-tool.mjs';
26
16
 
27
17
  export const name = 'dsh-subagent-profile';
28
18
  export const inject = ['subagents', 'tools', 'agents'];
29
19
 
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.
20
+ // Self-install the bundled "orchestrator" agent preset into the DSH
21
+ // agent-presets root so the mode appears in the new-session picker without
22
+ // manual copying (mirrors the shipped dsh-liangshen self-install). Idempotent:
23
+ // byte-identical trees are skipped; a bundle change rewrites the preset — the
24
+ // intended upgrade path. Fail-soft: the dispatch tool and settings page keep
25
+ // working even if the write is denied.
26
+ function syncBundledPresetsToHome(ctx) {
501
27
  try {
502
28
  const presetRoot = join(dshHome(), '.agent-presets');
503
29
  const sync = syncBundledPresets(presetRoot);
@@ -506,734 +32,169 @@ export async function apply(ctx) {
506
32
  } catch (error) {
507
33
  ctx.logger.warn('[dsh-subagent-profile] preset sync failed:', error instanceof Error ? error.message : String(error));
508
34
  }
35
+ }
509
36
 
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.
37
+ // subagent-profiles service over the store's per-apply profiles Map —
38
+ // lets the outside world enumerate and extend the registry without touching
39
+ // internals.
40
+ function provideProfileService(ctx, store) {
766
41
  ctx.provide('subagent-profiles', {
767
42
  register(profile) {
768
43
  if (!profile || typeof profile.id !== 'string' || profile.id.length === 0) {
769
44
  throw new Error('subagent-profiles: profile id must be a non-empty string');
770
45
  }
771
- if (profiles.has(profile.id)) {
46
+ if (store.profiles.has(profile.id)) {
772
47
  throw new Error(`subagent-profiles: profile "${profile.id}" is already registered`);
773
48
  }
774
49
  const registered = { ...profile };
775
- profiles.set(profile.id, registered);
50
+ store.profiles.set(profile.id, registered);
776
51
  return () => {
777
- if (profiles.get(profile.id) === registered) profiles.delete(profile.id);
52
+ if (store.profiles.get(profile.id) === registered) store.profiles.delete(profile.id);
778
53
  };
779
54
  },
780
55
  get(id) {
781
- return profiles.get(id);
56
+ return store.profiles.get(id);
782
57
  },
783
58
  list() {
784
- return [...profiles.values()];
59
+ return [...store.profiles.values()];
785
60
  },
786
61
  resolve(id) {
787
- return resolveProfile(id);
62
+ return store.resolveProfile(id);
788
63
  }
789
64
  });
65
+ }
790
66
 
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 / 主协调模式」时即指本预设,请据此协作。'
67
+ // 系统提示门控:两段 profile-mode section 只在当前会话 Agent 是编排者预设
68
+ // (orchestrator)时注入,从而消除从 standard/code/minimal 等不派发会话上的
69
+ // 每请求固定泄漏。判据(以源码为准确认,见 test/README.md):主判据
70
+ // composedPreset(agentCtx)==='orchestrator';否决 schemas(agent) 明确不含
71
+ // dispatch → 必空;回退 无 agentPresets → 保守不注入。
72
+ function sectionGatePasses(ctx, getEnabled, context) {
73
+ if (!getEnabled()) return false;
74
+ // 否决(防御性):schemas(agent) 明确不含 dispatch → 必空。
75
+ if (context !== undefined && context.agent !== undefined) {
76
+ const schemas = ctx.tools.schemas(context.agent);
77
+ if (Array.isArray(schemas) && !schemas.some((s) => s && s.name === 'dispatch')) {
78
+ return false;
79
+ }
80
+ }
81
+ // 主判据:preset 特征 —— composedPreset(agentCtx) === 'orchestrator'。
82
+ const agentPresets = ctx.get('agentPresets');
83
+ if (agentPresets !== undefined && typeof agentPresets.composedPreset === 'function') {
84
+ const agentCtx = context !== undefined && context.agent !== undefined && context.agent.ctx !== undefined
85
+ ? context.agent.ctx
86
+ : ctx;
87
+ try { return agentPresets.composedPreset(agentCtx) === 'orchestrator'; }
88
+ catch { return false; }
89
+ }
90
+ // 无 agentPresets → 无法判别 → 保守不注入。
91
+ return false;
92
+ }
93
+
94
+ // dispatch:profiles section 文本。引号引用:description 套引号;为空时显示
95
+ // 占位符(不套引号)。压平在存储层完成(sanitizeProfile),此处仅负责显示层包裹。
96
+ // 门控 profiles section 内附一行行为规则提示(非开放的 persona 注入)。
97
+ function profileSectionText(store, gate, context) {
98
+ if (!gate(context)) return '';
99
+ const rows = [...store.profiles.values()]
100
+ .filter((p) => p.enabled !== false)
101
+ .map((p) => {
102
+ const desc = typeof p.description === 'string' && p.description.length > 0 ? `"${p.description}"` : '(无描述)';
103
+ return `- ${p.id}: ${desc}${p.preset !== undefined ? ` (preset: ${p.preset})` : ''}`;
812
104
  });
813
- }
105
+ if (rows.length === 0) return '';
106
+ const note = '- 别把 1-2 步即可自查/可搜完的小事委派出去 —— 几分钟内能自查完的直接做。';
107
+ return `Available dispatch profiles (dispatch.profile):\n${rows.join('\n')}\n${note}`;
108
+ }
814
109
 
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
- }
110
+ // orchestrator:mode section 文本(逐字保留;与 profiles 同门控)。
111
+ const ORCHESTRATOR_MODE_TEXT = '本机已安装 dsh-subagent-profile 插件的「编排者模式」agent preset:新建会话的预设选择器中可选「编排者模式」。该模式把 Agent 定位为主协调者——拆解任务后按场景用 dispatch(内置 swap-standard=标准编码、researcher=调研检索,可在「子 Agent 方案」设置页自定义)与 subagent/subagent_fork/workflow 委派给子 Agent,再整合结果。preset 文件由插件维护于 ~/.dsh/.agent-presets,安装/升级时自动同步;用户提到「编排者模式 / orchestrator / 主协调模式」时即指本预设,请据此协作。';
112
+
113
+ // Directory section rendering the available profiles (systemPrompt's
114
+ // section `text` accepts a function, as the shipped tool-subagent proves).
115
+ // gate: `enabled` must be read live (getter), so /set-enabled toggles
116
+ // apply immediately without a restart.
117
+ function registerSystemPromptSections(ctx, store, getEnabled) {
118
+ const pluginSystemPrompt = ctx.get('systemPrompt');
119
+ if (pluginSystemPrompt === undefined) return;
120
+ const gate = (context) => sectionGatePasses(ctx, getEnabled, context);
121
+ pluginSystemPrompt.section({
122
+ name: 'dispatch:profiles',
123
+ order: 116.5,
124
+ text: (context) => profileSectionText(store, gate, context),
997
125
  });
998
- if (typeof disposeProvider === 'function') ctx.effect(() => disposeProvider);
126
+ // Announce the self-installed orchestrator preset so the current agent
127
+ // knows the mode exists and can point the user to it. Gated the same
128
+ // way — only a dispatch-capable agent sees it.
129
+ pluginSystemPrompt.section({
130
+ name: 'orchestrator:mode',
131
+ order: 117,
132
+ text: (context) => (gate(context) ? ORCHESTRATOR_MODE_TEXT : ''),
133
+ });
134
+ }
999
135
 
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
- }
136
+ // HTTP loopback routes for the Client settings UI (webServer.register ↔
137
+ // client fetch; JSON only) — the routes themselves live in
138
+ // lib/core/http-routes.mjs (imported above); this wiring only injects the
139
+ // per-apply deps. webServer is optional — a headless deployment keeps the
140
+ // dispatch tool and drops only the settings page. webServer's activation
141
+ // (listen) is async and may not be ready when this plugin's inject deps
142
+ // resolve, so register inside an inject sub-scope that waits for it
143
+ // (ctx.get would read undefined at apply time).
144
+ function registerSettingsRoutes(ctx, store, getEnabled, setEnabled, syncTool) {
145
+ ctx.inject(['webServer'], (scope) => {
146
+ scope.effect(createHttpRoutes({
147
+ webServer: scope.webServer,
148
+ store,
149
+ getEnabled,
150
+ setEnabled,
151
+ syncTool,
152
+ getLlm: () => ctx.get('llm'),
153
+ getAgentPresets: () => ctx.get('agentPresets'),
154
+ getTools: () => ctx.tools,
155
+ logger: ctx.logger,
156
+ }), 'dsh-subagent-profile: settings routes');
1218
157
  });
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
- }
158
+ }
159
+
160
+ export async function apply(ctx) {
161
+ // Enable/disable switch (default on, runtime-toggled by the settings
162
+ // page, persisted across restarts) + profile registry — lib/profiles-store
163
+ // .mjs: createProfileStore. loadProfiles runs once at startup via the
164
+ // explicit call below (the factory itself does not auto-load).
165
+ const store = createProfileStore({ dshHome: dshHome(), logger: ctx.logger });
166
+ let enabled = store.loadEnabled();
167
+ store.loadProfiles();
168
+ // Self-install the bundled "orchestrator" preset (idempotent, fail-soft).
169
+ syncBundledPresetsToHome(ctx);
170
+ // `dispatch` tool — lib/core/dispatch-tool.mjs: defineTool block (schema +
171
+ // execute), the result-schema consistency lock and the syncTool
172
+ // register/unregister logic.
173
+ // Created BEFORE the HTTP inject so createHttpRoutes can capture
174
+ // dispatch.syncTool (/set-enabled).
175
+ const dispatch = createDispatchTool({
176
+ register: (tool) => ctx.tools.register(tool),
177
+ store,
178
+ getEnabled: () => enabled,
179
+ getService: (name) => ctx.get(name),
180
+ logger: ctx.logger,
181
+ subagents: ctx.subagents,
1238
182
  });
183
+ // subagent-profiles service over the store's per-apply profiles Map.
184
+ provideProfileService(ctx, store);
185
+ // Gated system-prompt sections (profile directory + orchestrator mode).
186
+ registerSystemPromptSections(ctx, store, () => enabled);
187
+ // `profile` subagent provider — lib/core/profile-provider.mjs:
188
+ // createProfileProvider registers the provider and returns the disposer.
189
+ const disposeProvider = createProfileProvider({
190
+ subagents: ctx.subagents,
191
+ store,
192
+ getEnabled: () => enabled,
193
+ logger: ctx.logger,
194
+ });
195
+ if (typeof disposeProvider === 'function') ctx.effect(() => disposeProvider);
196
+ // HTTP loopback routes for the Client settings UI — lib/core/http-routes.mjs.
197
+ registerSettingsRoutes(ctx, store, () => enabled, (next) => { enabled = next; }, dispatch.syncTool);
198
+ // Teardown: unregister the dispatch tool (if still registered).
199
+ ctx.effect(() => dispatch.dispose);
1239
200
  }