dsh-plugin-tool-management 0.1.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,7 +9,7 @@ import { Worker } from 'node:worker_threads';
9
9
  import { spawn } from 'node:child_process';
10
10
  import { homedir } from 'node:os';
11
11
  import { basename, join } from 'node:path';
12
- import { state, setSkillEnabled, setSourceEnabled, deleteSkill, restoreTrash, permanentlyDeleteTrash, importSkill, importUploadedSkill, browseDirectories, createSkill, skillDetail, listProviderCandidates, getProviderSkill, userRoots, projectRoots, readManagerState, customRootsFromState, addCustomRoot, removeCustomRoot, logPath, trashRootPath, } from './core.js';
12
+ import { state, setSkillEnabled, setPreferredSkill, setSourceEnabled, setSourceRemoved, deleteSkill, restoreTrash, permanentlyDeleteTrash, importSkill, importUploadedSkill, browseDirectories, createSkill, skillDetail, listProviderCandidates, getProviderSkill, userRoots, projectRoots, readManagerState, customRootsFromState, addCustomRoot, removeCustomRoot, logPath, trashRootPath, } from './core.js';
13
13
  const MAX_LOG_BYTES = 1 << 20;
14
14
  const PROVIDER_NAME = 'dsh-plugin-tool-management-external';
15
15
  const message = (e) => String((e && e.message) || e);
@@ -91,8 +91,8 @@ async function permanentlyDeleteTrashSafely(id, log) {
91
91
  }
92
92
  // ── 文件监听:外部改动自动失效 ────────────────────────────────────────────
93
93
  /**
94
- * 监听技能来源目录,200ms 防抖后失效 provider 缓存:在编辑器或其他工具里
95
- * 新增/修改/删除技能后,无需手动刷新即可出现在列表里。
94
+ * 监听目录,200ms 防抖后回调:在编辑器或其他工具里新增/修改/删除文件后,无需手动
95
+ * 刷新即可看到变化。技能来源与规则根目录共用(见 src/rules/service.ts 的场景记忆段缓存)。
96
96
  *
97
97
  * 为什么用 worker_threads:Windows 上 fs.watch(recursive) 的句柄在「被监听
98
98
  * 目录被删除」时会静默卡死事件循环(不触发 error、unref 也无效)——宿主
@@ -100,7 +100,7 @@ async function permanentlyDeleteTrashSafely(id, log) {
100
100
  * unref(),无论目录发生什么,宿主与测试进程都能正常收尾;worker 内部失败
101
101
  * 也不影响主线程。目录不存在或平台不支持递归监听时静默跳过。
102
102
  */
103
- function watchSkillRoots(paths, invalidate) {
103
+ function watchDirectories(paths, invalidate) {
104
104
  const valid = paths.filter((dir) => dir && existsSync(dir));
105
105
  if (!valid.length)
106
106
  return () => { };
@@ -282,8 +282,15 @@ function notifyChatCatalog(ctx, invalidateSkills) {
282
282
  }
283
283
  catch { /* 斜杠菜单刷新失败不影响已完成的操作 */ }
284
284
  }
285
+ /** 全插件共用的审计日志:单一串行写队列,避免多处写同一文件交叉。 */
286
+ let sharedLog = null;
287
+ export function pluginLog() {
288
+ if (!sharedLog)
289
+ sharedLog = makeLog();
290
+ return sharedLog;
291
+ }
285
292
  export function createSkillsService(ctx) {
286
- const log = makeLog();
293
+ const log = pluginLog();
287
294
  const roots = userRoots();
288
295
  const rootByKey = Object.fromEntries(roots.map((root) => [root.key, root]));
289
296
  const projectOptions = () => ({ projectCwds: activeSessionCwds(ctx) });
@@ -350,7 +357,7 @@ export function createSkillsService(ctx) {
350
357
  }
351
358
  try {
352
359
  const watchPaths = roots.map((root) => String((root && root.path) || '')).filter(Boolean);
353
- disposers.push(watchSkillRoots(watchPaths, invalidateSkills));
360
+ disposers.push(watchDirectories(watchPaths, invalidateSkills));
354
361
  // 自定义目录在状态文件里,注册时读取一次并一并监听(后添加的目录由
355
362
  // provider 首次 list 天然覆盖,等插件重载后才有 watcher)。
356
363
  readManagerState()
@@ -359,7 +366,7 @@ export function createSkillsService(ctx) {
359
366
  .map((root) => String((root && root.path) || ''))
360
367
  .filter(Boolean);
361
368
  if (customPaths.length)
362
- disposers.push(watchSkillRoots(customPaths, invalidateSkills));
369
+ disposers.push(watchDirectories(customPaths, invalidateSkills));
363
370
  })
364
371
  .catch(() => { });
365
372
  }
@@ -387,18 +394,37 @@ export function createSkillsService(ctx) {
387
394
  return { ok: true, data: result };
388
395
  };
389
396
  const write = (task) => enqueueMutation(task);
397
+ // 写操作清单:与下方 ops 表同文件同源维护;HTTP 端门禁由 index.ts 从本集合派生,勿在宿主端另抄一份。
398
+ const writeOps = new Set([
399
+ 'skill-enable', 'skill-disable', 'skill-source-enable', 'skill-source-disable',
400
+ 'skill-prefer', 'skill-unprefer',
401
+ 'skill-create', 'skill-import', 'skill-upload', 'skill-delete',
402
+ 'skill-trash-restore', 'skill-trash-delete', 'skill-custom-add', 'skill-custom-remove',
403
+ ]);
390
404
  const ops = {
391
405
  // 读操作(state 走短 TTL 缓存,见上方 readState)
392
406
  'skill-state': wrap(() => readState()),
393
- 'skill-detail': wrap(async (args) => skillDetail(await requestRoot(String(args.root || 'dsh')), String(args.name || ''), projectOptions())),
407
+ 'skill-detail': wrap(async (args) => {
408
+ const key = String(args.root || 'dsh');
409
+ // requestRoot 未命中时回传原始 key,让 core 的 error.root.unknown 带上来源名(而非 undefined)。
410
+ return skillDetail((await requestRoot(key)) || key, String(args.name || ''), projectOptions());
411
+ }),
394
412
  'skill-browse': wrap((args) => browseDirectories(args && args.path)),
395
413
  // 启停(写 manager 状态,不改源文件)
396
414
  'skill-enable': wrap((args) => write(async () => setSkillEnabled(await requestRoot(String(args.root || 'dsh')), String(args.name || ''), true, log)), afterWrite),
397
415
  'skill-disable': wrap((args) => write(async () => setSkillEnabled(await requestRoot(String(args.root || 'dsh')), String(args.name || ''), false, log)), afterWrite),
398
416
  'skill-source-enable': wrap((args) => write(async () => setSourceEnabled(await requestRoot(String(args.root || '')), true, log)), afterWrite),
399
417
  'skill-source-disable': wrap((args) => write(async () => setSourceEnabled(await requestRoot(String(args.root || '')), false, log)), afterWrite),
418
+ // 移除 / 恢复来源(「不再读取这个文件夹」):只改本地状态,源目录一个字节都不动。
419
+ 'skill-source-remove': wrap((args) => write(async () => setSourceRemoved(await requestRoot(String(args.root || '')), true, log)), afterWrite),
420
+ 'skill-source-restore': wrap((args) => write(async () => setSourceRemoved(await requestRoot(String(args.root || '')), false, log)), afterWrite),
421
+ // 同名技能首选来源(手工指定哪个同名版本生效;只写本地策略)。
422
+ 'skill-prefer': wrap((args) => write(async () => setPreferredSkill(await requestRoot(String(args.root || 'dsh')), String(args.name || ''), true, log)), afterWrite),
423
+ 'skill-unprefer': wrap((args) => write(async () => setPreferredSkill(await requestRoot(String(args.root || 'dsh')), String(args.name || ''), false, log)), afterWrite),
400
424
  // 创建 / 导入 / 删除 / 回收站
401
- 'skill-create': wrap((args) => write(async () => createSkill({ name: args.name, description: args.description, body: args.body }, log, { root: await requestRoot(String(args.root || 'dsh')) })), afterWrite),
425
+ // 创建默认落 hub(`tool-management/skills/`):UI 不传 root 时走这里;
426
+ // core.js 的 createSkill 默认值是第二道保险(同口径)。官方 `~/.dsh/skills/` 仍作为来源列出。
427
+ 'skill-create': wrap((args) => write(async () => createSkill({ name: args.name, description: args.description, body: args.body }, log, { root: await requestRoot(String(args.root || 'hub')) })), afterWrite),
402
428
  'skill-import': wrap((args) => write(() => importSkill(String(args.source || ''), log, {
403
429
  conflict: args.conflict === 'overwrite' ? 'overwrite' : 'skip',
404
430
  dryRun: args.dryRun === true,
@@ -413,5 +439,5 @@ export function createSkillsService(ctx) {
413
439
  'skill-custom-add': wrap((args) => write(() => addCustomRoot(args && args.path, args && args.label, log)), afterWrite),
414
440
  'skill-custom-remove': wrap((args) => write(() => removeCustomRoot(args && args.key, log)), afterWrite),
415
441
  };
416
- return { ops, registerProviders };
442
+ return { ops, writeOps, registerProviders };
417
443
  }
@@ -0,0 +1,333 @@
1
+ // src/subagents/service.ts —— 轻量子智能体:人设发现(TTL 扫描)+ 官方 ctx.subagents.start 薄封装。
2
+ // 设计 §3:人设 = $DSH_HOME/tool-management/agents/<name>.md(frontmatter 可选,缺省派生);v1 串行运行;
3
+ // spawn provider 缺失时经 createRequire 挂载官方 dsh-subagent-spawn-in-process(宿主侧包)。
4
+ //
5
+ // v0.4 目录变更:人设由 `$DSH_HOME/subagents/` 搬到 `$DSH_HOME/tool-management/agents/`
6
+ // (插件产生的文件统一收在 tool-management/ 下)。旧目录在首次扫描时搬入,见 relocateLegacyPersonas。
7
+ import { createRequire } from 'node:module';
8
+ import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
9
+ import { join } from 'node:path';
10
+ import { resolveDshHome } from '../skills/core.js';
11
+ import { expandUploads, planPersonaImport } from '../imports/upload.js';
12
+ const message = (e) => String((e && e.message) || e);
13
+ /** 人设默认目录:`$DSH_HOME/tool-management/agents/`。 */
14
+ export function defaultPersonasDir() {
15
+ return join(resolveDshHome(), 'tool-management', 'agents');
16
+ }
17
+ /**
18
+ * 旧目录 `$DSH_HOME/subagents/` → `tool-management/agents/` 一次性搬移(幂等)。
19
+ * 只在目标不存在同名文件时搬(绝不覆盖);源目录保留空壳。每个进程只跑一次。
20
+ */
21
+ let personasRelocated = false;
22
+ async function relocateLegacyPersonas(dir) {
23
+ if (personasRelocated)
24
+ return;
25
+ personasRelocated = true;
26
+ const legacy = join(resolveDshHome(), 'subagents');
27
+ if (legacy === dir)
28
+ return;
29
+ let names;
30
+ try {
31
+ names = (await readdir(legacy)).filter((n) => n.toLowerCase().endsWith('.md'));
32
+ }
33
+ catch {
34
+ return;
35
+ }
36
+ if (!names.length)
37
+ return;
38
+ await mkdir(dir, { recursive: true }).catch(() => undefined);
39
+ for (const name of names) {
40
+ const from = join(legacy, name);
41
+ const to = join(dir, name);
42
+ try {
43
+ const exists = await stat(to).then(() => true).catch(() => false);
44
+ if (exists)
45
+ continue;
46
+ await rename(from, to);
47
+ }
48
+ catch {
49
+ /* 单项失败(被占用等)不阻断其余;保留源文件,下次启动再试 */
50
+ }
51
+ }
52
+ }
53
+ /** 行式 frontmatter 解析:只认 description / provider / model / tools / toolsDeny 五个键。 */
54
+ export function parsePersona(raw, fallbackName) {
55
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(raw);
56
+ const data = {};
57
+ let body = raw;
58
+ if (m) {
59
+ for (const line of m[1].split(/\r?\n/)) {
60
+ const kv = /^([A-Za-z_-]+)\s*:\s*(.*)$/.exec(line.trim());
61
+ if (kv)
62
+ data[kv[1].toLowerCase()] = kv[2].trim();
63
+ }
64
+ body = raw.slice(m[0].length);
65
+ }
66
+ const listOf = (value) => {
67
+ if (!value)
68
+ return undefined;
69
+ const list = value.split(/[,,]/).map((s) => s.trim()).filter(Boolean);
70
+ return list.length ? list : undefined;
71
+ };
72
+ const tools = listOf(data.tools);
73
+ // 兼容两种写法:`toolsdeny: a, b`(线上格式)与 `toolsDeny: a, b`(键名统一小写后同形)。
74
+ const toolsDeny = listOf(data.toolsdeny);
75
+ const firstLine = body.split(/\r?\n/).find((l) => l.trim())?.trim() ?? '';
76
+ return {
77
+ name: fallbackName,
78
+ description: data.description || firstLine,
79
+ provider: data.provider || undefined,
80
+ model: data.model || undefined,
81
+ tools,
82
+ toolsDeny,
83
+ body: body.trim(),
84
+ path: '',
85
+ };
86
+ }
87
+ const RESULT_MAX = 16 * 1024;
88
+ export function createSubagentService(ctx, opts) {
89
+ const dir = opts?.subagentsDir || defaultPersonasDir();
90
+ const req = createRequire(import.meta.url);
91
+ let cache = null;
92
+ async function list() {
93
+ if (cache && Date.now() - cache.at < 1000)
94
+ return cache.value;
95
+ // 旧目录搬家放在首次扫描前(幂等;失败不阻断,旧目录仍会被下面的读取兜底看到)。
96
+ await relocateLegacyPersonas(dir).catch(() => undefined);
97
+ const docs = [];
98
+ try {
99
+ const entries = await readdir(dir);
100
+ for (const fileName of entries.filter((e) => e.endsWith('.md')).sort()) {
101
+ const raw = await readFile(join(dir, fileName), 'utf8').catch(() => '');
102
+ if (!raw.trim())
103
+ continue;
104
+ docs.push({ ...parsePersona(raw, fileName.slice(0, -3)), path: join(dir, fileName) });
105
+ }
106
+ }
107
+ catch { /* 目录缺失 = 无人设 */ }
108
+ cache = { at: Date.now(), value: docs };
109
+ return docs;
110
+ }
111
+ // spawn provider 在场性(设计 §3.2,落地方式见 cordis.patch.yml 的取舍注释):
112
+ // 官方通道探测(ctx.subagents.list())→ 缺失才挂载 → 失败把原因原样带出(不吞、不谎报)。
113
+ // 不是一次性静默标记:每次调用都先探测,宿主后来注册了 provider 也能立刻跟上。
114
+ let spawnMount = null;
115
+ function hasSpawnProvider(runtime) {
116
+ if (typeof runtime.list !== 'function')
117
+ return false;
118
+ try {
119
+ const names = runtime.list();
120
+ return Array.isArray(names) && names.indexOf('spawn') >= 0;
121
+ }
122
+ catch {
123
+ return false;
124
+ }
125
+ }
126
+ async function ensureSpawnProvider() {
127
+ const runtime = ctx.get?.('subagents');
128
+ if (!runtime || typeof runtime.start !== 'function') {
129
+ throw new Error('子代理服务未挂载(ctx.subagents 缺失;请确认 DSH 版本 ≥0.1.5-rc.2)');
130
+ }
131
+ if (hasSpawnProvider(runtime))
132
+ return runtime;
133
+ // 宿主没有 list() 就探测不了:不重复挂载(避免同名 provider 二次注册),交给 start 报官方错误兜底。
134
+ if (typeof runtime.list !== 'function')
135
+ return runtime;
136
+ if (!spawnMount) {
137
+ try {
138
+ const mod = req('@deepseek-ai/dsh-subagent-spawn-in-process');
139
+ if (!mod || typeof mod.apply !== 'function')
140
+ throw new Error('模块未导出 apply(ctx, config)(版本不匹配?)');
141
+ mod.apply(ctx, { providerName: 'spawn' });
142
+ spawnMount = { ok: true };
143
+ }
144
+ catch (e) {
145
+ spawnMount = {
146
+ ok: false,
147
+ error: 'spawn provider 不可用:宿主没有注册它,本插件挂载 @deepseek-ai/dsh-subagent-spawn-in-process 也失败('
148
+ + message(e) + ')。请在宿主 profile 挂载该包(本插件已声明为可选 peerDependency),或安装后重启 DSH。',
149
+ };
150
+ }
151
+ }
152
+ if (!spawnMount.ok)
153
+ throw new Error(spawnMount.error);
154
+ if (!hasSpawnProvider(runtime))
155
+ throw new Error('spawn provider 挂载后仍未出现在 ctx.subagents.list()(宿主版本不匹配?)');
156
+ return runtime;
157
+ }
158
+ async function runOnce(parentAgent, p, task, signal) {
159
+ const runtime = await ensureSpawnProvider();
160
+ // 官方签名:start(name, request) —— name = ctx.subagents 上的 provider 注册名。
161
+ const run = await runtime.start('spawn', {
162
+ label: p.name,
163
+ parent: parentAgent,
164
+ // 官方 SubagentStartRequest.signal 为必填:调用方缺省时给一个永不中止的信号,不传 undefined。
165
+ signal: signal ?? new AbortController().signal,
166
+ prompt: [{ type: 'text', text: task }],
167
+ persona: p.body,
168
+ // 工具白/黑名单 → 官方 ToolRestriction(`deny` 优先级高于 `allow`;未知名官方会直接拒绝启动,
169
+ // 所以选择器给的是「全体预设工具并集」,见 index.ts presetToolCandidates)。
170
+ ...((p.tools?.length || p.toolsDeny?.length)
171
+ ? { toolFilter: { ...(p.tools?.length ? { allow: p.tools } : {}), ...(p.toolsDeny?.length ? { deny: p.toolsDeny } : {}) } }
172
+ : {}),
173
+ maxDepth: 1,
174
+ // provider 与 model 是模型路由的两半:DSH 的 resolveModel(provider, model) 不做
175
+ // `provider/model` 字符串拆分,只改 model 会落在**主会话的 provider** 上——跨来源
176
+ // 指定模型(如 sensenova 的 sensenova-6.8-flash-lite)必须两个键一起给。
177
+ ...(p.provider || p.model
178
+ ? { agentOptions: { ...(p.provider ? { provider: p.provider } : {}), ...(p.model ? { model: p.model } : {}) } }
179
+ : {}),
180
+ });
181
+ try {
182
+ const result = await run.result; // 官方契约:child 级失败不 reject(stopReason 体现)
183
+ const text = (result.output ?? [])
184
+ .map((b) => (b && typeof b === 'object' && typeof b.text === 'string' ? b.text : ''))
185
+ .join('').trim();
186
+ // 官方 SubagentResult 的诊断字段是 `diagnostic`(旧代码读 .detail 恒空 → 失败时模型只见空输出)。
187
+ const diagnostic = result.diagnostic ? `\n\n[provider] ${String(result.diagnostic)}` : '';
188
+ return {
189
+ text: ((text || '(子代理无输出)') + diagnostic).slice(0, RESULT_MAX),
190
+ runId: String(run.id ?? ''),
191
+ stopReason: String(result.stopReason ?? 'completed'),
192
+ };
193
+ }
194
+ finally {
195
+ await run.dispose().catch(() => { }); // 官方前台语义:collection 后即弃
196
+ }
197
+ }
198
+ // v1 串行:同一时刻至多一个子代理运行(设计 §3.2)。
199
+ let chain = Promise.resolve();
200
+ const runSerial = (parentAgent, p, task, signal) => {
201
+ const queued = chain.then(() => runOnce(parentAgent, p, task, signal), () => runOnce(parentAgent, p, task, signal));
202
+ chain = queued.catch(() => undefined);
203
+ return queued;
204
+ };
205
+ const ops = {
206
+ 'subagent-list': async () => {
207
+ const docs = await list();
208
+ return {
209
+ ok: true,
210
+ subagents: docs.map((p) => ({ name: p.name, description: p.description, provider: p.provider ?? null, model: p.model ?? null, tools: p.tools ?? null, toolsDeny: p.toolsDeny ?? null })),
211
+ };
212
+ },
213
+ 'subagent-get': async (args) => {
214
+ const name = String((args && args.name) || '').trim();
215
+ const docs = await list();
216
+ const p = docs.find((d) => d.name === name);
217
+ if (!p)
218
+ return { ok: false, error: `人设不存在: ${name}` };
219
+ return { ok: true, persona: { name: p.name, description: p.description, provider: p.provider ?? '', model: p.model ?? '', tools: p.tools ?? [], toolsDeny: p.toolsDeny ?? [], body: p.body } };
220
+ },
221
+ 'subagent-create': async (args) => {
222
+ const name = String((args && args.name) || '').trim();
223
+ if (!validPersonaName(name))
224
+ return { ok: false, error: `人设名不合法: ${name || '(空)'}(≤64 字符、不含路径分隔符与 < > : " | ? *、不以 . 开头)` };
225
+ const target = join(dir, name + '.md');
226
+ const exists = await readFile(target, 'utf8').then(() => true).catch(() => false);
227
+ if (exists)
228
+ return { ok: false, error: `人设已存在: ${name}` };
229
+ // 目录必须先建:v0.4 起人设落在 hub 内的 agents/,全新安装时它还不存在
230
+ // (旧版本落在 $DSH_HOME/subagents/,那个目录一直有人建,所以这个坑以前不显形)。
231
+ try {
232
+ await mkdir(dir, { recursive: true });
233
+ }
234
+ catch (e) {
235
+ return { ok: false, error: `创建人设目录失败: ${dir}(${message(e)})` };
236
+ }
237
+ await writeFile(target, serializePersona(args), 'utf8');
238
+ cache = null;
239
+ return { ok: true, name };
240
+ },
241
+ 'subagent-update': async (args) => {
242
+ const name = String((args && args.name) || '').trim();
243
+ if (!validPersonaName(name))
244
+ return { ok: false, error: `人设名不合法: ${name || '(空)'}` };
245
+ const target = join(dir, name + '.md');
246
+ const exists = await readFile(target, 'utf8').then(() => true).catch(() => false);
247
+ if (!exists)
248
+ return { ok: false, error: `人设不存在: ${name}` };
249
+ await writeFile(target, serializePersona(args), 'utf8');
250
+ cache = null;
251
+ return { ok: true, name };
252
+ },
253
+ 'subagent-delete': async (args) => {
254
+ const name = String((args && args.name) || '').trim();
255
+ if (!validPersonaName(name))
256
+ return { ok: false, error: `人设名不合法: ${name || '(空)'}` };
257
+ await rm(join(dir, name + '.md')).catch(() => { });
258
+ cache = null;
259
+ return { ok: true, name };
260
+ },
261
+ /**
262
+ * 导入人设(.md / .zip):一个 .md 一个人设,文件名(去扩展名)= 人设名;
263
+ * zip 内任意层级的 .md 都按文件名导入(目录层级忽略)。同名**跳过并报告**,不覆盖既有文件。
264
+ * 部分成功:单条失败只记 skipped。
265
+ */
266
+ 'subagent-import': async (args) => {
267
+ const files = args && args.files;
268
+ if (!Array.isArray(files) || !files.length)
269
+ return { ok: false, error: '没有选择要导入的文件' };
270
+ const { entries, problems } = expandUploads(files);
271
+ const planned = planPersonaImport(entries);
272
+ const skipped = [...problems, ...planned.problems];
273
+ const imported = [];
274
+ for (const target of planned.targets) {
275
+ if (!validPersonaName(target.name)) {
276
+ skipped.push({ name: target.name, reason: '人设名不合法(≤64 字符、不含路径分隔符与 < > : " | ? *、不以 . 开头)' });
277
+ continue;
278
+ }
279
+ const text = Buffer.from(target.bytes).toString('utf8').replace(/^\uFEFF/, '');
280
+ if (!text.trim()) {
281
+ skipped.push({ name: target.name, reason: '内容为空,已跳过' });
282
+ continue;
283
+ }
284
+ const targetPath = join(dir, target.name + '.md');
285
+ const exists = await readFile(targetPath, 'utf8').then(() => true).catch(() => false);
286
+ if (exists) {
287
+ skipped.push({ name: target.name, reason: '同名已存在(已跳过)' });
288
+ continue;
289
+ }
290
+ try {
291
+ await mkdir(dir, { recursive: true });
292
+ await writeFile(targetPath, text, 'utf8');
293
+ }
294
+ catch (e) {
295
+ skipped.push({ name: target.name, reason: '写入失败:' + message(e) });
296
+ continue;
297
+ }
298
+ imported.push(target.name);
299
+ }
300
+ if (imported.length)
301
+ cache = null;
302
+ return { ok: true, imported, skipped };
303
+ },
304
+ };
305
+ return { list, runSerial, ops, writeOps: new Set(['subagent-create', 'subagent-update', 'subagent-delete', 'subagent-import']) };
306
+ }
307
+ function validPersonaName(name) {
308
+ return name.length > 0 && name.length <= 64 && !name.startsWith('.') && !/[\\/<>:"|?*]/.test(name);
309
+ }
310
+ /** 人设文件序列化:frontmatter 只写用户填过的键;正文 = 人设提示词。 */
311
+ export function serializePersona(args) {
312
+ const description = String((args && args.description) || '').replace(/\r?\n/g, ' ').trim();
313
+ const provider = String((args && args.provider) || '').trim();
314
+ const model = String((args && args.model) || '').trim();
315
+ const toList = (v) => (Array.isArray(v) ? v.map((x) => String(x).trim()).filter(Boolean) : []);
316
+ const tools = toList(args?.tools);
317
+ // 黑名单字段兼容两种入参名:toolsDeny(UI/camel)与 tools_deny(snake)。
318
+ const toolsDeny = toList(args?.toolsDeny ?? args?.tools_deny);
319
+ const body = String((args && args.body) ?? '').trim();
320
+ const lines = ['---'];
321
+ if (description)
322
+ lines.push('description: ' + description);
323
+ if (provider)
324
+ lines.push('provider: ' + provider);
325
+ if (model)
326
+ lines.push('model: ' + model);
327
+ if (tools.length)
328
+ lines.push('tools: ' + tools.join(', '));
329
+ if (toolsDeny.length)
330
+ lines.push('toolsDeny: ' + toolsDeny.join(', '));
331
+ lines.push('---', '', body, '');
332
+ return lines.join('\n');
333
+ }
@@ -0,0 +1,62 @@
1
+ export function text(v) {
2
+ return [{ type: 'text', text: v }];
3
+ }
4
+ /** 场景绑定校验(设计 §3.3):启用场景的 subagents 并集;空并集 = 全部可用。 */
5
+ export async function filterBySceneBinding(docs, enabledSceneLists) {
6
+ const bound = new Set(enabledSceneLists.flat());
7
+ if (!bound.size)
8
+ return { allowed: docs, reason: null };
9
+ const allowed = docs.filter((d) => bound.has(d.name));
10
+ return { allowed, reason: allowed.length ? null : `当前启用场景的子智能体绑定: ${[...bound].join('、')}` };
11
+ }
12
+ export function defineSubagentListTool(subagents) {
13
+ return {
14
+ name: 'subagent_list',
15
+ description: 'List available personas (pre-configured subagent profiles) with their descriptions. Call before subagent_run.',
16
+ parameters: {
17
+ scene: { type: 'string', description: 'Optional scene name filter (informational; the tool already reflects scene binding).' },
18
+ },
19
+ output: {
20
+ schema: { type: 'string' },
21
+ render: (_a, v) => text(String(v)),
22
+ },
23
+ async execute() {
24
+ const { allowed, reason } = await filterBySceneBinding(await subagents.list(), await subagents.sceneLists());
25
+ const lines = allowed.map((p) => '- ' + p.name + ' — ' + (p.description || '(无描述)'));
26
+ return '人设子智能体(' + allowed.length + '):\n' + (lines.join('\n') || '(无)') + (reason ? '\n注意:' + reason : '');
27
+ },
28
+ };
29
+ }
30
+ export function defineSubagentRunTool(subagents) {
31
+ return {
32
+ name: 'subagent_run',
33
+ description: 'Run a named persona as a one-shot subagent: it receives the persona as its own system prompt, works on `task` in a fresh context, and returns only its final output. Discover personas with subagent_list first. Each run is stateless and ephemeral; it does not see this conversation.',
34
+ parameters: {
35
+ agent: { type: 'string', required: true, description: 'Persona name from subagent_list.' },
36
+ task: { type: 'string', required: true, description: 'Complete, self-contained task for the subagent (include all context it needs).' },
37
+ },
38
+ output: {
39
+ schema: { type: 'string' },
40
+ render: (_a, v) => text(String(v)),
41
+ },
42
+ async execute(args, exec) {
43
+ const name = String((args && args.agent) || '').trim();
44
+ const task = String((args && args.task) || '').trim();
45
+ if (!task)
46
+ throw new Error('task 不能为空');
47
+ // 官方 SubagentStartRequest.parent 必填:调用方 agent 缺失时给结构化错误,不把 undefined 透传下去。
48
+ if (!exec || !exec.agent)
49
+ throw new Error('缺少调用方 agent(exec.agent 不可用):无法创建子代理运行');
50
+ const docs = await subagents.list();
51
+ const { allowed, reason } = await filterBySceneBinding(docs, await subagents.sceneLists());
52
+ const persona = allowed.find((p) => p.name === name);
53
+ if (!persona) {
54
+ const available = allowed.map((p) => p.name).join('、') || '(无)';
55
+ throw new Error('人设不可用: ' + name + (reason ? '(' + reason + ')' : '(可用: ' + available + ')'));
56
+ }
57
+ const r = await subagents.runSerial(exec.agent, persona, task, exec.signal);
58
+ const prefix = r.stopReason && r.stopReason !== 'completed' ? `[stopReason: ${r.stopReason}]\n` : '';
59
+ return prefix + r.text;
60
+ },
61
+ };
62
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-plugin-tool-management",
3
- "version": "0.1.3",
4
- "description": "DSH plugin: unified Skills and MCP manager — Settings UI + HTTP API + model-facing skill_mcp_manager_* / skill_manager_* tools. Install with one command: dsh plugin --profile web add dsh-plugin-tool-management@latest",
3
+ "version": "0.5.0",
4
+ "description": "DSH plugin: one settings panel for MCP servers, Skills, AGENTS.md presets, archived sessions and scene memories — plus scene memory injected into the system prompt and one-shot persona subagents. Settings UI + HTTP API + 14 model tools (skill_mcp_manager_*, skill_manager_*, agentsmd_*, rule_manager_*, subagent_*). Install with one command: dsh plugin --profile web add dsh-plugin-tool-management@latest",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "https://github.com/ouli-1242/dsh-plugin-tool-management"
@@ -20,7 +20,9 @@
20
20
  "cordis.patch.yml",
21
21
  "README.md",
22
22
  "README_EN.md",
23
- "LICENSE"
23
+ "LICENSE",
24
+ "screenshots.json",
25
+ "docs"
24
26
  ],
25
27
  "engines": {
26
28
  "node": ">=18"
@@ -32,11 +34,10 @@
32
34
  "scripts": {
33
35
  "build": "tsc -p tsconfig.json && node scripts/sync-client.mjs",
34
36
  "build:client": "node scripts/sync-client.mjs",
35
- "test": "npm run build && node --test",
36
- "test:fast": "node --test",
37
- "test:client": "node scripts/sync-client.mjs && node --test tests/client.test.mjs",
38
37
  "prepublishOnly": "npm run build",
39
- "lint": "node --check lib/client.js && node --check lib/index.js"
38
+ "lint": "node --check lib/client.js && node --check lib/index.js",
39
+ "check:i18n": "node scripts/check-i18n.mjs src/client.js",
40
+ "test": "npm run build && npm run check:i18n && node --test test/archive.test.mjs test/import.test.mjs test/approval-policy.test.mjs test/subagent-scene.test.mjs test/subagent-persona.test.mjs test/hub-layout.test.mjs test/skills-state.test.mjs test/skills-delete.test.mjs test/skills-source-remove.test.mjs test/client-exports.test.mjs test/client-render.test.mjs"
40
41
  },
41
42
  "keywords": [
42
43
  "dsh",
@@ -62,19 +63,26 @@
62
63
  },
63
64
  "peerDependencies": {
64
65
  "@deepseek-ai/cordis": "^4.0.1",
65
- "@deepseek-ai/dsh-tools": "^0.1.5-rc.2",
66
- "@deepseek-ai/dsh-workspace": "^0.1.5-rc.2",
67
- "@deepseek-ai/dsh-spill-local": "^0.1.5-rc.2",
68
- "@deepseek-ai/dsh-session-projection-cache": "^0.1.5-rc.2",
69
- "@deepseek-ai/dsh-storage-domain": "^0.1.5-rc.2",
66
+ "@deepseek-ai/dsh-tools": ">=0.1.5-rc.2 <0.2.0-0",
67
+ "@deepseek-ai/dsh-workspace": ">=0.1.5-rc.2 <0.2.0-0",
68
+ "@deepseek-ai/dsh-spill-local": ">=0.1.5-rc.2 <0.2.0-0",
69
+ "@deepseek-ai/dsh-session-projection-cache": ">=0.1.5-rc.2 <0.2.0-0",
70
+ "@deepseek-ai/dsh-storage-domain": ">=0.1.5-rc.2 <0.2.0-0",
71
+ "@deepseek-ai/dsh-subagent-spawn-in-process": ">=0.1.5-rc.2 <0.2.0-0",
70
72
  "react": "^18.2.0"
71
73
  },
74
+ "peerDependenciesMeta": {
75
+ "@deepseek-ai/dsh-subagent-spawn-in-process": {
76
+ "optional": true
77
+ }
78
+ },
72
79
  "devDependencies": {
73
80
  "@deepseek-ai/cordis": "^4.0.1",
74
81
  "@deepseek-ai/dsh-session-projection-cache": "^0.1.5-rc.2",
75
82
  "@deepseek-ai/dsh-spill-local": "^0.1.5-rc.2",
76
83
  "@deepseek-ai/dsh-storage-domain": "^0.1.5-rc.2",
77
84
  "@deepseek-ai/dsh-tools": "^0.1.5-rc.2",
85
+ "@deepseek-ai/dsh-user-approval": "^0.1.5-rc.2",
78
86
  "@deepseek-ai/dsh-workspace": "^0.1.5-rc.2",
79
87
  "@types/node": "^24.0.0",
80
88
  "typescript": "^6.0.0"
@@ -0,0 +1,9 @@
1
+ [
2
+ "docs/images/MCP.png",
3
+ "docs/images/技能.png",
4
+ "docs/images/提示词.png",
5
+ "docs/images/会话.png",
6
+ "docs/images/场景.png",
7
+ "docs/images/记忆.png",
8
+ "docs/images/子智能体.png"
9
+ ]