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.
- package/README.md +300 -236
- package/README_EN.md +300 -290
- package/cordis.patch.yml +9 -55
- package/docs/Changelog.md +108 -435
- package/docs/images/1/345/234/272/346/231/257.png +0 -0
- package/docs/images/1/345/234/272/346/231/257_en.png +0 -0
- package/docs/images/2MCP.png +0 -0
- package/docs/images/2MCP_en.png +0 -0
- package/docs/images/3/346/212/200/350/203/275.png +0 -0
- package/docs/images/3/346/212/200/350/203/275_en.png +0 -0
- package/docs/images/4/345/255/220/346/231/272/350/203/275/344/275/223.png +0 -0
- package/docs/images/4/345/255/220/346/231/272/350/203/275/344/275/223_en.png +0 -0
- package/docs/images/5/346/217/220/347/244/272/350/257/215.png +0 -0
- package/docs/images/5/346/217/220/347/244/272/350/257/215_en.png +0 -0
- package/docs/images/6/350/256/260/345/277/206.png +0 -0
- package/docs/images/6/350/256/260/345/277/206_en.png +0 -0
- package/docs/images/7/344/274/232/350/257/235.png +0 -0
- package/docs/images/7/344/274/232/350/257/235_en.png +0 -0
- package/docs/images/8/345/205/274/345/256/271.png +0 -0
- package/docs/images/8/345/205/274/345/256/271_en.png +0 -0
- package/docs/update.md +54 -0
- package/lib/agents-md/preset-id.js +49 -0
- package/lib/agents-md/service.js +138 -56
- package/lib/client.js +4142 -3489
- package/lib/compat/probe.js +665 -0
- package/lib/history/bridge.js +293 -0
- package/lib/history/workspace.js +498 -52
- package/lib/http-fence.js +73 -0
- package/lib/index.js +511 -126
- package/lib/rules/provider.js +3 -3
- package/lib/rules/service.js +264 -30
- package/lib/scene-prompt-sync.js +112 -0
- package/lib/skills/core.js +112 -35
- package/lib/skills/service.js +6 -1
- package/package.json +5 -3
- package/screenshots.json +8 -7
- package/docs/images/MCP.png +0 -0
- package/docs/images//344/274/232/350/257/235.png +0 -0
- package/docs/images//345/234/272/346/231/257.png +0 -0
- package/docs/images//345/255/220/346/231/272/350/203/275/344/275/223.png +0 -0
- package/docs/images//346/212/200/350/203/275.png +0 -0
- package/docs/images//346/217/220/347/244/272/350/257/215.png +0 -0
- 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 {
|
|
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
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
324
|
-
|
|
325
|
-
//
|
|
326
|
-
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
|
|
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
|
-
|
|
337
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2226
|
-
'agentsmd-
|
|
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
|
-
|
|
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: '
|
|
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
|
-
//
|
|
2244
|
-
//
|
|
2245
|
-
|
|
2246
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2522
|
-
|
|
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
|
-
|
|
2540
|
-
|
|
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
|
-
|
|
2628
|
-
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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).
|
|
2946
|
-
//
|
|
2947
|
-
// `config.maxBodyBytes` overrides the cap
|
|
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
|
-
//
|
|
2977
|
-
//
|
|
2978
|
-
//
|
|
2979
|
-
//
|
|
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
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
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;
|