dsh-plugin-tool-management 0.5.0 → 0.6.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 (43) hide show
  1. package/README.md +300 -236
  2. package/README_EN.md +300 -290
  3. package/cordis.patch.yml +9 -55
  4. package/docs/Changelog.md +108 -435
  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 +54 -0
  22. package/lib/agents-md/preset-id.js +49 -0
  23. package/lib/agents-md/service.js +138 -56
  24. package/lib/client.js +4142 -3489
  25. package/lib/compat/probe.js +665 -0
  26. package/lib/history/bridge.js +293 -0
  27. package/lib/history/workspace.js +498 -52
  28. package/lib/http-fence.js +73 -0
  29. package/lib/index.js +511 -126
  30. package/lib/rules/provider.js +3 -3
  31. package/lib/rules/service.js +264 -30
  32. package/lib/scene-prompt-sync.js +112 -0
  33. package/lib/skills/core.js +112 -35
  34. package/lib/skills/service.js +6 -1
  35. package/package.json +5 -3
  36. package/screenshots.json +8 -7
  37. package/docs/images/MCP.png +0 -0
  38. package/docs/images//344/274/232/350/257/235.png +0 -0
  39. package/docs/images//345/234/272/346/231/257.png +0 -0
  40. package/docs/images//345/255/220/346/231/272/350/203/275/344/275/223.png +0 -0
  41. package/docs/images//346/212/200/350/203/275.png +0 -0
  42. package/docs/images//346/217/220/347/244/272/350/257/215.png +0 -0
  43. package/docs/images//350/256/260/345/277/206.png +0 -0
package/lib/index.js CHANGED
@@ -12,13 +12,18 @@
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
21
  import { createSubagentService } 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 { fenceRejection } from './http-fence.js';
25
+ import { hubPath, hubRoot, relocateEntries } from './hub.js';
26
+ import { createScenePromptSync } from './scene-prompt-sync.js';
22
27
  import { defineSubagentListTool, defineSubagentRunTool } from './subagents/tools.js';
23
28
  import { detectFormat, extractText, parseGenericText, parseJsonlTranscript, parseMarkdownTranscript } from './imports/parsers.js';
24
29
  import { homedir } from 'node:os';
@@ -58,13 +63,14 @@ function serializeTurns(turns, format) {
58
63
  }
59
64
  export default {
60
65
  name: 'dsh-plugin-tool-management-host',
61
- inject: ['timer', 'fs', 'settings', 'sandboxPolicy', 'webServer', 'tools', 'skills', 'sessions'],
66
+ inject: ['timer', 'fs', 'settings', 'sandboxPolicy', 'webServer', 'tools', 'skills', 'sessions', 'agents', 'workspaceRegistry', 'sessionProjectionCache', 'sessionPersistence'],
62
67
  apply(ctx, config) {
63
68
  const fs = ctx.fs;
64
69
  const settings = ctx.settings;
65
70
  const sandboxPolicy = ctx.sandboxPolicy;
66
71
  const webServer = ctx.webServer;
67
72
  const tools = ctx.tools;
73
+ const agents = ctx.get('agents');
68
74
  // pluginInventory is optional: probe at use time, degrade to no live info.
69
75
  const pluginInventory = ctx.get('pluginInventory');
70
76
  // Package version, surfaced in the Settings pages and the HTTP API. Read
@@ -74,12 +80,17 @@ export default {
74
80
  PKG_VERSION = createRequire(import.meta.url)('../package.json').version || 'unknown';
75
81
  }
76
82
  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.
83
+ // Optional access token. Enabled by setting `config.token` on this plugin's
84
+ // loader row (profile cordis.patch.yml override) or the
85
+ // DSH_PLUGIN_TOOL_MANAGEMENT_TOKEN env var. Two roles now:
86
+ // 1. escape hatch — a correct token is accepted *in place of* the host's
87
+ // browser authentication, so curl/scripts and LAN tooling keep working;
88
+ // 2. defense in depth — when set, every state-changing op additionally
89
+ // requires `x-dsh-token: <token>`.
90
+ // It is NOT the primary gate anymore: the route calls the host's
91
+ // connection.requestRejection fence first (Host must be loopback/LAN
92
+ // IP-literal → defeats DNS rebinding, plus browser-session cookie auth),
93
+ // so an unset token no longer means "anyone may call writes".
83
94
  // NOTE: the entry config arrives as the SECOND apply argument (Cordis
84
95
  // calls `callback(ctx, config)`) — never read it off `ctx.config`, which
85
96
  // is not an injected service and throws "cannot get property without
@@ -204,17 +215,44 @@ export default {
204
215
  }
205
216
  return states;
206
217
  }
207
- async function skillStates() {
218
+ /**
219
+ * 一次扫描同时给出三样东西(避免为了三份视图各扫一遍磁盘):
220
+ * - `states`:**真实生效**的技能 `<rootKey>/<name>` → boolean;
221
+ * - `shadowed`:被同名技能覆盖的键集合(`sk.shadowedBy` 存在);
222
+ * - `all`:全部键 → boolean(被覆盖的副本记 `false`,但键保留)。
223
+ *
224
+ * 为什么要区分:`sk.enabled` 只有**同名竞争的胜者**才有值,被覆盖的副本恒为 `undefined`。
225
+ * 旧实现用 `sk.enabled !== false` 取值,把副本误判成「已启用」,于是场景模式退出回放快照时
226
+ * 会凭空往 `enabledSkills` 写条目;反过来把副本当成「已停用」又会在进入模式时把用户
227
+ * 显式启用的记录改写成显式停用。两种都会在「同名竞争消失 / 来源重新打开」后改变实际行为。
228
+ */
229
+ async function skillScan() {
208
230
  const r = await skillsService.ops['skill-state']({});
209
231
  const states = {};
232
+ const all = {};
233
+ const shadowed = new Set();
210
234
  for (const root of ((r && r.data && r.data.roots) || [])) {
211
235
  for (const sk of (root.skills || [])) {
212
236
  const name = String(sk.declaredName || sk.name || '');
213
- if (name)
214
- states[String(root.key || '') + '/' + name] = sk.enabled !== false;
237
+ if (!name)
238
+ continue;
239
+ const key = String(root.key || '') + '/' + name;
240
+ if (sk.shadowedBy) {
241
+ shadowed.add(key);
242
+ all[key] = false;
243
+ continue;
244
+ }
245
+ const on = sk.enabled === true;
246
+ states[key] = on;
247
+ all[key] = on;
215
248
  }
216
249
  }
217
- return states;
250
+ return { states, shadowed, all };
251
+ }
252
+ async function skillStates() {
253
+ // knownSkillKeys 需要**全量键**(含被覆盖的副本):场景档案里允许勾选任意一条技能,
254
+ // 用生效集去校验会把合法勾选判成 stale 丢掉。
255
+ return (await skillScan()).all;
218
256
  }
219
257
  const archiveService = createArchiveEngine({
220
258
  loadSlice: async () => ({ ...(await rulesService.readArchiveSlice()) }),
@@ -259,12 +297,24 @@ export default {
259
297
  scheduleToolRestrictions();
260
298
  },
261
299
  knownSkillKeys: async () => new Set(Object.keys(await skillStates())),
262
- currentSkills: skillStates,
300
+ // 快照只取**真实生效**的条目(不含被同名覆盖的副本)——副本不参与模式应用,
301
+ // 退出回放时也不该去动它们,否则会改写用户对副本的显式策略记录。
302
+ currentSkills: async () => (await skillScan()).states,
263
303
  applySkills: async (target) => {
304
+ // 两道过滤,缺一不可:
305
+ // 1. 被同名覆盖的副本直接跳过——它们本来就不生效,写策略只会污染用户的记录。
306
+ // 2. **已经处于目标状态的技能不再写**。`applySkills` 写的是显式策略记录,而目标
307
+ // 状态是生效状态,两者不等价:对已经生效为「停用」的技能再 disable 一次,
308
+ // 只是把「没有记录」固化成「显式停用」,场景模式进出一次就会改写 state.json。
309
+ const { states, shadowed } = await skillScan();
264
310
  for (const [key, on] of Object.entries(target)) {
265
311
  const i = key.indexOf('/');
266
312
  if (i <= 0)
267
313
  throw new Error(`技能 key 不合法: ${key}`);
314
+ if (shadowed.has(key))
315
+ continue;
316
+ if (states[key] === on)
317
+ continue;
268
318
  const root = key.slice(0, i);
269
319
  const name = key.slice(i + 1);
270
320
  const r = await skillsService.ops[on ? 'skill-enable' : 'skill-disable']({ root, name });
@@ -315,29 +365,113 @@ export default {
315
365
  'skill-open',
316
366
  // agents-md 写操作(create/update/remove 改预设库;apply 写全局 AGENTS.md;import 从外部内容建预设)
317
367
  'agentsmd-create', 'agentsmd-update', 'agentsmd-apply', 'agentsmd-remove', 'agentsmd-import',
318
- // history 写操作(archive/unarchive 改归档集合;delete 永久删除;retention-set 写保留期)
368
+ // history 写操作(archive/unarchive 改归档集合;delete 永久删除;retention-set 写保留期;
369
+ // workspace-register 会新增一条宿主工作区登记,同样是写)
319
370
  'history-archive', 'history-unarchive', 'history-delete', 'history-retention-set',
320
371
  'history-unarchive-batch', 'history-delete-batch', 'history-import', 'history-export',
321
- 'history-archive-batch',
372
+ 'history-archive-batch', 'history-workspace-register',
322
373
  ]);
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'));
374
+ // 会泄露明文凭据 / 完整配置的 op:额外要求请求带同源 Origin 头(见路由内的注释)。
375
+ const SENSITIVE_OPS = new Set(['mcpm-reveal', 'mcpm-export']);
376
+ // 兼容体检的日志去重:同一宿主版本只写一条 compat/probe,避免轮询刷屏。
377
+ let compatLoggedFor;
378
+ // ---------- independent history facade ----------
379
+ // Keep the active workspaceRegistry/sessionProjectionCache instances intact.
380
+ // Native archive extensions are delegated by capability, not package name.
381
+ // Official gaps use a checked adapter (history/bridge.js); no service or
382
+ // storage-domain replacement. Retention uses the existing sidecar ledger.
383
+ // 历史侧车三件套(归档时刻账本 / 保留期设置 / 工作区登记快照)统一放 hub 根
384
+ // (`$DSH_HOME/tool-management/`),**不放插件目录**:npm 安装下插件目录会被
385
+ // `dsh plugin update` 整体替换,放那里等于"升级即丢账本"——账本一丢,保留期基线
386
+ // 就从 archivedAt 退回 createdAt,归档会话会被提前清掉(v0.4 把 agents-md 预设
387
+ // 搬进 hub 是同一条理由,见上方注释;这三个是当时漏掉的)。
388
+ // 旧位置(插件目录 data/)在启动时一次性搬入:只搬不删、绝不覆盖、失败下次再试。
389
+ const historyArchivedAtFile = String(config?.archivedAtFile || hubPath('history-archived-at.json'));
390
+ const historyWorkspaceSnapshotFile = String(config?.workspaceSnapshotFile || hubPath('history-workspaces.json'));
391
+ const historyRetentionPath = String(config?.historyRetentionPath || hubPath('history-retention.json'));
392
+ const historySidecarFiles = new Set(['history-archived-at.json', 'history-retention.json', 'history-workspaces.json']);
393
+ const historyStandby = relocateEntries(join(PLUGIN_ROOT, 'data'), hubRoot(), (name) => historySidecarFiles.has(name))
394
+ .catch(() => 0);
334
395
  const sweepIntervalMs = Number(config?.sweepIntervalMs || 0) || 6 * 60 * 60 * 1000;
396
+ let history;
397
+ ctx.effect(() => {
398
+ const registry = ctx.get('workspaceRegistry');
399
+ if (registry && typeof registry.archiveSession === 'function') {
400
+ history = new HistoryService(ctx, registry, {
401
+ archivedAtFile: historyArchivedAtFile,
402
+ workspaceSnapshotFile: historyWorkspaceSnapshotFile,
403
+ ready: historyStandby,
404
+ });
405
+ }
406
+ return async () => { const current = history; history = undefined; await current?.dispose(); };
407
+ }, 'dsh-plugin-tool-management: independent history');
335
408
  function getHistoryRegistry() {
336
- const r = ctx.get('workspaceRegistry');
337
- return r && typeof r.archiveSession === 'function' ? r : undefined;
409
+ return history;
410
+ }
411
+ // ── 场景提示词 → 全局基线(AGENTS.md)同步 ────────────────────────────────
412
+ //
413
+ // 用户裁定(2026-09-15):「切换场景,对应的提示词直接把 AGENTS.md 直接修改」。
414
+ // 实现收在 ./scene-prompt-sync.ts(可在临时目录上端到端验证):启用/切换场景、
415
+ // 关掉场景、改绑定、编辑"驱动基线的那份预设"四个动作都会同步;关掉场景时按
416
+ // 进场景前的基线快照(`scene-baseline.json`,hub 内)原文写回。
417
+ const scenePromptSync = createScenePromptSync({
418
+ agentsMd: agentsMdService,
419
+ rules: rulesService,
420
+ baselineFile: hubPath('scene-baseline.json'),
421
+ logger: ctx.logger,
422
+ });
423
+ /**
424
+ * 把同步结果并进 op 响应:只在原操作成功时同步,失败原样透传。
425
+ * 同步失败**不改**原操作的成功结论(场景确实切了),而是把原因放进 `agentsMd.error`,
426
+ * 由界面如实显示成警告 —— 绝不假装文件已经改好。
427
+ */
428
+ const withAgentsMdSync = (res) => scenePromptSync.withSync(res);
429
+ /**
430
+ * 恢复归档后的**归属恢复**(best-effort,绝不阻断恢复本身):按会话 cwd 找到或重建
431
+ * 工作区登记,并把会话挂回该登记的 `sessionIds`。恢复成功但归属恢复失败时返回 undefined,
432
+ * 由客户端按"会话已恢复、工作区归属未恢复"如实呈现。
433
+ */
434
+ async function restoreWorkspaceAccounting(sessionIds) {
435
+ const registry = getHistoryRegistry();
436
+ if (!registry || typeof registry.ensureWorkspaceAccounting !== 'function' || !sessionIds.length)
437
+ return undefined;
438
+ try {
439
+ return await registry.ensureWorkspaceAccounting(sessionIds);
440
+ }
441
+ catch (e) {
442
+ ctx.logger?.warn?.(`history: workspace accounting after restore failed: ${message(e)}`);
443
+ return undefined;
444
+ }
445
+ }
446
+ /**
447
+ * **当前生效的提示词预设 id**(界面「生效中」的那一份),供写操作做门禁:
448
+ * ① 启用的场景绑定了它 → 就是它(场景优先级最高,与 agentsmd-list 的标记同一口径);
449
+ * ② 否则回退到文件比对(`~/.dsh/AGENTS.md` 内容 == 某份预设)。
450
+ * 探测失败返回 `null`(调用方决定放行还是拒绝;当前只在删除上使用,放行 + warn)。
451
+ */
452
+ async function effectivePresetId() {
453
+ try {
454
+ const r = await rulesService.ops['rules-list']({});
455
+ const sp = r && r.ok ? r.scenePrompt : null;
456
+ if (sp && sp.scene && !sp.missing && sp.presetId)
457
+ return String(sp.presetId);
458
+ }
459
+ catch (e) {
460
+ ctx.logger?.warn?.(`agents-md: scene prompt probe failed: ${message(e)}`);
461
+ }
462
+ try {
463
+ const cur = await agentsMdService.getCurrent();
464
+ if (cur && cur.ok && cur.presetId)
465
+ return String(cur.presetId);
466
+ }
467
+ catch (e) {
468
+ ctx.logger?.warn?.(`agents-md: global baseline probe failed: ${message(e)}`);
469
+ }
470
+ return null;
338
471
  }
339
472
  async function readHistoryRetention() {
340
473
  try {
474
+ await historyStandby;
341
475
  const raw = await readFile(historyRetentionPath, 'utf8');
342
476
  const obj = JSON.parse(raw);
343
477
  const days = Number((obj && obj.retentionDays) ?? 0);
@@ -352,6 +486,7 @@ export default {
352
486
  }
353
487
  }
354
488
  async function writeHistoryRetention(retentionDays) {
489
+ await historyStandby;
355
490
  const dir = dirname(historyRetentionPath);
356
491
  await mkdir(dir, { recursive: true });
357
492
  // updatedAt = 修改时刻:每次改保留期,已归档会话的到期基线重置为此时刻。
@@ -1669,7 +1804,13 @@ export default {
1669
1804
  }
1670
1805
  else {
1671
1806
  c = removeMarked(c, id, 'enable');
1672
- c = appendBlock(c, buildDisableBlock(id, true));
1807
+ // 幂等:已经生效为「停用」时不再追加 disable 块。无条件 append 会让每次点
1808
+ // 「停用」都往 patch 里塞一条重复条目(历史上 18 条互相矛盾的条目就是这么来的,
1809
+ // 最终生效值只能靠 last-wins 合并顺序猜),且文件会随每次启停线性膨胀。
1810
+ const { rows } = parseRows(c);
1811
+ const row = rows.find((r) => r.id === id);
1812
+ if (!row || !row.disabled)
1813
+ c = appendBlock(c, buildDisableBlock(id, true));
1673
1814
  }
1674
1815
  await writePatch(abs, c);
1675
1816
  return { ok: true };
@@ -1979,6 +2120,109 @@ export default {
1979
2120
  },
1980
2121
  };
1981
2122
  }
2123
+ /**
2124
+ * 归档会话的归属解析(三级,宿主侧算好再给客户端):
2125
+ * 1. 活登记的 sessionIds 命中 → 组 = 该登记;
2126
+ * 2. 未命中但规范路径命中某活登记 → 组 = 该登记(会话不在其记账里,属正常);
2127
+ * 3. 没有任何活登记 → 按会话目录重建分组(`path:<归一化路径>`),
2128
+ * 目录仍在则可一键重新登记(写回宿主),目录已不在则退到 header 里的原始 cwd。
2129
+ * 宿主删除工作区是硬删、不留墓碑,登记一没,`workspaceId` 就再也查不到;但会话目录
2130
+ * (`registry.sessionPaths` / header.cwd)还在,所以归属可以按目录重建、并在目录回来时
2131
+ * 自动并回同一组。除插件自己 `data/` 下的「见过的登记」快照外,本函数不写任何宿主状态。
2132
+ * 任何一步失败都降级为「不分组」,绝不阻断列表。
2133
+ */
2134
+ async function buildHistoryGroups(registry, items) {
2135
+ const groups = [];
2136
+ const workspaces = {};
2137
+ try {
2138
+ const groupsById = new Map();
2139
+ const liveByPath = new Map();
2140
+ const owned = new Map();
2141
+ const liveRecords = [];
2142
+ const table = typeof registry.requireTable === 'function' ? registry.requireTable() : undefined;
2143
+ const state = typeof registry.requireState === 'function' ? registry.requireState() : undefined;
2144
+ if (table) {
2145
+ const ids = state && Array.isArray(state.workspaceIds) && state.workspaceIds.length
2146
+ ? state.workspaceIds
2147
+ : [...table.entries()].map(([id]) => id);
2148
+ for (const wid of ids) {
2149
+ const rec = table.get(wid);
2150
+ if (!rec || !Array.isArray(rec.sessionIds))
2151
+ continue;
2152
+ const path = typeof rec.path === 'string' && rec.path ? rec.path : undefined;
2153
+ const title = (rec.title && String(rec.title)) || (path ? workspaceBaseName(path) : String(wid));
2154
+ for (const sid of rec.sessionIds)
2155
+ if (!owned.has(sid))
2156
+ owned.set(sid, wid);
2157
+ if (path) {
2158
+ liveByPath.set(workspacePathKey(path), wid);
2159
+ liveRecords.push({ id: String(wid), path, title });
2160
+ }
2161
+ const view = { id: 'ws:' + wid, title, kind: 'live', registered: true, ...(path ? { path } : {}) };
2162
+ groupsById.set(view.id, view);
2163
+ groups.push(view);
2164
+ workspaces[wid] = { title, ...(path ? { path } : {}) };
2165
+ }
2166
+ }
2167
+ // 「见过的登记」快照:登记被删后仍能给出原命名,并区分「已移除」与「从未登记」。
2168
+ const snapshot = typeof registry.workspaceSnapshot === 'function' ? await registry.workspaceSnapshot() : undefined;
2169
+ if (liveRecords.length && typeof registry.rememberWorkspaces === 'function') {
2170
+ try {
2171
+ await registry.rememberWorkspaces(liveRecords);
2172
+ }
2173
+ catch { /* 快照写失败不影响分组 */ }
2174
+ }
2175
+ const sessionPaths = registry.sessionPaths instanceof Map ? registry.sessionPaths : undefined;
2176
+ const headers = registry.headers instanceof Map ? registry.headers : undefined;
2177
+ for (const it of items) {
2178
+ const sid = it.sessionId;
2179
+ let wid = owned.get(sid);
2180
+ const canonical = sessionPaths ? sessionPaths.get(sid) : undefined;
2181
+ if (wid === undefined && typeof canonical === 'string' && canonical) {
2182
+ const byPath = liveByPath.get(workspacePathKey(canonical));
2183
+ if (byPath !== undefined)
2184
+ wid = byPath;
2185
+ }
2186
+ if (wid !== undefined) {
2187
+ it.workspaceId = wid;
2188
+ it.groupId = 'ws:' + wid;
2189
+ continue;
2190
+ }
2191
+ let path = typeof canonical === 'string' && canonical ? canonical : undefined;
2192
+ if (path === undefined && it.cwd)
2193
+ path = it.cwd;
2194
+ if (path === undefined && headers) {
2195
+ const cwd = headers.get(sid)?.cwd;
2196
+ if (typeof cwd === 'string' && cwd)
2197
+ path = cwd;
2198
+ }
2199
+ if (!path)
2200
+ continue;
2201
+ const key = 'path:' + workspacePathKey(path);
2202
+ it.groupId = key;
2203
+ const known = snapshot ? snapshot.get(workspacePathKey(path)) : undefined;
2204
+ const existing = groupsById.get(key);
2205
+ if (existing) {
2206
+ // 同目录的多个会话:目录存在(sessionPaths 命中)即可重新登记。
2207
+ if (canonical !== undefined)
2208
+ existing.canRegister = true;
2209
+ continue;
2210
+ }
2211
+ const view = {
2212
+ id: key,
2213
+ title: known?.title || workspaceBaseName(path) || path,
2214
+ kind: 'detached',
2215
+ registered: known !== undefined,
2216
+ canRegister: canonical !== undefined,
2217
+ path: known?.path || path,
2218
+ };
2219
+ groupsById.set(key, view);
2220
+ groups.push(view);
2221
+ }
2222
+ }
2223
+ catch { /* 分组是增强:任何异常都退回扁平列表 */ }
2224
+ return { groups, workspaces };
2225
+ }
1982
2226
  /**
1983
2227
  * 记忆候选(档案编辑器第 4 段「记忆」):`[{ id, scene, name, description }]`。
1984
2228
  * 只读、不读正文(勾选集只存 id);任何异常降级为空列表,不让档案弹窗崩掉。
@@ -2171,6 +2415,15 @@ export default {
2171
2415
  // rules-remove-scene。
2172
2416
  // 成功返回扁平 {ok:true, ...}(不套 data),失败 {ok:false, error, code?}。
2173
2417
  ...rulesService.ops,
2418
+ // 场景 ↔ 全局基线(AGENTS.md)同步:用户裁定「切换场景,对应的提示词直接把 AGENTS.md
2419
+ // 直接修改」,所以这四个 rules 写 op 走包装 —— 先执行原逻辑,成功后把当前场景绑定的
2420
+ // 提示词写进 `~/.dsh/AGENTS.md`(或关掉场景时恢复进场景前的基线),结果并进响应
2421
+ // 的 `agentsMd` 字段(`applied` / `restored` / `unchanged` / `error`)。
2422
+ // 放在 spread 之后,显式覆盖同名 op。
2423
+ 'rules-set-active': async (args) => withAgentsMdSync(await rulesService.ops['rules-set-active'](args)),
2424
+ 'rules-update-scene': async (args) => withAgentsMdSync(await rulesService.ops['rules-update-scene'](args)),
2425
+ 'rules-create-scene': async (args) => withAgentsMdSync(await rulesService.ops['rules-create-scene'](args)),
2426
+ 'rules-remove-scene': async (args) => withAgentsMdSync(await rulesService.ops['rules-remove-scene'](args)),
2174
2427
  // 场景档案 ops(由 ./rules/archive-engine.ts 提供):scene-mode-get /
2175
2428
  // scene-archive-save / scene-mode-set。成功返回扁平 {ok:true, ...},
2176
2429
  // 失败 {ok:false, error};写 op 已含 archiveService.writeOps 门禁派生。
@@ -2220,13 +2473,72 @@ export default {
2220
2473
  // AGENTS.md 预设库 ops(由 ./agents-md/service.js 提供):agentsmd-list /
2221
2474
  // agentsmd-read / agentsmd-create / agentsmd-update / agentsmd-remove /
2222
2475
  // agentsmd-apply / agentsmd-get-current
2223
- 'agentsmd-list': () => agentsMdService.list(),
2476
+ // 提示词预设库(由 ./agents-md/service.ts 提供)。这里把两件事对齐成用户看到的一份状态:
2477
+ // - `active`(界面「生效中」)= **当前真正在起作用的基线**:启用的场景绑了预设就是它,
2478
+ // 否则才看文件比对(预设正文 == ~/.dsh/AGENTS.md)。用户裁定:「场景启动了,
2479
+ // 提示词页生效中的应该是场景选择的那个」。
2480
+ // - `fileApplied` = 文件比对结果(AGENTS.md 里确实是它)。两者同时为真说明
2481
+ // 场景绑的就是已应用的那份,正文一致时宿主不会再注入第二遍(去重)。
2482
+ 'agentsmd-list': async () => {
2483
+ const base = await agentsMdService.list();
2484
+ if (!base || base.ok === false)
2485
+ return base;
2486
+ let scenePrompt;
2487
+ try {
2488
+ const r = await rulesService.ops['rules-list']({});
2489
+ if (r && r.ok && r.scenePrompt)
2490
+ scenePrompt = r.scenePrompt;
2491
+ }
2492
+ catch { /* 场景服务不可用 → 只回预设库 */ }
2493
+ const sceneId = scenePrompt && !scenePrompt.missing && scenePrompt.scene ? scenePrompt.presetId : null;
2494
+ // 场景驱动时**只有场景绑的那份**算生效中(用户裁定);文件里那份降级为「文件里是它」,
2495
+ // 因为场景的提示词已经接管基线。没有场景驱动时才按文件比对定「生效中」。
2496
+ const sceneDrives = sceneId !== null;
2497
+ const presets = base.presets.map((p) => {
2498
+ const fileApplied = p.active === true;
2499
+ const byScene = sceneDrives && sceneId === p.id;
2500
+ const active = sceneDrives ? byScene : fileApplied;
2501
+ return { ...p, active, fileApplied, activeVia: byScene ? 'scene' : (active && fileApplied ? 'file' : null) };
2502
+ });
2503
+ return { ...base, presets, ...(scenePrompt ? { scenePrompt } : {}) };
2504
+ },
2224
2505
  '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) ?? '')),
2506
+ // content 与 from 都可选:新建弹窗里直接写正文(content),或从现有预设复制(from)。
2507
+ 'agentsmd-create': (args) => agentsMdService.create(String((args && args.id) || ''), {
2508
+ ...(args && args.from ? { from: String(args.from) } : {}),
2509
+ ...(args && typeof args.content === 'string' ? { content: String(args.content) } : {}),
2510
+ }),
2511
+ // 保存:改正文 + 可选改名(nextId)。改名成功顺带把场景绑定一起改名 ——
2512
+ // 绑定存在 rules-index.json 里,预设库自己看不到,不叫这一声就会留悬空绑定。
2513
+ // 保存后同步一次全局基线:若改的正是「正在驱动基线的那份」,正文要跟着写进
2514
+ // `~/.dsh/AGENTS.md`(否则页面标着「生效中」而文件里还是旧内容)。
2515
+ 'agentsmd-update': async (args) => {
2516
+ const res = await agentsMdService.update(String((args && args.id) || ''), String((args && args.content) ?? ''), args && args.nextId !== undefined ? String(args.nextId) : undefined);
2517
+ if (!res || res.ok === false)
2518
+ return res;
2519
+ let renamed = {};
2520
+ if (res.renamedFrom) {
2521
+ try {
2522
+ const r = await rulesService.ops['rules-rebind-prompt']({ from: res.renamedFrom, to: res.id });
2523
+ renamed = r && r.ok ? { reboundScenes: r.changed } : {};
2524
+ }
2525
+ catch { /* 改名同步失败不影响保存本身 */ }
2526
+ }
2527
+ return withAgentsMdSync({ ...res, ...renamed });
2528
+ },
2227
2529
  'agentsmd-apply': (args) => agentsMdService.apply(String((args && args.id) || '')),
2228
2530
  'agentsmd-get-current': () => agentsMdService.getCurrent(),
2229
- 'agentsmd-remove': (args) => agentsMdService.remove(String((args && args.id) || '')),
2531
+ // 删除:**正在生效的那份拒绝删除**(用户裁定)。生效 = 启用的场景绑定了它,
2532
+ // 否则 = 文件内容就是它。禁用的按钮只是提示,真正的门在这里(模型与旧页面也会走这条路)。
2533
+ // 探测失败时放行并记一条 warn:删预设不动 ~/.dsh/AGENTS.md,最坏是少一份副本,可重建。
2534
+ 'agentsmd-remove': async (args) => {
2535
+ const id = String((args && args.id) || '');
2536
+ const effective = await effectivePresetId();
2537
+ if (effective !== null && effective === id) {
2538
+ return { ok: false, error: `「${id}」正在生效,不能删除:先启用别的场景或换绑提示词,或先把别的预设应用上去。` };
2539
+ }
2540
+ return agentsMdService.remove(id);
2541
+ },
2230
2542
  'agentsmd-import': (args) => agentsMdService.importPreset(String((args && args.id) || ''), String((args && args.content) ?? '')),
2231
2543
  // History(归档会话管理)ops。只读:history-list / history-retention-get;
2232
2544
  // 写:history-archive / history-unarchive / history-delete / history-retention-set。
@@ -2234,45 +2546,16 @@ export default {
2234
2546
  'history-list': async () => {
2235
2547
  const registry = getHistoryRegistry();
2236
2548
  if (!registry)
2237
- return { ok: false, error: '归档服务未挂载:workspace 已被替换?请确认本插件补丁已生效。' };
2549
+ return { ok: false, error: '归档服务未就绪:请检查宿主 workspaceRegistry 服务。' };
2238
2550
  try {
2239
2551
  const items = (typeof registry.archivedSessionDetails === 'function')
2240
2552
  ? (await registry.archivedSessionDetails()).items
2241
2553
  : (await registry.archivedSessionMetadata()).items.map((i) => ({ sessionId: i.sessionId, createdAt: i.createdAt, archivedAt: registry.archivedAt?.(i.sessionId) }));
2242
2554
  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 };
2555
+ // 归属分组(best-effort):活登记 → 路径命中 → 按会话目录重建。
2556
+ // 宿主删除工作区后登记消失,靠会话目录把分组补回来(详见 buildHistoryGroups)。
2557
+ const { groups, workspaces } = await buildHistoryGroups(registry, items);
2558
+ return { ok: true, items, groups, workspaces, retentionDays };
2276
2559
  }
2277
2560
  catch (e) {
2278
2561
  return { ok: false, error: message(e) };
@@ -2291,23 +2574,6 @@ export default {
2291
2574
  ? (await registry.archivedSessionDetails()).items
2292
2575
  : (await registry.archivedSessionMetadata()).items;
2293
2576
  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
2577
  const cache = ctx.get('sessionProjectionCache');
2312
2578
  const items = [];
2313
2579
  for (const h of headers) {
@@ -2325,9 +2591,6 @@ export default {
2325
2591
  item.cwd = h.cwd;
2326
2592
  if (typeof h.createdAt === 'number' && Number.isFinite(h.createdAt))
2327
2593
  item.createdAt = h.createdAt;
2328
- const wid = owned.get(sessionId);
2329
- if (wid !== undefined)
2330
- item.workspaceId = wid;
2331
2594
  if (cache && typeof cache.cachedSnapshot === 'function') {
2332
2595
  try {
2333
2596
  const snap = cache.cachedSnapshot(h, 0, ['title']);
@@ -2348,7 +2611,9 @@ export default {
2348
2611
  }
2349
2612
  items.push(item);
2350
2613
  }
2351
- return { ok: true, items };
2614
+ // 归属分组(与 history-list 同源):导出弹窗的工作区筛选按同一套组 id。
2615
+ const { groups } = await buildHistoryGroups(registry, items);
2616
+ return { ok: true, items, groups };
2352
2617
  }
2353
2618
  catch (e) {
2354
2619
  return { ok: false, error: message(e) };
@@ -2414,8 +2679,7 @@ export default {
2414
2679
  catch (e) {
2415
2680
  return { ok: false, error: message(e) };
2416
2681
  }
2417
- },
2418
- // 批量归档(导出弹窗「归档所选」):把选中的会话收进 History,纳入保留期管理。
2682
+ }, // 批量归档(导出弹窗「归档所选」):把选中的会话收进 History,纳入保留期管理。
2419
2683
  'history-archive-batch': async (args) => {
2420
2684
  const registry = getHistoryRegistry();
2421
2685
  if (!registry)
@@ -2437,6 +2701,55 @@ export default {
2437
2701
  }
2438
2702
  return { ok: true, archived, failed };
2439
2703
  },
2704
+ // 兼容体检:把"宿主到底支持哪些操作、哪些降级了、为什么"变成可读状态,
2705
+ // 而不是等用户点删除时才看见报错。只读,不改任何数据。
2706
+ 'compat-status': async (args) => {
2707
+ const force = Boolean(args && args.refresh);
2708
+ try {
2709
+ const registry = getHistoryRegistry();
2710
+ const assessment = registry && typeof registry.capabilities === 'function'
2711
+ ? registry.capabilities(force)
2712
+ : undefined;
2713
+ if (assessment === undefined) {
2714
+ return {
2715
+ ok: true,
2716
+ host: { version: 'unknown' },
2717
+ findings: [],
2718
+ degraded: [],
2719
+ blockers: ['归档服务未挂载:无法探测宿主能力'],
2720
+ summary: '宿主能力探测不可用(归档服务未挂载)',
2721
+ };
2722
+ }
2723
+ const summary = summarize(assessment);
2724
+ // 每次刷新都留一条结构化日志:升级当天就能在日志里看到降级发生。
2725
+ if (force || !compatLoggedFor || compatLoggedFor !== assessment.identity.version) {
2726
+ compatLoggedFor = assessment.identity.version;
2727
+ void pluginLog()('compat/probe', {
2728
+ hostVersion: assessment.identity.version,
2729
+ summary,
2730
+ degraded: assessment.degraded.map((item) => ({ id: item.id, state: item.state, detail: item.detail })),
2731
+ blockers: assessment.identity.blockers,
2732
+ sameAsHost: assessment.identity.sameAsHost,
2733
+ }).catch(() => { });
2734
+ }
2735
+ return {
2736
+ ok: true,
2737
+ host: { version: assessment.identity.version, modules: assessment.identity.modules },
2738
+ sameAsHost: assessment.identity.sameAsHost,
2739
+ findings: assessment.findings,
2740
+ degraded: assessment.degraded,
2741
+ blockers: assessment.identity.blockers,
2742
+ mayDelete: assessment.mayDelete,
2743
+ verifiedVersion: VERIFIED_HOST_VERSION,
2744
+ expectedPeerRange: EXPECTED_PEER_RANGE,
2745
+ generatedAt: assessment.generatedAt,
2746
+ summary,
2747
+ };
2748
+ }
2749
+ catch (e) {
2750
+ return { ok: false, error: message(e) };
2751
+ }
2752
+ },
2440
2753
  'history-unarchive': async (args) => {
2441
2754
  const registry = getHistoryRegistry();
2442
2755
  if (!registry)
@@ -2446,7 +2759,8 @@ export default {
2446
2759
  return { ok: false, error: '缺少 sessionId' };
2447
2760
  try {
2448
2761
  const r = await registry.unarchiveSession(sessionId);
2449
- return { ok: true, archivedSessionIds: r.archivedSessionIds };
2762
+ const workspace = await restoreWorkspaceAccounting([sessionId]);
2763
+ return { ok: true, archivedSessionIds: r.archivedSessionIds, ...(workspace ? { workspace } : {}) };
2450
2764
  }
2451
2765
  catch (e) {
2452
2766
  return { ok: false, error: message(e) };
@@ -2478,7 +2792,8 @@ export default {
2478
2792
  return parsed;
2479
2793
  try {
2480
2794
  const r = await registry.unarchiveSessions(parsed.target);
2481
- return { ok: true, unarchivedSessionIds: r.unarchivedSessionIds, archivedSessionIds: r.archivedSessionIds };
2795
+ const workspace = await restoreWorkspaceAccounting(r.unarchivedSessionIds || []);
2796
+ return { ok: true, unarchivedSessionIds: r.unarchivedSessionIds, archivedSessionIds: r.archivedSessionIds, ...(workspace ? { workspace } : {}) };
2482
2797
  }
2483
2798
  catch (e) {
2484
2799
  return { ok: false, error: message(e) };
@@ -2499,6 +2814,28 @@ export default {
2499
2814
  return { ok: false, error: message(e) };
2500
2815
  }
2501
2816
  },
2817
+ // 重新登记工作区(写宿主状态):为「工作区已被删除、目录仍在」的分组补回一条登记。
2818
+ // 只在用户显式确认后调用;宿主 realpath 会拒绝不存在的目录,同路径已登记则幂等返回。
2819
+ // 不移动、不删除任何文件或会话,也不改写 sessionIds 记账。
2820
+ 'history-workspace-register': async (args) => {
2821
+ const registry = getHistoryRegistry();
2822
+ if (!registry)
2823
+ return { ok: false, error: '归档服务未挂载' };
2824
+ const path = String((args && args.path) || '').trim();
2825
+ if (!path)
2826
+ return { ok: false, error: '缺少工作区目录 path' };
2827
+ if (typeof registry.registerWorkspace !== 'function') {
2828
+ return { ok: false, error: '当前环境不支持重新登记工作区(registry 未实现 registerWorkspace)' };
2829
+ }
2830
+ const title = args && args.title ? String(args.title) : undefined;
2831
+ try {
2832
+ const workspace = await registry.registerWorkspace(path, title);
2833
+ return { ok: true, workspace };
2834
+ }
2835
+ catch (e) {
2836
+ return { ok: false, error: message(e) };
2837
+ }
2838
+ },
2502
2839
  // 从其他 Agent(Claude Code / Cursor JSONL、Codex Markdown、通用文本)导入对话,
2503
2840
  // 通过 sessions.create 的 seed 机制生成一个可继续对话的全新会话。
2504
2841
  'history-import': async (args) => {
@@ -2518,9 +2855,8 @@ export default {
2518
2855
  }
2519
2856
  if (!turns.length)
2520
2857
  return { ok: false, error: '未能从该文件中识别出对话内容' };
2521
- const sessions = ctx.get('sessions');
2522
- if (!sessions || typeof sessions.create !== 'function')
2523
- return { ok: false, error: '当前环境不支持创建会话(sessions.create 不可用)' };
2858
+ if (!agents || typeof agents.create !== 'function')
2859
+ return { ok: false, error: '当前环境不支持创建会话(agents.create 不可用)' };
2524
2860
  // 仅接受绝对路径的 cwd;非法或缺失时会话不带目录(归入未分组)。
2525
2861
  const cwdArg = String((args && args.cwd) || '').trim();
2526
2862
  const cwd = /^([A-Za-z]:[\\/]|\\\\|\/)/.test(cwdArg) ? cwdArg : undefined;
@@ -2536,8 +2872,19 @@ export default {
2536
2872
  surfaceOp: 'append',
2537
2873
  }));
2538
2874
  try {
2539
- const created = sessions.create(undefined, { seed, meta: { ...(cwd ? { cwd } : {}), createdAt: base } });
2540
- return { ok: true, sessionId: created.id, count: turns.length };
2875
+ // `sessions.create()` 只创建裸 Session;它不经过 DSH agent factory / workspace
2876
+ // / session-log,所以返回的内存 id 不会进入 UI 的会话列表。必须走官方的
2877
+ // `ctx.agents.create()`,它会在同一事务里创建 Agent、发布 Session,并触发宿主
2878
+ // 的持久化与 projection 链路。这里使用 UUID,避免 SessionStore 的裸自增 id 与
2879
+ // 宿主 API 会话 id 空间混用。
2880
+ const id = 'session-' + randomUUID();
2881
+ await agents.create({ sessionId: id, seed, ...(cwd ? { meta: { cwd } } : {}) });
2882
+ const sessions = ctx.get('sessions');
2883
+ const live = sessions && typeof sessions.get === 'function' ? sessions.get(id) : undefined;
2884
+ if (live === undefined || live === null) {
2885
+ return { ok: false, error: `创建会话失败:宿主未登记该会话(${id}),未导入任何内容` };
2886
+ }
2887
+ return { ok: true, sessionId: id, count: turns.length };
2541
2888
  }
2542
2889
  catch (e) {
2543
2890
  return { ok: false, error: '创建会话失败: ' + message(e) };
@@ -2624,17 +2971,20 @@ export default {
2624
2971
  },
2625
2972
  'history-retention-set': async (args) => {
2626
2973
  const days = Number((args && args.retentionDays) ?? -1);
2627
- if (!Number.isFinite(days) || days < 0)
2628
- return { ok: false, error: 'retentionDays 需为非负整数(0=永久不删除)' };
2974
+ // 错误文案承诺的是「非负整数」,就按整数校验:1.5 这类小数以前会被静默取整成 1,
2975
+ // 用户看到的回执与输入不符。
2976
+ if (!Number.isSafeInteger(days) || days < 0) {
2977
+ return { ok: false, error: 'retentionDays 需为非负整数(0=永久不删除),收到: ' + String((args && args.retentionDays)) };
2978
+ }
2629
2979
  try {
2630
- await writeHistoryRetention(Math.floor(days));
2980
+ await writeHistoryRetention(days);
2631
2981
  }
2632
2982
  catch (e) {
2633
2983
  return { ok: false, error: '写入保留期失败: ' + message(e) };
2634
2984
  }
2635
2985
  // 设置变更后立即扫一次,UI 反映新策略。
2636
2986
  await sweepHistory().catch(() => { });
2637
- return { ok: true, retentionDays: Math.floor(days) };
2987
+ return { ok: true, retentionDays: days };
2638
2988
  },
2639
2989
  };
2640
2990
  // ---------- agent-facing tools (standard ctx.tools.register + defineTool) ----------
@@ -2746,7 +3096,10 @@ export default {
2746
3096
  }));
2747
3097
  tools.register(defineTool({
2748
3098
  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.',
3099
+ // 落点必须和 UI「创建技能」一致:两者都走 core 的默认落点(hub 的
3100
+ // tool-management/skills/,hub 缺失时退回 DSH_HOME/skills)。以前这里硬编码
3101
+ // root:'dsh',于是同一个「新建技能」动作,人点界面和模型调用会落到两个不同的根。
3102
+ 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
3103
  parameters: {
2751
3104
  name: { type: 'string', required: true, description: 'Skill name; normalized to kebab-case.' },
2752
3105
  description: { type: 'string', required: true, description: 'A concise routing description for when to use the skill.' },
@@ -2754,7 +3107,7 @@ export default {
2754
3107
  },
2755
3108
  output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
2756
3109
  async execute(args) {
2757
- const r = await skillsService.ops['skill-create']({ name: args.name, description: args.description, body: args.body, root: 'dsh' });
3110
+ const r = await skillsService.ops['skill-create']({ name: args.name, description: args.description, body: args.body });
2758
3111
  if (!r || r.ok === false)
2759
3112
  throw new Error((r && r.error) || 'skill create failed');
2760
3113
  const data = r.data || {};
@@ -2872,13 +3225,31 @@ export default {
2872
3225
  pluginLog()('confirm-bypass', `完全权限(approval=never):跳过${CONFIRM_LABELS[String(exec && exec.name)]}的确认,直接放行`).catch(() => { });
2873
3226
  return true;
2874
3227
  };
2875
- ctx.on('tools/pre-execute', (exec, next) => {
3228
+ // subagent_run 的目标人设是否真实存在。确认门是「问用户要不要做」,如果这个请求
3229
+ // 本来就做不成(人设不存在),弹卡 / 写 bypass 日志只会产生一次无效审批:
3230
+ // 用户批准之后模型收到的是「人设不可用」。所以先校验,再决定要不要问。
3231
+ // 校验本身不可用时返回 true(保持原行为,绝不因为探测失败而少问一次)。
3232
+ const subagentRunTargetExists = async (exec) => {
3233
+ try {
3234
+ const name = String((exec && exec.arguments && exec.arguments.agent) || '').trim();
3235
+ if (!name)
3236
+ return false;
3237
+ const list = await subagentService.list();
3238
+ return list.some((p) => p.name === name);
3239
+ }
3240
+ catch (e) {
3241
+ return true;
3242
+ }
3243
+ };
3244
+ ctx.on('tools/pre-execute', async (exec, next) => {
2876
3245
  if (!exec || !CONFIRM_LABELS[String(exec.name)])
2877
3246
  return next();
3247
+ if (exec.name === 'subagent_run' && !(await subagentRunTargetExists(exec)))
3248
+ return next();
2878
3249
  if (bypassedByFullAccess(exec))
2879
3250
  return next();
2880
3251
  if (exec.name === 'skill_manager_create') {
2881
- return Promise.resolve({ kind: 'ask', reason: 'Create a new skill under DSH_HOME/skills' });
3252
+ return Promise.resolve({ kind: 'ask', reason: 'Create a new skill under ~/.dsh/tool-management/skills' });
2882
3253
  }
2883
3254
  if (exec.name === 'rule_manager_write') {
2884
3255
  // D2:模型写规则默认需确认;设置关闭后直接放行。ask 无应答者时降级为拒绝(fail-closed),
@@ -2942,9 +3313,10 @@ export default {
2942
3313
  // ---------- HTTP API route (UI half), registered defensively ----------
2943
3314
  if (webServer) {
2944
3315
  // 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).
3316
+ // payloads (64 MiB of raw content). Reachability is decided by
3317
+ // fenceRejection() below — the host's Host/Origin + browser-session fence
3318
+ // (or a correct explicit token). `config.maxBodyBytes` overrides the cap
3319
+ // (tests use a small value).
2948
3320
  const configuredMaxBody = Number(config?.maxBodyBytes);
2949
3321
  const MAX_BODY = Number.isFinite(configuredMaxBody) && configuredMaxBody > 0 ? configuredMaxBody : 88 * 1024 * 1024;
2950
3322
  const readBody = (req) => new Promise((resolve, reject) => {
@@ -2965,6 +3337,20 @@ export default {
2965
3337
  req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
2966
3338
  req.on('error', reject);
2967
3339
  });
3340
+ // 宿主 BrowserAuth 栅栏。宿主的鉴权**只包在它自己注册的路由上**——dsh-host-webserver
3341
+ // 没有全局中间件,既定约定是每个注册方自己调 connection.requestRejection(req):
3342
+ // 它先做 Host/Origin 栅栏(Host 必须是回环或 deployment 派生的 LAN IP 字面量,
3343
+ // 这是 DNS rebinding 唯一伪造不了的头),再叠加 browser-session cookie 鉴权。
3344
+ // 本插件此前只查公开常量 x-dsh-plugin 与自比对(Origin.host === Host)的 "同源",
3345
+ // 两者都挡不住 rebind 后的网页或本机任意进程 —— 等于没鉴权。现在优先走宿主栅栏。
3346
+ const connection = (() => {
3347
+ try {
3348
+ return ctx.get('connection');
3349
+ }
3350
+ catch {
3351
+ return undefined;
3352
+ }
3353
+ })();
2968
3354
  try {
2969
3355
  // ctx.effect wires the route's disposer into this plugin's scope, so an
2970
3356
  // unload (HMR removal, disable, update) unregisters the route — the
@@ -2973,12 +3359,10 @@ export default {
2973
3359
  kind: 'exact',
2974
3360
  path: '/dsh-plugin-tool-management/api',
2975
3361
  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.
3362
+ // 鉴权顺序:POST-only → 插件门禁头(防误触,不是凭证)→ 宿主栅栏
3363
+ // (Host/Origin + browser-session cookie)→ 可选访问令牌。令牌是本地工具与
3364
+ // LAN 部署的逃生门:**令牌正确即视为已授权**,可越过浏览器鉴权;
3365
+ // 未配置令牌时不再有"谁都放行"的缺口。
2982
3366
  const hdr = (name) => {
2983
3367
  const v = req.headers?.[name];
2984
3368
  return Array.isArray(v) ? v[0] ?? '' : v ?? '';
@@ -2994,18 +3378,12 @@ export default {
2994
3378
  return;
2995
3379
  }
2996
3380
  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' }));
3381
+ const tokenAccepted = TOKEN !== '' && hdr('x-dsh-token') === TOKEN;
3382
+ if (!tokenAccepted) {
3383
+ const fence = fenceRejection(req, connection);
3384
+ if (fence) {
3385
+ res.writeHead(fence.status, { 'content-type': 'application/json' });
3386
+ res.end(JSON.stringify({ ok: false, error: fence.error }));
3009
3387
  return;
3010
3388
  }
3011
3389
  }
@@ -3023,6 +3401,13 @@ export default {
3023
3401
  /* otherwise fall through with {} */
3024
3402
  }
3025
3403
  const op = String(payload.op || '');
3404
+ // 敏感 op(会吐明文密钥与完整配置)额外要求带 Origin 头:即使在宿主栅栏
3405
+ // 缺失的组合里,也不接受"无 Origin 的本机请求"直接取密钥。持有效令牌的
3406
+ // 本地工具不属于此类,直接放行。
3407
+ if (SENSITIVE_OPS.has(op) && !origin && !tokenAccepted) {
3408
+ res.end(JSON.stringify({ ok: false, error: '该操作仅接受同源浏览器请求(缺少 Origin 头)' }));
3409
+ return;
3410
+ }
3026
3411
  if (TOKEN && WRITE_OPS.has(op) && hdr('x-dsh-token') !== TOKEN) {
3027
3412
  res.end(JSON.stringify({ ok: false, error: '缺少或错误的访问令牌(x-dsh-token)' }));
3028
3413
  return;