dsh-plugin-tool-management 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +131 -0
  2. package/README.md +185 -238
  3. package/README_EN.md +185 -304
  4. package/cordis.patch.yml +9 -55
  5. package/docs/images/1/345/234/272/346/231/257.png +0 -0
  6. package/docs/images/1/345/234/272/346/231/257_en.png +0 -0
  7. package/docs/images/2MCP.png +0 -0
  8. package/docs/images/2MCP_en.png +0 -0
  9. package/docs/images/3/346/212/200/350/203/275.png +0 -0
  10. package/docs/images/3/346/212/200/350/203/275_en.png +0 -0
  11. package/docs/images/4/345/255/220/346/231/272/350/203/275/344/275/223.png +0 -0
  12. package/docs/images/4/345/255/220/346/231/272/350/203/275/344/275/223_en.png +0 -0
  13. package/docs/images/5/346/217/220/347/244/272/350/257/215.png +0 -0
  14. package/docs/images/5/346/217/220/347/244/272/350/257/215_en.png +0 -0
  15. package/docs/images/6/350/256/260/345/277/206.png +0 -0
  16. package/docs/images/6/350/256/260/345/277/206_en.png +0 -0
  17. package/docs/images/7/344/274/232/350/257/235.png +0 -0
  18. package/docs/images/7/344/274/232/350/257/235_en.png +0 -0
  19. package/docs/images/8/345/205/274/345/256/271.png +0 -0
  20. package/docs/images/8/345/205/274/345/256/271_en.png +0 -0
  21. package/docs/update.md +55 -0
  22. package/lib/agents-md/preset-id.js +49 -0
  23. package/lib/agents-md/service.js +180 -57
  24. package/lib/client.js +4710 -3560
  25. package/lib/compat/preset-reach.js +425 -0
  26. package/lib/compat/probe.js +665 -0
  27. package/lib/history/bridge.js +293 -0
  28. package/lib/history/workspace.js +498 -52
  29. package/lib/http-fence.js +94 -0
  30. package/lib/hub.js +160 -2
  31. package/lib/index.js +759 -157
  32. package/lib/mcp/override-blocks.js +195 -0
  33. package/lib/rules/provider.js +3 -3
  34. package/lib/rules/service.js +342 -31
  35. package/lib/scene-prompt-sync.js +112 -0
  36. package/lib/skills/core.js +123 -35
  37. package/lib/skills/service.js +9 -1
  38. package/lib/subagents/service.js +197 -23
  39. package/lib/subagents/tools.js +8 -2
  40. package/package.json +6 -3
  41. package/screenshots.json +10 -9
  42. package/docs/Changelog.md +0 -517
  43. package/docs/images/MCP.png +0 -0
  44. package/docs/images//344/274/232/350/257/235.png +0 -0
  45. package/docs/images//345/234/272/346/231/257.png +0 -0
  46. package/docs/images//345/255/220/346/231/272/350/203/275/344/275/223.png +0 -0
  47. package/docs/images//346/212/200/350/203/275.png +0 -0
  48. package/docs/images//346/217/220/347/244/272/350/257/215.png +0 -0
  49. package/docs/images//350/256/260/345/277/206.png +0 -0
package/lib/index.js CHANGED
@@ -12,13 +12,20 @@
12
12
  // artifact — same convention as DSH's own packages, which ship compiled JS).
13
13
  import { defineTool } from '@deepseek-ai/dsh-tools';
14
14
  import { createRequire } from 'node:module';
15
+ import { randomUUID } from 'node:crypto';
16
+ import { ArchiveWorkspaceRegistry as HistoryService, workspaceBaseName, workspacePathKey } from './history/workspace.js';
15
17
  import { createSkillsService, pluginLog } from './skills/service.js';
16
18
  import { createAgentsMdService } from './agents-md/service.js';
17
19
  import { createRulesService } from './rules/service.js';
18
20
  import { createArchiveEngine } from './rules/archive-engine.js';
19
- import { createSubagentService } from './subagents/service.js';
21
+ import { createSubagentService, decideToolFilter } from './subagents/service.js';
20
22
  import { isApprovalNever } from './approval-policy.js';
21
- import { hubPath, relocateEntries } from './hub.js';
23
+ import { EXPECTED_PEER_RANGE, VERIFIED_HOST_VERSION, summarize } from './compat/probe.js';
24
+ import { assessPresetReach, presetRosterOf, reachNoticeForAgent } from './compat/preset-reach.js';
25
+ import { fenceRejection, secretOpRejection } from './http-fence.js';
26
+ import { planOverrideCompaction } from './mcp/override-blocks.js';
27
+ import { hubPath, hubRoot, relocateEntries } from './hub.js';
28
+ import { createScenePromptSync } from './scene-prompt-sync.js';
22
29
  import { defineSubagentListTool, defineSubagentRunTool } from './subagents/tools.js';
23
30
  import { detectFormat, extractText, parseGenericText, parseJsonlTranscript, parseMarkdownTranscript } from './imports/parsers.js';
24
31
  import { homedir } from 'node:os';
@@ -58,15 +65,19 @@ function serializeTurns(turns, format) {
58
65
  }
59
66
  export default {
60
67
  name: 'dsh-plugin-tool-management-host',
61
- inject: ['timer', 'fs', 'settings', 'sandboxPolicy', 'webServer', 'tools', 'skills', 'sessions'],
68
+ inject: ['timer', 'fs', 'settings', 'sandboxPolicy', 'webServer', 'tools', 'skills', 'sessions', 'agents', 'workspaceRegistry', 'sessionProjectionCache', 'sessionPersistence'],
62
69
  apply(ctx, config) {
63
70
  const fs = ctx.fs;
64
71
  const settings = ctx.settings;
65
72
  const sandboxPolicy = ctx.sandboxPolicy;
66
73
  const webServer = ctx.webServer;
67
74
  const tools = ctx.tools;
75
+ const agents = ctx.get('agents');
68
76
  // pluginInventory is optional: probe at use time, degrade to no live info.
69
77
  const pluginInventory = ctx.get('pluginInventory');
78
+ // Agent 预设名单(@deepseek-ai/dsh-agent-presets):同样是可选服务,按需取用。
79
+ // 读它只为了回答"当前预设下本插件注入的东西到不到得了模型"——只读,不挂载任何预设。
80
+ const presetRoster = () => presetRosterOf(ctx);
70
81
  // Package version, surfaced in the Settings pages and the HTTP API. Read
71
82
  // from the installed package.json so it always matches the release tag.
72
83
  let PKG_VERSION = 'unknown';
@@ -74,12 +85,17 @@ export default {
74
85
  PKG_VERSION = createRequire(import.meta.url)('../package.json').version || 'unknown';
75
86
  }
76
87
  catch (e) { /* keep unknown */ }
77
- // Optional access token (defense in depth for LAN exposure). Enabled by
78
- // setting `config.token` on this plugin's loader row (profile
79
- // cordis.patch.yml override) or the DSH_PLUGIN_TOOL_MANAGEMENT_TOKEN env var. When
80
- // set, every state-changing op requires `x-dsh-token: <token>`. Read-only
81
- // ops (plugin-version, mcpm-list, skill-state) stay open so the UI still
82
- // renders; mcpm-export is guarded too because it leaks full configs.
88
+ // Optional access token. Enabled by setting `config.token` on this plugin's
89
+ // loader row (profile cordis.patch.yml override) or the
90
+ // DSH_PLUGIN_TOOL_MANAGEMENT_TOKEN env var. Two roles now:
91
+ // 1. escape hatch — a correct token is accepted *in place of* the host's
92
+ // browser authentication, so curl/scripts and LAN tooling keep working;
93
+ // 2. defense in depth — when set, every state-changing op additionally
94
+ // requires `x-dsh-token: <token>`.
95
+ // It is NOT the primary gate anymore: the route calls the host's
96
+ // connection.requestRejection fence first (Host must be loopback/LAN
97
+ // IP-literal → defeats DNS rebinding, plus browser-session cookie auth),
98
+ // so an unset token no longer means "anyone may call writes".
83
99
  // NOTE: the entry config arrives as the SECOND apply argument (Cordis
84
100
  // calls `callback(ctx, config)`) — never read it off `ctx.config`, which
85
101
  // is not an injected service and throws "cannot get property without
@@ -204,17 +220,82 @@ export default {
204
220
  }
205
221
  return states;
206
222
  }
207
- async function skillStates() {
223
+ /**
224
+ * 一次扫描同时给出三样东西(避免为了三份视图各扫一遍磁盘):
225
+ * - `states`:**真实生效**的技能 `<rootKey>/<name>` → boolean;
226
+ * - `shadowed`:被同名技能覆盖的键集合(`sk.shadowedBy` 存在);
227
+ * - `all`:全部键 → boolean(被覆盖的副本记 `false`,但键保留)。
228
+ *
229
+ * 为什么要区分:`sk.enabled` 只有**同名竞争的胜者**才有值,被覆盖的副本恒为 `undefined`。
230
+ * 旧实现用 `sk.enabled !== false` 取值,把副本误判成「已启用」,于是场景模式退出回放快照时
231
+ * 会凭空往 `enabledSkills` 写条目;反过来把副本当成「已停用」又会在进入模式时把用户
232
+ * 显式启用的记录改写成显式停用。两种都会在「同名竞争消失 / 来源重新打开」后改变实际行为。
233
+ */
234
+ async function skillScan() {
208
235
  const r = await skillsService.ops['skill-state']({});
209
236
  const states = {};
237
+ const all = {};
238
+ const shadowed = new Set();
210
239
  for (const root of ((r && r.data && r.data.roots) || [])) {
211
240
  for (const sk of (root.skills || [])) {
212
241
  const name = String(sk.declaredName || sk.name || '');
213
- if (name)
214
- states[String(root.key || '') + '/' + name] = sk.enabled !== false;
242
+ if (!name)
243
+ continue;
244
+ const key = String(root.key || '') + '/' + name;
245
+ if (sk.shadowedBy) {
246
+ shadowed.add(key);
247
+ all[key] = false;
248
+ continue;
249
+ }
250
+ const on = sk.enabled === true;
251
+ states[key] = on;
252
+ all[key] = on;
215
253
  }
216
254
  }
217
- return states;
255
+ return { states, shadowed, all };
256
+ }
257
+ async function skillStates() {
258
+ // knownSkillKeys 需要**全量键**(含被覆盖的副本):场景档案里允许勾选任意一条技能,
259
+ // 用生效集去校验会把合法勾选判成 stale 丢掉。
260
+ return (await skillScan()).all;
261
+ }
262
+ /**
263
+ * 场景档案「技能集」的技能行 —— 技能名与来源名**分开**给出。
264
+ *
265
+ * 界面早先直接渲染 `key`(形如 `<来源 key>/<技能名>`),而自定义目录的来源 key 是
266
+ * `custom-<sha256-16>`,于是导入的技能在场景档案里显示成一串哈希(用户 2026-09-15 报的)。
267
+ * 这里把来源的 label / localeKey 一并带上,界面就能显示「技能名 · 来源名」。
268
+ *
269
+ * 含被覆盖的副本(`shadowed`):场景允许勾选任意一条技能,用生效集校验会把合法勾选判成 stale。
270
+ */
271
+ async function skillRows() {
272
+ const rows = [];
273
+ try {
274
+ const r = await skillsService.ops['skill-state']({});
275
+ for (const root of ((r && r.data && r.data.roots) || [])) {
276
+ const rootKey = String((root && root.key) || '');
277
+ if (!rootKey)
278
+ continue;
279
+ for (const sk of ((root && root.skills) || [])) {
280
+ const name = String((sk && (sk.declaredName || sk.name)) || '');
281
+ if (!name)
282
+ continue;
283
+ rows.push({
284
+ key: rootKey + '/' + name,
285
+ name,
286
+ enabled: sk.enabled === true && !sk.shadowedBy,
287
+ shadowed: !!sk.shadowedBy,
288
+ rootKey,
289
+ rootLabel: String((root && root.label) || rootKey),
290
+ ...(root && root.localeKey ? { rootLocaleKey: String(root.localeKey) } : {}),
291
+ ...(root && root.kind ? { rootKind: String(root.kind) } : {}),
292
+ });
293
+ }
294
+ }
295
+ }
296
+ catch { /* 技能状态读不到 → 空列表(界面如实显示"没有可选项") */ }
297
+ // 平铺列表按技能名排(同名再按来源),比按来源聚在一起更好找。
298
+ return rows.sort((a, b) => a.name.localeCompare(b.name) || a.rootKey.localeCompare(b.rootKey));
218
299
  }
219
300
  const archiveService = createArchiveEngine({
220
301
  loadSlice: async () => ({ ...(await rulesService.readArchiveSlice()) }),
@@ -259,12 +340,24 @@ export default {
259
340
  scheduleToolRestrictions();
260
341
  },
261
342
  knownSkillKeys: async () => new Set(Object.keys(await skillStates())),
262
- currentSkills: skillStates,
343
+ // 快照只取**真实生效**的条目(不含被同名覆盖的副本)——副本不参与模式应用,
344
+ // 退出回放时也不该去动它们,否则会改写用户对副本的显式策略记录。
345
+ currentSkills: async () => (await skillScan()).states,
263
346
  applySkills: async (target) => {
347
+ // 两道过滤,缺一不可:
348
+ // 1. 被同名覆盖的副本直接跳过——它们本来就不生效,写策略只会污染用户的记录。
349
+ // 2. **已经处于目标状态的技能不再写**。`applySkills` 写的是显式策略记录,而目标
350
+ // 状态是生效状态,两者不等价:对已经生效为「停用」的技能再 disable 一次,
351
+ // 只是把「没有记录」固化成「显式停用」,场景模式进出一次就会改写 state.json。
352
+ const { states, shadowed } = await skillScan();
264
353
  for (const [key, on] of Object.entries(target)) {
265
354
  const i = key.indexOf('/');
266
355
  if (i <= 0)
267
356
  throw new Error(`技能 key 不合法: ${key}`);
357
+ if (shadowed.has(key))
358
+ continue;
359
+ if (states[key] === on)
360
+ continue;
268
361
  const root = key.slice(0, i);
269
362
  const name = key.slice(i + 1);
270
363
  const r = await skillsService.ops[on ? 'skill-enable' : 'skill-disable']({ root, name });
@@ -307,6 +400,7 @@ export default {
307
400
  ...archiveService.writeOps,
308
401
  ...subagentService.writeOps,
309
402
  'mcpm-add', 'mcpm-edit', 'mcpm-remove', 'mcpm-set-enabled', 'mcpm-set-all', 'mcpm-restart',
403
+ 'mcpm-compact',
310
404
  'mcpm-export', 'mcpm-import', 'mcpm-note', 'mcpm-settings', 'mcpm-tool-enabled',
311
405
  // mcpm-reveal returns UNMASKED secrets; even though it is a read, it is
312
406
  // token-gated like a write — on a LAN-exposed port the token must be the
@@ -315,29 +409,116 @@ export default {
315
409
  'skill-open',
316
410
  // agents-md 写操作(create/update/remove 改预设库;apply 写全局 AGENTS.md;import 从外部内容建预设)
317
411
  'agentsmd-create', 'agentsmd-update', 'agentsmd-apply', 'agentsmd-remove', 'agentsmd-import',
318
- // history 写操作(archive/unarchive 改归档集合;delete 永久删除;retention-set 写保留期)
412
+ // 提示词预设的回收站(恢复 / 永久删除都是写)
413
+ 'agentsmd-trash-restore', 'agentsmd-trash-delete',
414
+ // history 写操作(archive/unarchive 改归档集合;delete 永久删除;retention-set 写保留期;
415
+ // workspace-register 会新增一条宿主工作区登记,同样是写)
319
416
  'history-archive', 'history-unarchive', 'history-delete', 'history-retention-set',
320
417
  'history-unarchive-batch', 'history-delete-batch', 'history-import', 'history-export',
321
- 'history-archive-batch',
418
+ 'history-archive-batch', 'history-workspace-register',
322
419
  ]);
323
- // ---------- history(归档会话管理,折叠自 dsh-archive-manager)----------
324
- // cordis.patch.yml 禁用官方 workspace 与 session-projection-cache,插入本插件
325
- // 的归档感知子类(lib/history/workspace.js + lib/history/projcache.js)。子类
326
- // 经 Service.constructor 继承官方服务名(workspaceRegistry / sessionProjectionCache),
327
- // 因此 ctx.get('workspaceRegistry') 拿到的就是归档子类实例。
328
- //
329
- // 保留期(retentionDays):0 = 永久不删除,7/30 = 归档满 N 天后自动永久删除。
330
- // archivedAt 账本由子类自身维护(data/history-archived-at.json);保留期配置
331
- // 由本插件 data/history-retention.json 存储。sweeper 在 apply 时跑一次并周期
332
- // 复跑(默认 6h,可经 config.sweepIntervalMs 覆盖),把到期归档会话批量永久删除。
333
- const historyRetentionPath = String(config?.historyRetentionPath || join(PLUGIN_ROOT, 'data', 'history-retention.json'));
420
+ // 会泄露明文凭据 / 完整配置的 op:**必须**带对的访问令牌,没配令牌就一律拒绝
421
+ // (判定在 http-fence.ts 的 secretOpRejection,含两种情况的区分与理由)。
422
+ const SENSITIVE_OPS = new Set(['mcpm-reveal', 'mcpm-export']);
423
+ // 兼容体检的日志去重:同一宿主版本只写一条 compat/probe,避免轮询刷屏。
424
+ let compatLoggedFor;
425
+ // ---------- independent history facade ----------
426
+ // Keep the active workspaceRegistry/sessionProjectionCache instances intact.
427
+ // Native archive extensions are delegated by capability, not package name.
428
+ // Official gaps use a checked adapter (history/bridge.js); no service or
429
+ // storage-domain replacement. Retention uses the existing sidecar ledger.
430
+ // 历史侧车三件套(归档时刻账本 / 保留期设置 / 工作区登记快照)统一放 hub 根
431
+ // (`$DSH_HOME/tool-management/`),**不放插件目录**:npm 安装下插件目录会被
432
+ // `dsh plugin update` 整体替换,放那里等于"升级即丢账本"——账本一丢,保留期基线
433
+ // 就从 archivedAt 退回 createdAt,归档会话会被提前清掉(v0.4 把 agents-md 预设
434
+ // 搬进 hub 是同一条理由,见上方注释;这三个是当时漏掉的)。
435
+ // 旧位置(插件目录 data/)在启动时一次性搬入:只搬不删、绝不覆盖、失败下次再试。
436
+ const historyArchivedAtFile = String(config?.archivedAtFile || hubPath('history-archived-at.json'));
437
+ const historyWorkspaceSnapshotFile = String(config?.workspaceSnapshotFile || hubPath('history-workspaces.json'));
438
+ const historyRetentionPath = String(config?.historyRetentionPath || hubPath('history-retention.json'));
439
+ const historySidecarFiles = new Set(['history-archived-at.json', 'history-retention.json', 'history-workspaces.json']);
440
+ const historyStandby = relocateEntries(join(PLUGIN_ROOT, 'data'), hubRoot(), (name) => historySidecarFiles.has(name))
441
+ .catch(() => 0);
334
442
  const sweepIntervalMs = Number(config?.sweepIntervalMs || 0) || 6 * 60 * 60 * 1000;
443
+ let history;
444
+ ctx.effect(() => {
445
+ const registry = ctx.get('workspaceRegistry');
446
+ if (registry && typeof registry.archiveSession === 'function') {
447
+ history = new HistoryService(ctx, registry, {
448
+ archivedAtFile: historyArchivedAtFile,
449
+ workspaceSnapshotFile: historyWorkspaceSnapshotFile,
450
+ ready: historyStandby,
451
+ });
452
+ }
453
+ return async () => { const current = history; history = undefined; await current?.dispose(); };
454
+ }, 'dsh-plugin-tool-management: independent history');
335
455
  function getHistoryRegistry() {
336
- const r = ctx.get('workspaceRegistry');
337
- return r && typeof r.archiveSession === 'function' ? r : undefined;
456
+ return history;
457
+ }
458
+ // ── 场景提示词 → 全局基线(AGENTS.md)同步 ────────────────────────────────
459
+ //
460
+ // 用户裁定(2026-09-15):「切换场景,对应的提示词直接把 AGENTS.md 直接修改」。
461
+ // 实现收在 ./scene-prompt-sync.ts(可在临时目录上端到端验证):启用/切换场景、
462
+ // 关掉场景、改绑定、编辑"驱动基线的那份预设"四个动作都会同步;关掉场景时按
463
+ // 进场景前的基线快照(`scene-baseline.json`,hub 内)原文写回。
464
+ const scenePromptSync = createScenePromptSync({
465
+ agentsMd: agentsMdService,
466
+ rules: rulesService,
467
+ baselineFile: hubPath('scene-baseline.json'),
468
+ logger: ctx.logger,
469
+ });
470
+ /**
471
+ * 把同步结果并进 op 响应:只在原操作成功时同步,失败原样透传。
472
+ * 同步失败**不改**原操作的成功结论(场景确实切了),而是把原因放进 `agentsMd.error`,
473
+ * 由界面如实显示成警告 —— 绝不假装文件已经改好。
474
+ */
475
+ const withAgentsMdSync = (res) => scenePromptSync.withSync(res);
476
+ /**
477
+ * 恢复归档后的**归属恢复**(best-effort,绝不阻断恢复本身):按会话 cwd 找到或重建
478
+ * 工作区登记,并把会话挂回该登记的 `sessionIds`。恢复成功但归属恢复失败时返回 undefined,
479
+ * 由客户端按"会话已恢复、工作区归属未恢复"如实呈现。
480
+ */
481
+ async function restoreWorkspaceAccounting(sessionIds) {
482
+ const registry = getHistoryRegistry();
483
+ if (!registry || typeof registry.ensureWorkspaceAccounting !== 'function' || !sessionIds.length)
484
+ return undefined;
485
+ try {
486
+ return await registry.ensureWorkspaceAccounting(sessionIds);
487
+ }
488
+ catch (e) {
489
+ ctx.logger?.warn?.(`history: workspace accounting after restore failed: ${message(e)}`);
490
+ return undefined;
491
+ }
492
+ }
493
+ /**
494
+ * **当前生效的提示词预设 id**(界面「生效中」的那一份),供写操作做门禁:
495
+ * ① 启用的场景绑定了它 → 就是它(场景优先级最高,与 agentsmd-list 的标记同一口径);
496
+ * ② 否则回退到文件比对(`~/.dsh/AGENTS.md` 内容 == 某份预设)。
497
+ * 探测失败返回 `null`(调用方决定放行还是拒绝;当前只在删除上使用,放行 + warn)。
498
+ */
499
+ async function effectivePresetId() {
500
+ try {
501
+ const r = await rulesService.ops['rules-list']({});
502
+ const sp = r && r.ok ? r.scenePrompt : null;
503
+ if (sp && sp.scene && !sp.missing && sp.presetId)
504
+ return String(sp.presetId);
505
+ }
506
+ catch (e) {
507
+ ctx.logger?.warn?.(`agents-md: scene prompt probe failed: ${message(e)}`);
508
+ }
509
+ try {
510
+ const cur = await agentsMdService.getCurrent();
511
+ if (cur && cur.ok && cur.presetId)
512
+ return String(cur.presetId);
513
+ }
514
+ catch (e) {
515
+ ctx.logger?.warn?.(`agents-md: global baseline probe failed: ${message(e)}`);
516
+ }
517
+ return null;
338
518
  }
339
519
  async function readHistoryRetention() {
340
520
  try {
521
+ await historyStandby;
341
522
  const raw = await readFile(historyRetentionPath, 'utf8');
342
523
  const obj = JSON.parse(raw);
343
524
  const days = Number((obj && obj.retentionDays) ?? 0);
@@ -352,6 +533,7 @@ export default {
352
533
  }
353
534
  }
354
535
  async function writeHistoryRetention(retentionDays) {
536
+ await historyStandby;
355
537
  const dir = dirname(historyRetentionPath);
356
538
  await mkdir(dir, { recursive: true });
357
539
  // updatedAt = 修改时刻:每次改保留期,已归档会话的到期基线重置为此时刻。
@@ -1669,7 +1851,13 @@ export default {
1669
1851
  }
1670
1852
  else {
1671
1853
  c = removeMarked(c, id, 'enable');
1672
- c = appendBlock(c, buildDisableBlock(id, true));
1854
+ // 幂等:已经生效为「停用」时不再追加 disable 块。无条件 append 会让每次点
1855
+ // 「停用」都往 patch 里塞一条重复条目(历史上 18 条互相矛盾的条目就是这么来的,
1856
+ // 最终生效值只能靠 last-wins 合并顺序猜),且文件会随每次启停线性膨胀。
1857
+ const { rows } = parseRows(c);
1858
+ const row = rows.find((r) => r.id === id);
1859
+ if (!row || !row.disabled)
1860
+ c = appendBlock(c, buildDisableBlock(id, true));
1673
1861
  }
1674
1862
  await writePatch(abs, c);
1675
1863
  return { ok: true };
@@ -1800,6 +1988,55 @@ export default {
1800
1988
  return { ok: true };
1801
1989
  });
1802
1990
  }
1991
+ /**
1992
+ * 收敛补丁文件里的启停覆盖块(用户显式点「整理补丁」才走这里)。
1993
+ *
1994
+ * 只删"删掉也不改变生效状态"的块(判定见 planOverrideCompaction):insert 行、
1995
+ * 带 config 的覆盖块、以及决定当前生效值的那一条都原样保留。删除前 writePatch
1996
+ * 会自动备份上一版(保留最近 5 份),所以这一步是可回退的。
1997
+ *
1998
+ * 基准状态由 planOverrideCompaction 自己从 insert 行读(另一份文件的内容只用来补
1999
+ * "insert 行不在本文件里"的情况)。这里**不要**喂 parseRows() 的 rows —— 那是合并
2000
+ * 覆盖块之后的生效值,会让每个 decider 都被判成多余:第一版就是这么把 6 台 MCP
2001
+ * 全部启用的。同理,界面不显示任何"多余块数"(那个数字也来自同一条错误口径)。
2002
+ */
2003
+ async function mcpmCompact() {
2004
+ const p = await ensurePaths();
2005
+ return withWriteLock(async () => {
2006
+ const levels = ['project', 'global'];
2007
+ const absOf = (level) => (level === 'project' ? p.projectPatch : p.globalPatch);
2008
+ const contents = {};
2009
+ for (const level of levels) {
2010
+ try {
2011
+ contents[level] = await readPatch(absOf(level));
2012
+ }
2013
+ catch (e) {
2014
+ contents[level] = '';
2015
+ }
2016
+ }
2017
+ const removed = {};
2018
+ const files = [];
2019
+ let total = 0;
2020
+ for (const level of levels) {
2021
+ const content = contents[level];
2022
+ if (!content)
2023
+ continue;
2024
+ const others = levels.filter((other) => other !== level).map((other) => contents[other]).filter(Boolean);
2025
+ const plan = planOverrideCompaction(content, others);
2026
+ if (!plan.ranges.length)
2027
+ continue;
2028
+ await writePatch(absOf(level), spliceRanges(splitLines(content), plan.ranges));
2029
+ files.push(absOf(level));
2030
+ for (const [id, stat] of Object.entries(plan.ids)) {
2031
+ if (!stat.dropped)
2032
+ continue;
2033
+ removed[id] = (removed[id] || 0) + stat.dropped;
2034
+ total += stat.dropped;
2035
+ }
2036
+ }
2037
+ return { ok: true, removed: total, ids: removed, files };
2038
+ });
2039
+ }
1803
2040
  async function mcpmExport() {
1804
2041
  const p = await ensurePaths();
1805
2042
  const list = await mcpmList();
@@ -1979,6 +2216,109 @@ export default {
1979
2216
  },
1980
2217
  };
1981
2218
  }
2219
+ /**
2220
+ * 归档会话的归属解析(三级,宿主侧算好再给客户端):
2221
+ * 1. 活登记的 sessionIds 命中 → 组 = 该登记;
2222
+ * 2. 未命中但规范路径命中某活登记 → 组 = 该登记(会话不在其记账里,属正常);
2223
+ * 3. 没有任何活登记 → 按会话目录重建分组(`path:<归一化路径>`),
2224
+ * 目录仍在则可一键重新登记(写回宿主),目录已不在则退到 header 里的原始 cwd。
2225
+ * 宿主删除工作区是硬删、不留墓碑,登记一没,`workspaceId` 就再也查不到;但会话目录
2226
+ * (`registry.sessionPaths` / header.cwd)还在,所以归属可以按目录重建、并在目录回来时
2227
+ * 自动并回同一组。除插件自己 `data/` 下的「见过的登记」快照外,本函数不写任何宿主状态。
2228
+ * 任何一步失败都降级为「不分组」,绝不阻断列表。
2229
+ */
2230
+ async function buildHistoryGroups(registry, items) {
2231
+ const groups = [];
2232
+ const workspaces = {};
2233
+ try {
2234
+ const groupsById = new Map();
2235
+ const liveByPath = new Map();
2236
+ const owned = new Map();
2237
+ const liveRecords = [];
2238
+ const table = typeof registry.requireTable === 'function' ? registry.requireTable() : undefined;
2239
+ const state = typeof registry.requireState === 'function' ? registry.requireState() : undefined;
2240
+ if (table) {
2241
+ const ids = state && Array.isArray(state.workspaceIds) && state.workspaceIds.length
2242
+ ? state.workspaceIds
2243
+ : [...table.entries()].map(([id]) => id);
2244
+ for (const wid of ids) {
2245
+ const rec = table.get(wid);
2246
+ if (!rec || !Array.isArray(rec.sessionIds))
2247
+ continue;
2248
+ const path = typeof rec.path === 'string' && rec.path ? rec.path : undefined;
2249
+ const title = (rec.title && String(rec.title)) || (path ? workspaceBaseName(path) : String(wid));
2250
+ for (const sid of rec.sessionIds)
2251
+ if (!owned.has(sid))
2252
+ owned.set(sid, wid);
2253
+ if (path) {
2254
+ liveByPath.set(workspacePathKey(path), wid);
2255
+ liveRecords.push({ id: String(wid), path, title });
2256
+ }
2257
+ const view = { id: 'ws:' + wid, title, kind: 'live', registered: true, ...(path ? { path } : {}) };
2258
+ groupsById.set(view.id, view);
2259
+ groups.push(view);
2260
+ workspaces[wid] = { title, ...(path ? { path } : {}) };
2261
+ }
2262
+ }
2263
+ // 「见过的登记」快照:登记被删后仍能给出原命名,并区分「已移除」与「从未登记」。
2264
+ const snapshot = typeof registry.workspaceSnapshot === 'function' ? await registry.workspaceSnapshot() : undefined;
2265
+ if (liveRecords.length && typeof registry.rememberWorkspaces === 'function') {
2266
+ try {
2267
+ await registry.rememberWorkspaces(liveRecords);
2268
+ }
2269
+ catch { /* 快照写失败不影响分组 */ }
2270
+ }
2271
+ const sessionPaths = registry.sessionPaths instanceof Map ? registry.sessionPaths : undefined;
2272
+ const headers = registry.headers instanceof Map ? registry.headers : undefined;
2273
+ for (const it of items) {
2274
+ const sid = it.sessionId;
2275
+ let wid = owned.get(sid);
2276
+ const canonical = sessionPaths ? sessionPaths.get(sid) : undefined;
2277
+ if (wid === undefined && typeof canonical === 'string' && canonical) {
2278
+ const byPath = liveByPath.get(workspacePathKey(canonical));
2279
+ if (byPath !== undefined)
2280
+ wid = byPath;
2281
+ }
2282
+ if (wid !== undefined) {
2283
+ it.workspaceId = wid;
2284
+ it.groupId = 'ws:' + wid;
2285
+ continue;
2286
+ }
2287
+ let path = typeof canonical === 'string' && canonical ? canonical : undefined;
2288
+ if (path === undefined && it.cwd)
2289
+ path = it.cwd;
2290
+ if (path === undefined && headers) {
2291
+ const cwd = headers.get(sid)?.cwd;
2292
+ if (typeof cwd === 'string' && cwd)
2293
+ path = cwd;
2294
+ }
2295
+ if (!path)
2296
+ continue;
2297
+ const key = 'path:' + workspacePathKey(path);
2298
+ it.groupId = key;
2299
+ const known = snapshot ? snapshot.get(workspacePathKey(path)) : undefined;
2300
+ const existing = groupsById.get(key);
2301
+ if (existing) {
2302
+ // 同目录的多个会话:目录存在(sessionPaths 命中)即可重新登记。
2303
+ if (canonical !== undefined)
2304
+ existing.canRegister = true;
2305
+ continue;
2306
+ }
2307
+ const view = {
2308
+ id: key,
2309
+ title: known?.title || workspaceBaseName(path) || path,
2310
+ kind: 'detached',
2311
+ registered: known !== undefined,
2312
+ canRegister: canonical !== undefined,
2313
+ path: known?.path || path,
2314
+ };
2315
+ groupsById.set(key, view);
2316
+ groups.push(view);
2317
+ }
2318
+ }
2319
+ catch { /* 分组是增强:任何异常都退回扁平列表 */ }
2320
+ return { groups, workspaces };
2321
+ }
1982
2322
  /**
1983
2323
  * 记忆候选(档案编辑器第 4 段「记忆」):`[{ id, scene, name, description }]`。
1984
2324
  * 只读、不读正文(勾选集只存 id);任何异常降级为空列表,不让档案弹窗崩掉。
@@ -2045,32 +2385,24 @@ export default {
2045
2385
  const agentPresets = (typeof ctx.get === 'function' ? ctx.get('agentPresets') : undefined);
2046
2386
  if (agentPresets && typeof agentPresets.list === 'function') {
2047
2387
  try {
2388
+ // roster 顺序 = 官方的展示顺序(preset.yml 的 order:标准 1 / PTC 2 / 极简 3 / 创造 4,
2389
+ // 自建预设排在其后)。人设编辑器的四行照抄这个顺序,**不要**再按 id 排序。
2048
2390
  const roster = await agentPresets.list();
2049
2391
  for (const p of roster || []) {
2050
2392
  const id = String((p && p.id) || '');
2051
2393
  if (!id)
2052
2394
  continue;
2053
- presets.push({ id, name: String((p && (p.name || p.id)) || id) });
2054
- let scopeKey;
2055
- try {
2056
- // 官方说明:该调用会确保预设的 standing mount(不创建 agent/session),
2057
- // 已挂载的预设直接复用;正因如此才需要这里的 60 秒缓存。
2058
- scopeKey = typeof agentPresets.standingKeyFor === 'function' ? await agentPresets.standingKeyFor(id) : undefined;
2059
- }
2060
- catch {
2061
- scopeKey = undefined;
2062
- }
2063
- let names = [];
2064
- try {
2065
- names = (await tools.schemas(scopeKey) || []).map((s) => String(s.name)).filter(Boolean);
2066
- }
2067
- catch {
2068
- names = [];
2069
- }
2395
+ // MCP 工具名形如 `mcp__<server>__<tool>`:面向上百个条目,噪声大于价值 → 不进候选。
2396
+ // 子代理照样能用当前在跑的 MCP —— 那份名单在 decideToolFilter 里运行时并进白名单。
2397
+ const names = (await presetToolNames(id)).filter((name) => !name.startsWith('mcp__'));
2398
+ presets.push({
2399
+ id,
2400
+ name: String((p && (p.name || p.id)) || id),
2401
+ trust: String((p && p.trust) || 'user'),
2402
+ broken: typeof (p && p.broken) === 'string',
2403
+ tools: names,
2404
+ });
2070
2405
  for (const name of names) {
2071
- // MCP 工具名形如 `mcp__<server>__<tool>`:面向上百个条目,噪声大于价值 → 不进候选。
2072
- if (name.startsWith('mcp__'))
2073
- continue;
2074
2406
  const rec = byName.get(name) || { name, presets: new Set(), current: false };
2075
2407
  rec.presets.add(id);
2076
2408
  byName.set(name, rec);
@@ -2092,11 +2424,94 @@ export default {
2092
2424
  tools: [...byName.values()]
2093
2425
  .map((r) => ({ name: r.name, presets: [...r.presets].sort(), current: r.current }))
2094
2426
  .sort((a, b) => a.name.localeCompare(b.name)),
2095
- presets: presets.sort((a, b) => a.id.localeCompare(b.id)),
2427
+ // 保持 roster 顺序(官方的展示顺序),前端四行直接照用。
2428
+ presets,
2096
2429
  };
2097
2430
  presetToolsCache = { at: Date.now(), value };
2098
2431
  return value;
2099
2432
  }
2433
+ /**
2434
+ * 单个预设的工具名(列表里含 MCP 与宿主平面的工具,因为子代理的可见集合是
2435
+ * "宿主平面 ∪ 该预设"的并集)。枚举会为该预设建立 standing mount —— 官方语义:
2436
+ * 一个预设每进程只挂一次,正常创建会话时同样会挂,所以这里按 id 缓存 60 秒;
2437
+ * `subagent_run` 每次委派都要用它校验名单,不能每次都重新枚举。
2438
+ */
2439
+ const presetNamesCache = new Map();
2440
+ async function presetToolNames(id) {
2441
+ const hit = presetNamesCache.get(id);
2442
+ if (hit && Date.now() - hit.at < 60000)
2443
+ return hit.names;
2444
+ const agentPresets = (typeof ctx.get === 'function' ? ctx.get('agentPresets') : undefined);
2445
+ let scopeKey;
2446
+ try {
2447
+ scopeKey = agentPresets && typeof agentPresets.standingKeyFor === 'function'
2448
+ ? await agentPresets.standingKeyFor(id)
2449
+ : undefined;
2450
+ }
2451
+ catch {
2452
+ scopeKey = undefined;
2453
+ }
2454
+ let names = [];
2455
+ try {
2456
+ names = ((await tools.schemas(scopeKey)) || []).map((s) => String(s.name)).filter(Boolean);
2457
+ }
2458
+ catch {
2459
+ names = [];
2460
+ }
2461
+ presetNamesCache.set(id, { at: Date.now(), names });
2462
+ return names;
2463
+ }
2464
+ /** 预设名单的名字投影(只读 roster,不挂载任何预设):场景页显示模式名用。 */
2465
+ async function presetNames() {
2466
+ const agentPresets = (typeof ctx.get === 'function' ? ctx.get('agentPresets') : undefined);
2467
+ if (!agentPresets || typeof agentPresets.list !== 'function')
2468
+ return [];
2469
+ try {
2470
+ const roster = await agentPresets.list();
2471
+ return (roster || [])
2472
+ .map((p) => ({ id: String((p && p.id) || ''), name: String((p && (p.name || p.id)) || ''), trust: String((p && p.trust) || 'user') }))
2473
+ .filter((p) => p.id !== '');
2474
+ }
2475
+ catch {
2476
+ return [];
2477
+ }
2478
+ }
2479
+ /** 调用方 agent 跑在哪个 Agent 预设上;判断不了返回 null(绝不猜)。 */
2480
+ function currentPresetId(agentCtx) {
2481
+ if (agentCtx === undefined || agentCtx === null)
2482
+ return null;
2483
+ try {
2484
+ const roster = presetRoster();
2485
+ if (roster === undefined || typeof roster.composedPreset !== 'function')
2486
+ return null;
2487
+ const id = String(roster.composedPreset(agentCtx) ?? '');
2488
+ return id === '' ? null : id;
2489
+ }
2490
+ catch {
2491
+ return null;
2492
+ }
2493
+ }
2494
+ /**
2495
+ * 人设的工具限制:按**当前会话的预设**决定这次委派下发什么(规则见 decideToolFilter)。
2496
+ * 已知工具名 = 宿主平面(含 MCP)∪ 当前预设的工具;枚举失败时退化成宿主平面,
2497
+ * 于是模式名单里的预设侧工具会被如实记为"当前不存在",而不是造成官方 restrict 抛错。
2498
+ */
2499
+ async function subagentToolFilterFor(persona, agentCtx) {
2500
+ let hostNames = [];
2501
+ try {
2502
+ hostNames = ((await tools.schemas()) || []).map((s) => String(s.name)).filter(Boolean);
2503
+ }
2504
+ catch {
2505
+ hostNames = [];
2506
+ }
2507
+ const mcp = hostNames.filter((name) => name.startsWith('mcp__'));
2508
+ const known = new Set(hostNames);
2509
+ const presetId = currentPresetId(agentCtx);
2510
+ if (presetId !== null)
2511
+ for (const name of await presetToolNames(presetId))
2512
+ known.add(name);
2513
+ return decideToolFilter(persona, presetId, { names: known, mcp });
2514
+ }
2100
2515
  /**
2101
2516
  * 模型候选(人设的「模型」下拉):宿主已注册的 provider + 各 provider 能宣告的模型。
2102
2517
  * 只读宿主 LLM 目录,**不发起网络请求**(`discoverModels` 会打端点,这里不用);
@@ -2156,6 +2571,7 @@ export default {
2156
2571
  'mcpm-set-all': mcpmSetAll,
2157
2572
  'mcpm-restart': mcpmRestart,
2158
2573
  'mcpm-remove': mcpmRemove,
2574
+ 'mcpm-compact': mcpmCompact,
2159
2575
  'mcpm-export': mcpmExport,
2160
2576
  'mcpm-import': mcpmImport,
2161
2577
  // 技能管理 ops(由 ./skills/service.js 提供):skill-state / skill-detail /
@@ -2171,6 +2587,15 @@ export default {
2171
2587
  // rules-remove-scene。
2172
2588
  // 成功返回扁平 {ok:true, ...}(不套 data),失败 {ok:false, error, code?}。
2173
2589
  ...rulesService.ops,
2590
+ // 场景 ↔ 全局基线(AGENTS.md)同步:用户裁定「切换场景,对应的提示词直接把 AGENTS.md
2591
+ // 直接修改」,所以这四个 rules 写 op 走包装 —— 先执行原逻辑,成功后把当前场景绑定的
2592
+ // 提示词写进 `~/.dsh/AGENTS.md`(或关掉场景时恢复进场景前的基线),结果并进响应
2593
+ // 的 `agentsMd` 字段(`applied` / `restored` / `unchanged` / `error`)。
2594
+ // 放在 spread 之后,显式覆盖同名 op。
2595
+ 'rules-set-active': async (args) => withAgentsMdSync(await rulesService.ops['rules-set-active'](args)),
2596
+ 'rules-update-scene': async (args) => withAgentsMdSync(await rulesService.ops['rules-update-scene'](args)),
2597
+ 'rules-create-scene': async (args) => withAgentsMdSync(await rulesService.ops['rules-create-scene'](args)),
2598
+ 'rules-remove-scene': async (args) => withAgentsMdSync(await rulesService.ops['rules-remove-scene'](args)),
2174
2599
  // 场景档案 ops(由 ./rules/archive-engine.ts 提供):scene-mode-get /
2175
2600
  // scene-archive-save / scene-mode-set。成功返回扁平 {ok:true, ...},
2176
2601
  // 失败 {ok:false, error};写 op 已含 archiveService.writeOps 门禁派生。
@@ -2185,7 +2610,7 @@ export default {
2185
2610
  'model-candidates': async () => ({ ok: true, ...(await modelCandidates()) }),
2186
2611
  // 场景档案勾选器数据源 v2:全部 MCP 服务器(含未运行)+ 技能全集 + 人设清单。
2187
2612
  'scene-inventory': async () => {
2188
- const [rowsR, tools, skills, subs] = await Promise.all([mcpmListView(), toolStates(), skillStates(), subagentService.list()]);
2613
+ const [rowsR, tools, skills, subs] = await Promise.all([mcpmListView(), toolStates(), skillRows(), subagentService.list()]);
2189
2614
  const disabledRaw = await readDisabledTools();
2190
2615
  const rows = (rowsR && rowsR.rows) || [];
2191
2616
  const mcpServers = [];
@@ -2209,8 +2634,13 @@ export default {
2209
2634
  ok: true,
2210
2635
  mcpServers,
2211
2636
  tools: Object.entries(tools).map(([key, enabled]) => ({ key, enabled })),
2212
- skills: Object.entries(skills).map(([key, enabled]) => ({ key, enabled })),
2213
- subagents: subs.map((p) => ({ name: p.name, description: p.description })),
2637
+ // 技能行:技能名与来源名分开给(早先只给 `<来源 key>/<技能名>`,自定义目录的
2638
+ // 来源 key 是 `custom-<hash>`,于是导入的技能在场景里显示成一串哈希)。
2639
+ skills,
2640
+ // 人设行要显示"按模式配了什么限制",所以把按模式名单一起带上;
2641
+ // presets 只给名字与来源(不枚举工具,避免打开档案弹窗就挂载每个预设)。
2642
+ subagents: subs.map((p) => ({ name: p.name, description: p.description, toolsByPreset: p.toolsByPreset ?? null })),
2643
+ presets: await presetNames(),
2214
2644
  // 档案编辑器第 4 段「记忆」的候选:场景 + 每个场景里的记忆条数。
2215
2645
  // 记忆正文不在这里返回(勾选集只存 id,渲染时才读盘)。
2216
2646
  scenes: await memorySceneCandidates(),
@@ -2220,59 +2650,94 @@ export default {
2220
2650
  // AGENTS.md 预设库 ops(由 ./agents-md/service.js 提供):agentsmd-list /
2221
2651
  // agentsmd-read / agentsmd-create / agentsmd-update / agentsmd-remove /
2222
2652
  // agentsmd-apply / agentsmd-get-current
2223
- 'agentsmd-list': () => agentsMdService.list(),
2653
+ // 提示词预设库(由 ./agents-md/service.ts 提供)。这里把两件事对齐成用户看到的一份状态:
2654
+ // - `active`(界面「生效中」)= **当前真正在起作用的基线**:启用的场景绑了预设就是它,
2655
+ // 否则才看文件比对(预设正文 == ~/.dsh/AGENTS.md)。用户裁定:「场景启动了,
2656
+ // 提示词页生效中的应该是场景选择的那个」。
2657
+ // - `fileApplied` = 文件比对结果(AGENTS.md 里确实是它)。两者同时为真说明
2658
+ // 场景绑的就是已应用的那份,正文一致时宿主不会再注入第二遍(去重)。
2659
+ 'agentsmd-list': async () => {
2660
+ const base = await agentsMdService.list();
2661
+ if (!base || base.ok === false)
2662
+ return base;
2663
+ let scenePrompt;
2664
+ try {
2665
+ const r = await rulesService.ops['rules-list']({});
2666
+ if (r && r.ok && r.scenePrompt)
2667
+ scenePrompt = r.scenePrompt;
2668
+ }
2669
+ catch { /* 场景服务不可用 → 只回预设库 */ }
2670
+ const sceneId = scenePrompt && !scenePrompt.missing && scenePrompt.scene ? scenePrompt.presetId : null;
2671
+ // 场景驱动时**只有场景绑的那份**算生效中(用户裁定);文件里那份降级为「文件里是它」,
2672
+ // 因为场景的提示词已经接管基线。没有场景驱动时才按文件比对定「生效中」。
2673
+ const sceneDrives = sceneId !== null;
2674
+ const presets = base.presets.map((p) => {
2675
+ const fileApplied = p.active === true;
2676
+ const byScene = sceneDrives && sceneId === p.id;
2677
+ const active = sceneDrives ? byScene : fileApplied;
2678
+ return { ...p, active, fileApplied, activeVia: byScene ? 'scene' : (active && fileApplied ? 'file' : null) };
2679
+ });
2680
+ return { ...base, presets, ...(scenePrompt ? { scenePrompt } : {}) };
2681
+ },
2224
2682
  'agentsmd-read': (args) => agentsMdService.read(String((args && args.id) || '')),
2225
- 'agentsmd-create': (args) => agentsMdService.create(String((args && args.id) || ''), args && args.from ? String(args.from) : undefined),
2226
- 'agentsmd-update': (args) => agentsMdService.update(String((args && args.id) || ''), String((args && args.content) ?? '')),
2683
+ // content 与 from 都可选:新建弹窗里直接写正文(content),或从现有预设复制(from)。
2684
+ 'agentsmd-create': (args) => agentsMdService.create(String((args && args.id) || ''), {
2685
+ ...(args && args.from ? { from: String(args.from) } : {}),
2686
+ ...(args && typeof args.content === 'string' ? { content: String(args.content) } : {}),
2687
+ }),
2688
+ // 保存:改正文 + 可选改名(nextId)。改名成功顺带把场景绑定一起改名 ——
2689
+ // 绑定存在 rules-index.json 里,预设库自己看不到,不叫这一声就会留悬空绑定。
2690
+ // 保存后同步一次全局基线:若改的正是「正在驱动基线的那份」,正文要跟着写进
2691
+ // `~/.dsh/AGENTS.md`(否则页面标着「生效中」而文件里还是旧内容)。
2692
+ 'agentsmd-update': async (args) => {
2693
+ const res = await agentsMdService.update(String((args && args.id) || ''), String((args && args.content) ?? ''), args && args.nextId !== undefined ? String(args.nextId) : undefined);
2694
+ if (!res || res.ok === false)
2695
+ return res;
2696
+ let renamed = {};
2697
+ if (res.renamedFrom) {
2698
+ try {
2699
+ const r = await rulesService.ops['rules-rebind-prompt']({ from: res.renamedFrom, to: res.id });
2700
+ renamed = r && r.ok ? { reboundScenes: r.changed } : {};
2701
+ }
2702
+ catch { /* 改名同步失败不影响保存本身 */ }
2703
+ }
2704
+ return withAgentsMdSync({ ...res, ...renamed });
2705
+ },
2227
2706
  'agentsmd-apply': (args) => agentsMdService.apply(String((args && args.id) || '')),
2228
2707
  'agentsmd-get-current': () => agentsMdService.getCurrent(),
2229
- 'agentsmd-remove': (args) => agentsMdService.remove(String((args && args.id) || '')),
2708
+ // 删除:**正在生效的那份拒绝删除**(用户裁定)。生效 = 启用的场景绑定了它,
2709
+ // 否则 = 文件内容就是它。禁用的按钮只是提示,真正的门在这里(模型与旧页面也会走这条路)。
2710
+ // 探测失败时放行并记一条 warn:删预设不动 ~/.dsh/AGENTS.md,最坏是少一份副本,可重建。
2711
+ 'agentsmd-remove': async (args) => {
2712
+ const id = String((args && args.id) || '');
2713
+ const effective = await effectivePresetId();
2714
+ if (effective !== null && effective === id) {
2715
+ return { ok: false, error: `「${id}」正在生效,不能删除:先启用别的场景或换绑提示词,或先把别的预设应用上去。` };
2716
+ }
2717
+ return agentsMdService.remove(id);
2718
+ },
2230
2719
  'agentsmd-import': (args) => agentsMdService.importPreset(String((args && args.id) || ''), String((args && args.content) ?? '')),
2720
+ // 提示词预设的回收站:删预设 = 把整个预设目录移入 `hub/trash/agents-md-trash/<id>/`,
2721
+ // 误删可从「提示词」页的回收站里恢复(同 id 已存在时拒绝恢复,绝不覆盖)。
2722
+ 'agentsmd-trash-list': () => agentsMdService.trashList(),
2723
+ 'agentsmd-trash-restore': (args) => agentsMdService.trashRestore(String((args && args.id) || '')),
2724
+ 'agentsmd-trash-delete': (args) => agentsMdService.trashDelete(String((args && args.id) || '')),
2231
2725
  // History(归档会话管理)ops。只读:history-list / history-retention-get;
2232
2726
  // 写:history-archive / history-unarchive / history-delete / history-retention-set。
2233
2727
  // workspaceRegistry 缺失(补丁未生效)时返回明确错误,不崩页面。
2234
2728
  'history-list': async () => {
2235
2729
  const registry = getHistoryRegistry();
2236
2730
  if (!registry)
2237
- return { ok: false, error: '归档服务未挂载:workspace 已被替换?请确认本插件补丁已生效。' };
2731
+ return { ok: false, error: '归档服务未就绪:请检查宿主 workspaceRegistry 服务。' };
2238
2732
  try {
2239
2733
  const items = (typeof registry.archivedSessionDetails === 'function')
2240
2734
  ? (await registry.archivedSessionDetails()).items
2241
2735
  : (await registry.archivedSessionMetadata()).items.map((i) => ({ sessionId: i.sessionId, createdAt: i.createdAt, archivedAt: registry.archivedAt?.(i.sessionId) }));
2242
2736
  const { retentionDays } = await readHistoryRetention();
2243
- // 分组增强(best-effort):用 workspace 记账表反查每个归档会话的归属,
2244
- // 供客户端按项目分组。requireTable 缺失或任何异常都静默降级为扁平列表。
2245
- if (typeof registry.requireTable === 'function') {
2246
- try {
2247
- const table = registry.requireTable();
2248
- const state = typeof registry.requireState === 'function' ? registry.requireState() : undefined;
2249
- const wsIds = state && Array.isArray(state.workspaceIds) && state.workspaceIds.length
2250
- ? state.workspaceIds
2251
- : [...table.entries()].map(([id]) => id);
2252
- const owned = new Map();
2253
- const workspaces = {};
2254
- for (const wid of wsIds) {
2255
- const rec = table.get(wid);
2256
- if (!rec || !Array.isArray(rec.sessionIds))
2257
- continue;
2258
- for (const sid of rec.sessionIds)
2259
- if (!owned.has(sid))
2260
- owned.set(sid, wid);
2261
- workspaces[wid] = {
2262
- title: (rec.title && String(rec.title)) || String(wid),
2263
- ...(typeof rec.path === 'string' && rec.path ? { path: rec.path } : {}),
2264
- };
2265
- }
2266
- for (const it of items) {
2267
- const wid = owned.get(it.sessionId);
2268
- if (wid !== undefined)
2269
- it.workspaceId = wid;
2270
- }
2271
- return { ok: true, items, workspaces, retentionDays };
2272
- }
2273
- catch (e) { /* 降级为下方扁平返回 */ }
2274
- }
2275
- return { ok: true, items, retentionDays };
2737
+ // 归属分组(best-effort):活登记 → 路径命中 → 按会话目录重建。
2738
+ // 宿主删除工作区后登记消失,靠会话目录把分组补回来(详见 buildHistoryGroups)。
2739
+ const { groups, workspaces } = await buildHistoryGroups(registry, items);
2740
+ return { ok: true, items, groups, workspaces, retentionDays };
2276
2741
  }
2277
2742
  catch (e) {
2278
2743
  return { ok: false, error: message(e) };
@@ -2291,23 +2756,6 @@ export default {
2291
2756
  ? (await registry.archivedSessionDetails()).items
2292
2757
  : (await registry.archivedSessionMetadata()).items;
2293
2758
  const archived = new Set(archivedItems.map((i) => i.sessionId));
2294
- // 工作区归属反查(与 history-list 一致;best-effort)。
2295
- const table = typeof registry.requireTable === 'function' ? registry.requireTable() : undefined;
2296
- const state = typeof registry.requireState === 'function' ? registry.requireState() : undefined;
2297
- const wsIds = state && Array.isArray(state.workspaceIds) && state.workspaceIds.length
2298
- ? state.workspaceIds
2299
- : (table ? [...table.entries()].map(([id]) => id) : []);
2300
- const owned = new Map();
2301
- if (table) {
2302
- for (const wid of wsIds) {
2303
- const rec = table.get(wid);
2304
- if (!rec || !Array.isArray(rec.sessionIds))
2305
- continue;
2306
- for (const sid of rec.sessionIds)
2307
- if (!owned.has(sid))
2308
- owned.set(sid, wid);
2309
- }
2310
- }
2311
2759
  const cache = ctx.get('sessionProjectionCache');
2312
2760
  const items = [];
2313
2761
  for (const h of headers) {
@@ -2325,9 +2773,6 @@ export default {
2325
2773
  item.cwd = h.cwd;
2326
2774
  if (typeof h.createdAt === 'number' && Number.isFinite(h.createdAt))
2327
2775
  item.createdAt = h.createdAt;
2328
- const wid = owned.get(sessionId);
2329
- if (wid !== undefined)
2330
- item.workspaceId = wid;
2331
2776
  if (cache && typeof cache.cachedSnapshot === 'function') {
2332
2777
  try {
2333
2778
  const snap = cache.cachedSnapshot(h, 0, ['title']);
@@ -2348,7 +2793,9 @@ export default {
2348
2793
  }
2349
2794
  items.push(item);
2350
2795
  }
2351
- return { ok: true, items };
2796
+ // 归属分组(与 history-list 同源):导出弹窗的工作区筛选按同一套组 id。
2797
+ const { groups } = await buildHistoryGroups(registry, items);
2798
+ return { ok: true, items, groups };
2352
2799
  }
2353
2800
  catch (e) {
2354
2801
  return { ok: false, error: message(e) };
@@ -2414,8 +2861,7 @@ export default {
2414
2861
  catch (e) {
2415
2862
  return { ok: false, error: message(e) };
2416
2863
  }
2417
- },
2418
- // 批量归档(导出弹窗「归档所选」):把选中的会话收进 History,纳入保留期管理。
2864
+ }, // 批量归档(导出弹窗「归档所选」):把选中的会话收进 History,纳入保留期管理。
2419
2865
  'history-archive-batch': async (args) => {
2420
2866
  const registry = getHistoryRegistry();
2421
2867
  if (!registry)
@@ -2437,6 +2883,79 @@ export default {
2437
2883
  }
2438
2884
  return { ok: true, archived, failed };
2439
2885
  },
2886
+ // 兼容体检:把"宿主到底支持哪些操作、哪些降级了、为什么"变成可读状态,
2887
+ // 而不是等用户点删除时才看见报错。只读,不改任何数据。
2888
+ 'compat-status': async (args) => {
2889
+ const force = Boolean(args && args.refresh);
2890
+ try {
2891
+ const registry = getHistoryRegistry();
2892
+ const assessment = registry && typeof registry.capabilities === 'function'
2893
+ ? registry.capabilities(force)
2894
+ : undefined;
2895
+ if (assessment === undefined) {
2896
+ return {
2897
+ ok: true,
2898
+ host: { version: 'unknown' },
2899
+ findings: [],
2900
+ degraded: [],
2901
+ blockers: ['归档服务未挂载:无法探测宿主能力'],
2902
+ summary: '宿主能力探测不可用(归档服务未挂载)',
2903
+ };
2904
+ }
2905
+ const summary = summarize(assessment);
2906
+ // 每次刷新都留一条结构化日志:升级当天就能在日志里看到降级发生。
2907
+ if (force || !compatLoggedFor || compatLoggedFor !== assessment.identity.version) {
2908
+ compatLoggedFor = assessment.identity.version;
2909
+ void pluginLog()('compat/probe', {
2910
+ hostVersion: assessment.identity.version,
2911
+ summary,
2912
+ degraded: assessment.degraded.map((item) => ({ id: item.id, state: item.state, detail: item.detail })),
2913
+ blockers: assessment.identity.blockers,
2914
+ sameAsHost: assessment.identity.sameAsHost,
2915
+ }).catch(() => { });
2916
+ }
2917
+ return {
2918
+ ok: true,
2919
+ host: { version: assessment.identity.version, modules: assessment.identity.modules },
2920
+ sameAsHost: assessment.identity.sameAsHost,
2921
+ findings: assessment.findings,
2922
+ degraded: assessment.degraded,
2923
+ blockers: assessment.identity.blockers,
2924
+ mayDelete: assessment.mayDelete,
2925
+ verifiedVersion: VERIFIED_HOST_VERSION,
2926
+ expectedPeerRange: EXPECTED_PEER_RANGE,
2927
+ generatedAt: assessment.generatedAt,
2928
+ summary,
2929
+ };
2930
+ }
2931
+ catch (e) {
2932
+ return { ok: false, error: message(e) };
2933
+ }
2934
+ },
2935
+ // 预设可达性矩阵:每个 Agent 预设下,本插件的注入类能力到不到得了模型。
2936
+ // 只读预设组合文本,不挂载任何预设、不改任何数据。回答的是"面板上说注入了,
2937
+ // 模型真的看得到吗" —— minimal 这类 persona complete 的预设会压制全部提示词段。
2938
+ 'preset-reach': async () => {
2939
+ try {
2940
+ // 宿主平面的 MCP 工具数:四个官方预设都不在组合里挂 MCP,MCP 由
2941
+ // $DSH_HOME/cordis.patch.yml 这一层挂载,所以它对所有预设一视同仁;
2942
+ // 这一列要回答的只是"现在有没有 MCP 工具可用"。探测失败 → 不传该字段,
2943
+ // 矩阵那一格显示"判断不了"而不是猜。
2944
+ let mcpTools;
2945
+ try {
2946
+ const schemas = await tools.schemas();
2947
+ mcpTools = schemas.filter((s) => String(s && s.name || '').startsWith('mcp__')).length;
2948
+ }
2949
+ catch {
2950
+ mcpTools = undefined;
2951
+ }
2952
+ const report = await assessPresetReach(presetRoster(), { mcpTools });
2953
+ return { ok: true, ...report };
2954
+ }
2955
+ catch (e) {
2956
+ return { ok: false, error: message(e) };
2957
+ }
2958
+ },
2440
2959
  'history-unarchive': async (args) => {
2441
2960
  const registry = getHistoryRegistry();
2442
2961
  if (!registry)
@@ -2446,7 +2965,8 @@ export default {
2446
2965
  return { ok: false, error: '缺少 sessionId' };
2447
2966
  try {
2448
2967
  const r = await registry.unarchiveSession(sessionId);
2449
- return { ok: true, archivedSessionIds: r.archivedSessionIds };
2968
+ const workspace = await restoreWorkspaceAccounting([sessionId]);
2969
+ return { ok: true, archivedSessionIds: r.archivedSessionIds, ...(workspace ? { workspace } : {}) };
2450
2970
  }
2451
2971
  catch (e) {
2452
2972
  return { ok: false, error: message(e) };
@@ -2478,7 +2998,8 @@ export default {
2478
2998
  return parsed;
2479
2999
  try {
2480
3000
  const r = await registry.unarchiveSessions(parsed.target);
2481
- return { ok: true, unarchivedSessionIds: r.unarchivedSessionIds, archivedSessionIds: r.archivedSessionIds };
3001
+ const workspace = await restoreWorkspaceAccounting(r.unarchivedSessionIds || []);
3002
+ return { ok: true, unarchivedSessionIds: r.unarchivedSessionIds, archivedSessionIds: r.archivedSessionIds, ...(workspace ? { workspace } : {}) };
2482
3003
  }
2483
3004
  catch (e) {
2484
3005
  return { ok: false, error: message(e) };
@@ -2499,6 +3020,28 @@ export default {
2499
3020
  return { ok: false, error: message(e) };
2500
3021
  }
2501
3022
  },
3023
+ // 重新登记工作区(写宿主状态):为「工作区已被删除、目录仍在」的分组补回一条登记。
3024
+ // 只在用户显式确认后调用;宿主 realpath 会拒绝不存在的目录,同路径已登记则幂等返回。
3025
+ // 不移动、不删除任何文件或会话,也不改写 sessionIds 记账。
3026
+ 'history-workspace-register': async (args) => {
3027
+ const registry = getHistoryRegistry();
3028
+ if (!registry)
3029
+ return { ok: false, error: '归档服务未挂载' };
3030
+ const path = String((args && args.path) || '').trim();
3031
+ if (!path)
3032
+ return { ok: false, error: '缺少工作区目录 path' };
3033
+ if (typeof registry.registerWorkspace !== 'function') {
3034
+ return { ok: false, error: '当前环境不支持重新登记工作区(registry 未实现 registerWorkspace)' };
3035
+ }
3036
+ const title = args && args.title ? String(args.title) : undefined;
3037
+ try {
3038
+ const workspace = await registry.registerWorkspace(path, title);
3039
+ return { ok: true, workspace };
3040
+ }
3041
+ catch (e) {
3042
+ return { ok: false, error: message(e) };
3043
+ }
3044
+ },
2502
3045
  // 从其他 Agent(Claude Code / Cursor JSONL、Codex Markdown、通用文本)导入对话,
2503
3046
  // 通过 sessions.create 的 seed 机制生成一个可继续对话的全新会话。
2504
3047
  'history-import': async (args) => {
@@ -2518,9 +3061,8 @@ export default {
2518
3061
  }
2519
3062
  if (!turns.length)
2520
3063
  return { ok: false, error: '未能从该文件中识别出对话内容' };
2521
- const sessions = ctx.get('sessions');
2522
- if (!sessions || typeof sessions.create !== 'function')
2523
- return { ok: false, error: '当前环境不支持创建会话(sessions.create 不可用)' };
3064
+ if (!agents || typeof agents.create !== 'function')
3065
+ return { ok: false, error: '当前环境不支持创建会话(agents.create 不可用)' };
2524
3066
  // 仅接受绝对路径的 cwd;非法或缺失时会话不带目录(归入未分组)。
2525
3067
  const cwdArg = String((args && args.cwd) || '').trim();
2526
3068
  const cwd = /^([A-Za-z]:[\\/]|\\\\|\/)/.test(cwdArg) ? cwdArg : undefined;
@@ -2536,8 +3078,19 @@ export default {
2536
3078
  surfaceOp: 'append',
2537
3079
  }));
2538
3080
  try {
2539
- const created = sessions.create(undefined, { seed, meta: { ...(cwd ? { cwd } : {}), createdAt: base } });
2540
- return { ok: true, sessionId: created.id, count: turns.length };
3081
+ // `sessions.create()` 只创建裸 Session;它不经过 DSH agent factory / workspace
3082
+ // / session-log,所以返回的内存 id 不会进入 UI 的会话列表。必须走官方的
3083
+ // `ctx.agents.create()`,它会在同一事务里创建 Agent、发布 Session,并触发宿主
3084
+ // 的持久化与 projection 链路。这里使用 UUID,避免 SessionStore 的裸自增 id 与
3085
+ // 宿主 API 会话 id 空间混用。
3086
+ const id = 'session-' + randomUUID();
3087
+ await agents.create({ sessionId: id, seed, ...(cwd ? { meta: { cwd } } : {}) });
3088
+ const sessions = ctx.get('sessions');
3089
+ const live = sessions && typeof sessions.get === 'function' ? sessions.get(id) : undefined;
3090
+ if (live === undefined || live === null) {
3091
+ return { ok: false, error: `创建会话失败:宿主未登记该会话(${id}),未导入任何内容` };
3092
+ }
3093
+ return { ok: true, sessionId: id, count: turns.length };
2541
3094
  }
2542
3095
  catch (e) {
2543
3096
  return { ok: false, error: '创建会话失败: ' + message(e) };
@@ -2624,17 +3177,20 @@ export default {
2624
3177
  },
2625
3178
  'history-retention-set': async (args) => {
2626
3179
  const days = Number((args && args.retentionDays) ?? -1);
2627
- if (!Number.isFinite(days) || days < 0)
2628
- return { ok: false, error: 'retentionDays 需为非负整数(0=永久不删除)' };
3180
+ // 错误文案承诺的是「非负整数」,就按整数校验:1.5 这类小数以前会被静默取整成 1,
3181
+ // 用户看到的回执与输入不符。
3182
+ if (!Number.isSafeInteger(days) || days < 0) {
3183
+ return { ok: false, error: 'retentionDays 需为非负整数(0=永久不删除),收到: ' + String((args && args.retentionDays)) };
3184
+ }
2629
3185
  try {
2630
- await writeHistoryRetention(Math.floor(days));
3186
+ await writeHistoryRetention(days);
2631
3187
  }
2632
3188
  catch (e) {
2633
3189
  return { ok: false, error: '写入保留期失败: ' + message(e) };
2634
3190
  }
2635
3191
  // 设置变更后立即扫一次,UI 反映新策略。
2636
3192
  await sweepHistory().catch(() => { });
2637
- return { ok: true, retentionDays: Math.floor(days) };
3193
+ return { ok: true, retentionDays: days };
2638
3194
  },
2639
3195
  };
2640
3196
  // ---------- agent-facing tools (standard ctx.tools.register + defineTool) ----------
@@ -2746,7 +3302,10 @@ export default {
2746
3302
  }));
2747
3303
  tools.register(defineTool({
2748
3304
  name: 'skill_manager_create',
2749
- description: 'Create a new local DSH skill under DSH_HOME/skills. Use only when the user explicitly asks to create or save a reusable skill.',
3305
+ // 落点必须和 UI「创建技能」一致:两者都走 core 的默认落点(hub 的
3306
+ // tool-management/skills/,hub 缺失时退回 DSH_HOME/skills)。以前这里硬编码
3307
+ // root:'dsh',于是同一个「新建技能」动作,人点界面和模型调用会落到两个不同的根。
3308
+ description: 'Create a new local DSH skill under the tool-management skills root (DSH_HOME/tool-management/skills). Use only when the user explicitly asks to create or save a reusable skill.',
2750
3309
  parameters: {
2751
3310
  name: { type: 'string', required: true, description: 'Skill name; normalized to kebab-case.' },
2752
3311
  description: { type: 'string', required: true, description: 'A concise routing description for when to use the skill.' },
@@ -2754,7 +3313,7 @@ export default {
2754
3313
  },
2755
3314
  output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
2756
3315
  async execute(args) {
2757
- const r = await skillsService.ops['skill-create']({ name: args.name, description: args.description, body: args.body, root: 'dsh' });
3316
+ const r = await skillsService.ops['skill-create']({ name: args.name, description: args.description, body: args.body });
2758
3317
  if (!r || r.ok === false)
2759
3318
  throw new Error((r && r.error) || 'skill create failed');
2760
3319
  const data = r.data || {};
@@ -2767,12 +3326,15 @@ export default {
2767
3326
  description: 'List AGENTS.md presets in the plugin preset library (id, active state).',
2768
3327
  parameters: {},
2769
3328
  output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
2770
- async execute() {
3329
+ async execute(_args, exec) {
2771
3330
  const r = await agentsMdService.list();
2772
3331
  if (!r.ok)
2773
3332
  throw new Error(r.error);
2774
3333
  const summary = r.presets.map((p) => p.id + (p.active ? ' [active]' : ''));
2775
- return 'AGENTS.md presets:\n' + (summary.join('\n') || '(none)') + '\n(Applying takes effect on the next session created; the current session is unchanged.)';
3334
+ // 同上:AGENTS.md 由 dsh-agent-instructions 行承载,预设没挂这一行(或 persona
3335
+ // 是 complete)时文件内容不会进提示词。
3336
+ const notice = await reachNoticeForAgent(presetRoster(), exec && exec.agent && exec.agent.ctx);
3337
+ return 'AGENTS.md presets:\n' + (summary.join('\n') || '(none)') + '\n(Applying takes effect on the next session created; the current session is unchanged.)' + notice;
2776
3338
  },
2777
3339
  }));
2778
3340
  tools.register(defineTool({
@@ -2800,15 +3362,18 @@ export default {
2800
3362
  group: { type: 'string', description: 'Optional scene filter.' },
2801
3363
  },
2802
3364
  output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
2803
- async execute(args) {
3365
+ async execute(args, exec) {
2804
3366
  const r = await rulesService.ops['rules-list'](args);
2805
3367
  if (!r || r.ok === false)
2806
3368
  throw new Error((r && r.error) || '读取规则失败');
2807
3369
  const lines = (r.rules || []).map((x) => ('- ' + x.id + ' [' + (x.group || '未归属场景') + '] ' + (x.enabled ? '已启用' : '已停用') +
2808
3370
  (x.description ? ' — ' + x.description : '')));
2809
3371
  const scenes = (r.scenes || []).map((s) => (s.label || s.name) + (s.active ? '(启用)' : '(未启用)')).join('、');
3372
+ // 注入边界:预设可能压制提示词段(complete persona,或未挂 agent-instructions),
3373
+ // 此时列出的记忆**不在**模型上下文里。必须说出来,否则模型会假设自己已经看到正文。
3374
+ const notice = await reachNoticeForAgent(presetRoster(), exec && exec.agent && exec.agent.ctx);
2810
3375
  return '记忆(' + (r.rules || []).length + '):\n' + (lines.join('\n') || '(无记忆)') +
2811
- '\n场景:' + (scenes || '(无)') + (r.activeMode === 'all' ? '(默认全部启用)' : '(已收窄)');
3376
+ '\n场景:' + (scenes || '(无)') + (r.activeMode === 'all' ? '(默认全部启用)' : '(已收窄)') + notice;
2812
3377
  },
2813
3378
  }));
2814
3379
  tools.register(defineTool({
@@ -2848,7 +3413,9 @@ export default {
2848
3413
  // 与其余 12 个工具同一条注册通道:defineTool 负责编译 parameters(object root + required),
2849
3414
  // 裸 register 会把未编译的参数声明直接发给模型 API。
2850
3415
  tools.register(defineTool(defineSubagentListTool({ list: () => subagentService.list(), sceneLists: subagentSceneLists })));
2851
- tools.register(defineTool(defineSubagentRunTool({ ...subagentService, sceneLists: subagentSceneLists })));
3416
+ // 工具限制按**当前会话的 Agent 预设**下发:父会话跑在哪个预设,就用那个预设那一行的
3417
+ // 白/黑名单(`decideToolFilter`),名单里已消失的工具名会被丢掉并在结果里如实说明。
3418
+ tools.register(defineTool(defineSubagentRunTool({ ...subagentService, sceneLists: subagentSceneLists, toolFilterFor: subagentToolFilterFor })));
2852
3419
  }
2853
3420
  catch (e) {
2854
3421
  console.error('[dsh-plugin-tool-management] subagent tool registration failed:', message(e));
@@ -2872,13 +3439,31 @@ export default {
2872
3439
  pluginLog()('confirm-bypass', `完全权限(approval=never):跳过${CONFIRM_LABELS[String(exec && exec.name)]}的确认,直接放行`).catch(() => { });
2873
3440
  return true;
2874
3441
  };
2875
- ctx.on('tools/pre-execute', (exec, next) => {
3442
+ // subagent_run 的目标人设是否真实存在。确认门是「问用户要不要做」,如果这个请求
3443
+ // 本来就做不成(人设不存在),弹卡 / 写 bypass 日志只会产生一次无效审批:
3444
+ // 用户批准之后模型收到的是「人设不可用」。所以先校验,再决定要不要问。
3445
+ // 校验本身不可用时返回 true(保持原行为,绝不因为探测失败而少问一次)。
3446
+ const subagentRunTargetExists = async (exec) => {
3447
+ try {
3448
+ const name = String((exec && exec.arguments && exec.arguments.agent) || '').trim();
3449
+ if (!name)
3450
+ return false;
3451
+ const list = await subagentService.list();
3452
+ return list.some((p) => p.name === name);
3453
+ }
3454
+ catch (e) {
3455
+ return true;
3456
+ }
3457
+ };
3458
+ ctx.on('tools/pre-execute', async (exec, next) => {
2876
3459
  if (!exec || !CONFIRM_LABELS[String(exec.name)])
2877
3460
  return next();
3461
+ if (exec.name === 'subagent_run' && !(await subagentRunTargetExists(exec)))
3462
+ return next();
2878
3463
  if (bypassedByFullAccess(exec))
2879
3464
  return next();
2880
3465
  if (exec.name === 'skill_manager_create') {
2881
- return Promise.resolve({ kind: 'ask', reason: 'Create a new skill under DSH_HOME/skills' });
3466
+ return Promise.resolve({ kind: 'ask', reason: 'Create a new skill under ~/.dsh/tool-management/skills' });
2882
3467
  }
2883
3468
  if (exec.name === 'rule_manager_write') {
2884
3469
  // D2:模型写规则默认需确认;设置关闭后直接放行。ask 无应答者时降级为拒绝(fail-closed),
@@ -2942,9 +3527,10 @@ export default {
2942
3527
  // ---------- HTTP API route (UI half), registered defensively ----------
2943
3528
  if (webServer) {
2944
3529
  // Cap request bodies (88 MiB): skill-upload carries Base64 folder/ZIP
2945
- // payloads (64 MiB of raw content). The route is only reachable from
2946
- // local/trusted origins (Host + CSRF header + optional token gate).
2947
- // `config.maxBodyBytes` overrides the cap (tests use a small value).
3530
+ // payloads (64 MiB of raw content). Reachability is decided by
3531
+ // fenceRejection() below — the host's Host/Origin + browser-session fence
3532
+ // (or a correct explicit token). `config.maxBodyBytes` overrides the cap
3533
+ // (tests use a small value).
2948
3534
  const configuredMaxBody = Number(config?.maxBodyBytes);
2949
3535
  const MAX_BODY = Number.isFinite(configuredMaxBody) && configuredMaxBody > 0 ? configuredMaxBody : 88 * 1024 * 1024;
2950
3536
  const readBody = (req) => new Promise((resolve, reject) => {
@@ -2965,6 +3551,20 @@ export default {
2965
3551
  req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
2966
3552
  req.on('error', reject);
2967
3553
  });
3554
+ // 宿主 BrowserAuth 栅栏。宿主的鉴权**只包在它自己注册的路由上**——dsh-host-webserver
3555
+ // 没有全局中间件,既定约定是每个注册方自己调 connection.requestRejection(req):
3556
+ // 它先做 Host/Origin 栅栏(Host 必须是回环或 deployment 派生的 LAN IP 字面量,
3557
+ // 这是 DNS rebinding 唯一伪造不了的头),再叠加 browser-session cookie 鉴权。
3558
+ // 本插件此前只查公开常量 x-dsh-plugin 与自比对(Origin.host === Host)的 "同源",
3559
+ // 两者都挡不住 rebind 后的网页或本机任意进程 —— 等于没鉴权。现在优先走宿主栅栏。
3560
+ const connection = (() => {
3561
+ try {
3562
+ return ctx.get('connection');
3563
+ }
3564
+ catch {
3565
+ return undefined;
3566
+ }
3567
+ })();
2968
3568
  try {
2969
3569
  // ctx.effect wires the route's disposer into this plugin's scope, so an
2970
3570
  // unload (HMR removal, disable, update) unregisters the route — the
@@ -2973,12 +3573,10 @@ export default {
2973
3573
  kind: 'exact',
2974
3574
  path: '/dsh-plugin-tool-management/api',
2975
3575
  handler: async (req, res) => {
2976
- // Cross-site (CSRF) gate. This route mutates config files, so it
2977
- // must only be reachable from the DSH web UI (same origin) or
2978
- // local tooling. A browser cross-site request cannot attach a
2979
- // custom header without a CORS preflight, and this route never
2980
- // answers preflights — requiring `x-dsh-plugin` is the primary
2981
- // gate; POST-only and the Origin check are defense in depth.
3576
+ // 鉴权顺序:POST-only → 插件门禁头(防误触,不是凭证)→ 宿主栅栏
3577
+ // (Host/Origin + browser-session cookie)→ 可选访问令牌。令牌是本地工具与
3578
+ // LAN 部署的逃生门:**令牌正确即视为已授权**,可越过浏览器鉴权;
3579
+ // 未配置令牌时不再有"谁都放行"的缺口。
2982
3580
  const hdr = (name) => {
2983
3581
  const v = req.headers?.[name];
2984
3582
  return Array.isArray(v) ? v[0] ?? '' : v ?? '';
@@ -2993,19 +3591,12 @@ export default {
2993
3591
  res.end(JSON.stringify({ ok: false, error: 'missing plugin gate header' }));
2994
3592
  return;
2995
3593
  }
2996
- const origin = hdr('origin');
2997
- if (origin) {
2998
- let sameOrigin = false;
2999
- try {
3000
- const u = new URL(origin);
3001
- const hostHdr = hdr('host') || '';
3002
- sameOrigin =
3003
- /^(localhost|127\.0\.0\.1|\[::1\])$/i.test(u.hostname) || u.host === hostHdr;
3004
- }
3005
- catch (e) { /* unparseable origin → rejected below */ }
3006
- if (!sameOrigin) {
3007
- res.writeHead(403, { 'content-type': 'application/json' });
3008
- res.end(JSON.stringify({ ok: false, error: 'cross-origin request rejected' }));
3594
+ const tokenAccepted = TOKEN !== '' && hdr('x-dsh-token') === TOKEN;
3595
+ if (!tokenAccepted) {
3596
+ const fence = fenceRejection(req, connection);
3597
+ if (fence) {
3598
+ res.writeHead(fence.status, { 'content-type': 'application/json' });
3599
+ res.end(JSON.stringify({ ok: false, error: fence.error }));
3009
3600
  return;
3010
3601
  }
3011
3602
  }
@@ -3023,6 +3614,17 @@ export default {
3023
3614
  /* otherwise fall through with {} */
3024
3615
  }
3025
3616
  const op = String(payload.op || '');
3617
+ // 敏感 op(会吐明文密钥与完整配置):**必须**带对的访问令牌,没配令牌
3618
+ // 就没有明文(判定与理由见 http-fence.ts 的 secretOpRejection)。
3619
+ // 之前这里只查"有没有 Origin 头",而 token 为空时它形同虚设 —— 任何能
3620
+ // 打开 GUI 的浏览器都拿得到全部密钥原文。
3621
+ if (SENSITIVE_OPS.has(op)) {
3622
+ const gate = secretOpRejection({ tokenConfigured: TOKEN !== '', tokenAccepted });
3623
+ if (gate) {
3624
+ res.end(JSON.stringify({ ok: false, error: gate.error, code: gate.code }));
3625
+ return;
3626
+ }
3627
+ }
3026
3628
  if (TOKEN && WRITE_OPS.has(op) && hdr('x-dsh-token') !== TOKEN) {
3027
3629
  res.end(JSON.stringify({ ok: false, error: '缺少或错误的访问令牌(x-dsh-token)' }));
3028
3630
  return;