dsh-plugin-yanyan-toolkit 0.0.0-stage → 1.2.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 (34) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/LICENSE +29 -0
  3. package/README.i18n.yaml +6 -0
  4. package/README.md +65 -2
  5. package/README.zh.md +66 -0
  6. package/cordis.patch.yml +35 -0
  7. package/icon.svg +8 -0
  8. package/lib/client.js +26 -0
  9. package/lib/index.js +44 -0
  10. package/locale/en.json +6 -0
  11. package/locale/zh.json +6 -0
  12. package/package.json +79 -4
  13. package/packages/dsh-plugin-yanyan-subagent/README.md +72 -0
  14. package/packages/dsh-plugin-yanyan-subagent/README.zh.md +72 -0
  15. package/packages/dsh-plugin-yanyan-subagent/cordis.patch.yml +8 -0
  16. package/packages/dsh-plugin-yanyan-subagent/icon.svg +8 -0
  17. package/packages/dsh-plugin-yanyan-subagent/lib/client.js +1250 -0
  18. package/packages/dsh-plugin-yanyan-subagent/lib/index.js +951 -0
  19. package/packages/dsh-plugin-yanyan-subagent/lib/legacy-hidden-ids.json +9 -0
  20. package/packages/dsh-plugin-yanyan-subagent/lib/log-rewrite.js +382 -0
  21. package/packages/dsh-plugin-yanyan-subagent/lib/zstd-frames.js +81 -0
  22. package/packages/dsh-plugin-yanyan-subagent/locale/en.json +1 -0
  23. package/packages/dsh-plugin-yanyan-subagent/locale/zh.json +1 -0
  24. package/packages/dsh-plugin-yanyan-subagent/package.json +32 -0
  25. package/packages/dsh-plugin-yanyan-update/README.md +24 -0
  26. package/packages/dsh-plugin-yanyan-update/README.zh.md +24 -0
  27. package/packages/dsh-plugin-yanyan-update/cordis.patch.yml +7 -0
  28. package/packages/dsh-plugin-yanyan-update/icon.svg +5 -0
  29. package/packages/dsh-plugin-yanyan-update/lib/client.js +400 -0
  30. package/packages/dsh-plugin-yanyan-update/lib/index.js +840 -0
  31. package/packages/dsh-plugin-yanyan-update/locale/en.json +1 -0
  32. package/packages/dsh-plugin-yanyan-update/locale/zh.json +1 -0
  33. package/packages/dsh-plugin-yanyan-update/package.json +32 -0
  34. package/scripts/cleanup-ghost-subagents.mjs +241 -0
@@ -0,0 +1,951 @@
1
+ // dsh-plugin-yanyan-subagent — Host half(纯 ESM)。
2
+ //
3
+ // host 半区职责:
4
+ // ① /health 健康检查;
5
+ // ② /config 回显 normalizeConfig 后的配置;
6
+ // ③ /list 可选兜底数据源:ctx.get('subagents').listDescendants();
7
+ // ④ /purge 物理删除一个子智能体(/archive 保留为兼容别名)。
8
+ // 所有 /__ 路由统一经过 ctx.connection.requestRejection(req) 准入栅栏。
9
+ //
10
+ // 为什么必须物理删除而不是归档:
11
+ // subagentCatalog 是父会话日志里 `subagent/catalog` 事件的 append-only
12
+ // 投影(官方 @deepseek-ai/dsh-chunked-list 只追加、无删除)。archiveSession
13
+ // 只是打个标记,既不删会话数据,也移除不了 catalog 引用;对 mnemon 虚拟
14
+ // 委派(id 形如 mnemon-run-v1-<uuid>,不是 session-<uuid> 会话)更会直接
15
+ // 报 session/not-found。所以这里:真实会话优先复用现成的
16
+ // /api/dsh-session-archive/delete 物理删除管线;无论成功与否,都再做一轮
17
+ // 本地清理(会话目录 / projcache / catalog 引用 / workspace 记账 / 台账 /
18
+ // 归档标记),并把命中条目的 id 交给 client 的持久化隐藏清单兜底。
19
+ //
20
+ // 本文件导出的纯函数同时供 test/*.test.mjs 做单元测试,不依赖 DSH 运行时。
21
+
22
+ import {
23
+ existsSync, readFileSync, readdirSync, realpathSync, rmSync, statSync,
24
+ writeFileSync, renameSync, unlinkSync,
25
+ } from 'node:fs';
26
+ import { join, resolve, sep } from 'node:path';
27
+ import {
28
+ readCatalogEvents, allSessionLogPaths, retireCatalogRow,
29
+ retireCatalogRowsEverywhere, scrubCatalogRows, flushPendingRewrites,
30
+ pendingFilePath, dropProjectionCache, queueCatalogRetire,
31
+ } from './log-rewrite.js';
32
+
33
+ export const name = 'dsh-plugin-yanyan-subagent';
34
+ export const VERSION = '1.2.0';
35
+
36
+ // 顶层硬依赖只保留路由与准入所必需的;模型工具通过可选的 ctx.inject(['tools'])
37
+ // 注册,没有该服务时静默降级,绝不影响下面这些 HTTP 路由的加载。
38
+ export const inject = ['webServer', 'connection'];
39
+
40
+ const ROUTE_BASE = '/__dsh-plugin-yanyan-subagent';
41
+ export const HEALTH_ROUTE = `${ROUTE_BASE}/health`;
42
+ export const CONFIG_ROUTE = `${ROUTE_BASE}/config`;
43
+ export const LIST_ROUTE = `${ROUTE_BASE}/list`;
44
+ export const CATALOG_ROUTE = `${ROUTE_BASE}/catalog`;
45
+ export const ARCHIVE_ROUTE = `${ROUTE_BASE}/archive`;
46
+ export const PURGE_ROUTE = `${ROUTE_BASE}/purge`;
47
+ export const SCRUB_ROUTE = `${ROUTE_BASE}/scrub`;
48
+ export const CLEANUP_ROUTE = `${ROUTE_BASE}/cleanup`;
49
+ export const DELETE_API = '/api/dsh-session-archive/delete';
50
+
51
+ const BODY_LIMIT = 64 * 1024;
52
+ export const DEFAULT_TAB_LABEL = '智能体管理';
53
+ export const CONFIG_DEFAULTS = Object.freeze({
54
+ tabLabel: DEFAULT_TAB_LABEL,
55
+ autoCleanEnabled: false,
56
+ idleMinutes: 30,
57
+ checkIntervalMin: 1,
58
+ labelMaxLength: 24,
59
+ });
60
+
61
+ // ───────────────── 纯函数:配置归一化(供测试导入) ─────────────────
62
+ // profile 覆盖层对同名 id 是整份替换 config、不做深合并,所以每个缺省
63
+ // key 都必须在这里兜底。idleMinutes 限制 1..10080,checkIntervalMin
64
+ // 限制 1..1440,tabLabel 去首尾空白、超长按 labelMaxLength 截断、空则回落默认。
65
+ export function normalizeConfig(config) {
66
+ const c = config && typeof config === 'object' ? config : {};
67
+ const maxLength = CONFIG_DEFAULTS.labelMaxLength;
68
+
69
+ let tabLabel = typeof c.tabLabel === 'string' ? c.tabLabel.trim() : '';
70
+ if (!tabLabel) tabLabel = DEFAULT_TAB_LABEL;
71
+ else if (tabLabel.length > maxLength) tabLabel = tabLabel.slice(0, maxLength);
72
+
73
+ let idleMinutes = Number(c.idleMinutes);
74
+ if (!Number.isFinite(idleMinutes)) idleMinutes = CONFIG_DEFAULTS.idleMinutes;
75
+ idleMinutes = Math.floor(idleMinutes);
76
+ if (idleMinutes < 1 || idleMinutes > 10080) idleMinutes = CONFIG_DEFAULTS.idleMinutes;
77
+
78
+ let checkIntervalMin = Number(c.checkIntervalMin);
79
+ if (!Number.isFinite(checkIntervalMin)) checkIntervalMin = CONFIG_DEFAULTS.checkIntervalMin;
80
+ checkIntervalMin = Math.floor(checkIntervalMin);
81
+ if (checkIntervalMin < 1 || checkIntervalMin > 1440) checkIntervalMin = CONFIG_DEFAULTS.checkIntervalMin;
82
+
83
+ return {
84
+ tabLabel,
85
+ autoCleanEnabled: c.autoCleanEnabled === true,
86
+ idleMinutes,
87
+ checkIntervalMin,
88
+ labelMaxLength: maxLength,
89
+ };
90
+ }
91
+
92
+ // ───────────────── 纯函数:catalog 兼容解析(供测试导入) ─────────────────
93
+ // 官方投影值可能是 SubagentListEntry[] 数组,也可能是 {entries:[...]} /
94
+ // {rows:[...]},还可能为空 / 缺失。统一收敛成条目数组。
95
+ function isRow(x) {
96
+ return !!x && typeof x === 'object' && !Array.isArray(x);
97
+ }
98
+ export function parseCatalog(raw) {
99
+ if (Array.isArray(raw)) return raw.filter(isRow);
100
+ if (raw && typeof raw === 'object') {
101
+ if (Array.isArray(raw.entries)) return raw.entries.filter(isRow);
102
+ if (Array.isArray(raw.rows)) return raw.rows.filter(isRow);
103
+ }
104
+ return [];
105
+ }
106
+
107
+ // ───────────────── 纯函数:空闲清理筛选(供测试导入) ─────────────────
108
+ // 规则:仅 activity==='inactive';排除 diagnostic 行、running 行、rootId 自身;
109
+ // idleMs = now - idleSince(id),idleSince 缺省为 now(即刚观察到,空闲 0)。
110
+ // idleMinutes<=0 表示不按阈值过滤(只要空闲就选)。
111
+ export function idleFilter(rows, options) {
112
+ const opts = options && typeof options === 'object' ? options : {};
113
+ const rootId = opts.rootId;
114
+ const idleMinutes = typeof opts.idleMinutes === 'number' && Number.isFinite(opts.idleMinutes)
115
+ ? opts.idleMinutes : CONFIG_DEFAULTS.idleMinutes;
116
+ const now = typeof opts.now === 'number' && Number.isFinite(opts.now) ? opts.now : Date.now();
117
+ const since = typeof opts.idleSince === 'function' ? opts.idleSince : () => now;
118
+ const threshold = idleMinutes > 0 ? idleMinutes * 60_000 : 0;
119
+ const list = Array.isArray(rows) ? rows : [];
120
+ const out = [];
121
+ for (const row of list) {
122
+ if (!isRow(row)) continue;
123
+ if (row.kind === 'diagnostic') continue;
124
+ if (row.activity === 'running') continue;
125
+ if (row.activity !== 'inactive') continue;
126
+ if (rootId != null && row.id === rootId) continue;
127
+ const started = since(row.id);
128
+ const base = typeof started === 'number' && Number.isFinite(started) ? started : now;
129
+ const idleMs = now - base;
130
+ if (threshold > 0 && idleMs < threshold) continue;
131
+ out.push(row);
132
+ }
133
+ return out;
134
+ }
135
+
136
+ // ───────────────── 纯函数:删除 id 安全校验(供测试导入) ─────────────────
137
+ // sessionId 必须是非空字符串且不含 / \ ..;不做任何路径操作。
138
+ export function isSafeSessionId(value) {
139
+ if (typeof value !== 'string' || !value) return false;
140
+ if (value.indexOf('/') !== -1) return false;
141
+ if (value.indexOf('\\') !== -1) return false;
142
+ if (value.indexOf('..') !== -1) return false;
143
+ return true;
144
+ }
145
+
146
+ // ───────────────── 纯函数:id 归一化与路径安全(供测试导入) ─────────────────
147
+ // 同一会话在父 catalog 里是裸 id,在归档清单里常带 `session-` 前缀。
148
+ // 一律用「去前缀后的核心 id」做主键,保证两种拼写都命中。
149
+ export function coreId(value) {
150
+ return String(value || '').replace(/^session-/, '');
151
+ }
152
+ export function spellings(value) {
153
+ const c = coreId(value);
154
+ return [c, 'session-' + c];
155
+ }
156
+ // mnemon 的虚拟委派(run/task/review 等)都不是真正的 session-<uuid> 会话。
157
+ export function isVirtualId(value) {
158
+ return /^mnemon-/i.test(coreId(value));
159
+ }
160
+ export function isInside(root, child) {
161
+ const r = resolve(root), c = resolve(child);
162
+ return c === r || c.startsWith(r + sep);
163
+ }
164
+ function matchesId(value, core) {
165
+ return typeof value === 'string' && coreId(value) === core;
166
+ }
167
+
168
+ // 从 projcache 的 chunked-list head(含 previous 分片)里剔除命中条目。
169
+ // 返回 {changed, removed};changed=true 时调用方应把 record 写回磁盘。
170
+ export function stripCatalogEntries(record, ids) {
171
+ const set = new Set((Array.isArray(ids) ? ids : [ids]).map(coreId));
172
+ const rows = record && record.record && record.record.rows;
173
+ const cat = rows && rows.subagentCatalog;
174
+ const head = cat && cat.val && cat.val.head;
175
+ if (!head) return { changed: false, removed: 0 };
176
+ let removed = 0;
177
+ let changed = false;
178
+ const seen = new Set();
179
+ for (let chunk = head; chunk && !seen.has(chunk); chunk = chunk.previous) {
180
+ seen.add(chunk);
181
+ if (!Array.isArray(chunk.values)) continue;
182
+ const next = chunk.values.filter((v) => {
183
+ const cid = v && (v.childId || v.id);
184
+ const hit = typeof cid === 'string' && set.has(coreId(cid));
185
+ if (hit) removed += 1;
186
+ return !hit;
187
+ });
188
+ if (next.length !== chunk.values.length) {
189
+ chunk.values = next;
190
+ changed = true;
191
+ }
192
+ }
193
+ return { changed, removed };
194
+ }
195
+
196
+ export function catalogFilePath(dshHome, sessionId) {
197
+ const dir = join(dshHome, 'storages', 'session_projcache', 'sessions');
198
+ const c = coreId(sessionId);
199
+ const name = String(sessionId).startsWith('session-') ? String(sessionId) : 'session-' + c;
200
+ return join(dir, name + '.json');
201
+ }
202
+
203
+ // DSH_HOME:优先环境变量;桌面启动器没有 --profile / DSH_PROFILE,但宿主把
204
+ // {name, dir, patchPath} 挂在 profileContext 上。dir 可能是 profile 目录,
205
+ // 向上找同时含 storages/ 与 sessions/ 的祖先即为 DSH_HOME。
206
+ export function resolveDshHome(ctx) {
207
+ if (process.env.DSH_HOME) return process.env.DSH_HOME;
208
+ let facts;
209
+ try {
210
+ facts = typeof ctx.get === 'function' ? ctx.get('profileContext') : undefined;
211
+ } catch (error) { facts = undefined; }
212
+ if (facts && typeof facts.dshHome === 'string' && facts.dshHome) return facts.dshHome;
213
+ const dirs = [];
214
+ if (facts && typeof facts.dir === 'string' && facts.dir) dirs.push(facts.dir);
215
+ if (facts && typeof facts.patchPath === 'string' && facts.patchPath) dirs.push(resolve(facts.patchPath, '..'));
216
+ for (const start of dirs) {
217
+ let cur = resolve(start);
218
+ for (let i = 0; i < 5; i++) {
219
+ if (existsSync(join(cur, 'storages')) && existsSync(join(cur, 'sessions'))) return cur;
220
+ const parent = resolve(cur, '..');
221
+ if (parent === cur) break;
222
+ cur = parent;
223
+ }
224
+ }
225
+ return '';
226
+ }
227
+
228
+ // ───────────────── 本地物理清理(纯文件操作,可单测) ─────────────────
229
+ function readJsonSafe(p) {
230
+ try { return JSON.parse(readFileSync(p, 'utf8')); } catch (error) { return null; }
231
+ }
232
+ function writeJsonAtomic(p, obj) {
233
+ const tmp = p + '.yanyan.tmp';
234
+ writeFileSync(tmp, JSON.stringify(obj), 'utf8');
235
+ renameSync(tmp, p);
236
+ }
237
+
238
+ /**
239
+ * 物理删除一个会话在本机留下的全部痕迹。
240
+ * 限定在 dshHome 内;所有目录删除都做 realpath 校验,防止符号链接越界。
241
+ * 遍历所有 projcache 的 catalog 文件(幽灵条目可能落在任意父会话缓存里,
242
+ * 不一定是 client 传来的 rootSessionId 那个文件)。
243
+ */
244
+ export function purgePhysical(dshHome, id, rootSessionId) {
245
+ const out = {
246
+ dirs: [], files: [], archived: 0, members: 0, ledger: 0,
247
+ catalog: [], catalogRemoved: 0, index: 0, skipped: [],
248
+ };
249
+ if (!dshHome) return out;
250
+ const c = coreId(id);
251
+
252
+ // ① 会话目录(两种拼写)。
253
+ const sessionsRoot = join(dshHome, 'sessions');
254
+ if (existsSync(sessionsRoot)) {
255
+ let rootReal;
256
+ try { rootReal = realpathSync(sessionsRoot); } catch (error) { rootReal = resolve(sessionsRoot); }
257
+ for (const proj of readdirSync(sessionsRoot)) {
258
+ const pp = join(sessionsRoot, proj);
259
+ let st;
260
+ try { st = statSync(pp); } catch (error) { continue; }
261
+ if (!st.isDirectory()) continue;
262
+ for (const seg of readdirSync(pp)) {
263
+ if (!matchesId(seg, c)) continue;
264
+ // 绝不删父会话目录本身。
265
+ if (rootSessionId && matchesId(seg, coreId(rootSessionId))) {
266
+ out.skipped.push(join(pp, seg) + ' (父会话,跳过)');
267
+ continue;
268
+ }
269
+ const dir = join(pp, seg);
270
+ try {
271
+ const real = realpathSync(dir);
272
+ if (!isInside(rootReal, real)) { out.skipped.push(dir + ' (路径越界)'); continue; }
273
+ out.dirs.push(dir);
274
+ rmSync(real, { recursive: true, force: true });
275
+ } catch (error) {
276
+ out.skipped.push(dir + ' (' + String((error && error.message) || error) + ')');
277
+ }
278
+ }
279
+ }
280
+ }
281
+
282
+ // ② projcache:清掉所有 catalog 文件里的命中条目 + 删幽灵自己的缓存文件。
283
+ const pdir = join(dshHome, 'storages', 'session_projcache', 'sessions');
284
+ if (existsSync(pdir)) {
285
+ for (const f of readdirSync(pdir)) {
286
+ if (!f.endsWith('.json')) continue;
287
+ const p = join(pdir, f);
288
+ const rec = readJsonSafe(p);
289
+ if (!rec) continue;
290
+ const strip = stripCatalogEntries(rec, [c]);
291
+ if (strip.changed) {
292
+ out.catalog.push(f + ' (-' + strip.removed + ')');
293
+ out.catalogRemoved += strip.removed;
294
+ try { writeJsonAtomic(p, rec); } catch (error) { out.skipped.push(p); }
295
+ }
296
+ if (matchesId(f.replace(/\.json$/, ''), c)) {
297
+ out.files.push(p);
298
+ try { unlinkSync(p); } catch (error) { /* 已不存在 */ }
299
+ }
300
+ }
301
+ }
302
+ // 聚合索引(若存在)。
303
+ const idxPath = join(dshHome, 'storages', 'session_projcache.json');
304
+ if (existsSync(idxPath)) {
305
+ const doc = readJsonSafe(idxPath);
306
+ const t = doc && doc.tables && doc.tables.sessions;
307
+ if (t) {
308
+ let n = 0;
309
+ for (const k of Object.keys(t)) {
310
+ if (matchesId(k, c)) { delete t[k]; n += 1; }
311
+ }
312
+ if (n > 0) {
313
+ out.index = n;
314
+ try { writeJsonAtomic(idxPath, doc); } catch (error) { /* 只读时忽略 */ }
315
+ }
316
+ }
317
+ }
318
+
319
+ // ③ workspace.json:归档标记 + 各 workspace 的 sessionIds 记账。
320
+ const wp = join(dshHome, 'storages', 'workspace.json');
321
+ if (existsSync(wp)) {
322
+ const doc = readJsonSafe(wp);
323
+ if (doc) {
324
+ let changed = false;
325
+ const g = doc.global;
326
+ if (g && Array.isArray(g.archivedSessionIds)) {
327
+ const next = g.archivedSessionIds.filter((x) => !matchesId(x, c));
328
+ out.archived = g.archivedSessionIds.length - next.length;
329
+ if (out.archived > 0) { g.archivedSessionIds = next; changed = true; }
330
+ }
331
+ const ws = doc.tables && doc.tables.workspaces;
332
+ const list = Array.isArray(ws) ? ws : (ws && typeof ws === 'object' ? Object.values(ws) : []);
333
+ for (const w of list) {
334
+ if (!w || !Array.isArray(w.sessionIds)) continue;
335
+ const next = w.sessionIds.filter((x) => !matchesId(x, c));
336
+ out.members += w.sessionIds.length - next.length;
337
+ if (next.length !== w.sessionIds.length) { w.sessionIds = next; changed = true; }
338
+ }
339
+ if (changed) {
340
+ try { writeJsonAtomic(wp, doc); } catch (error) { out.skipped.push(wp); }
341
+ }
342
+ }
343
+ }
344
+
345
+ // ④ 归档台账(dsh-session-archive 的 archivedAt 事实源)。
346
+ const lp = join(dshHome, 'dsh-session-archive', 'archive-ledger.json');
347
+ if (existsSync(lp)) {
348
+ const doc = readJsonSafe(lp);
349
+ const e = doc && doc.entries;
350
+ if (e && typeof e === 'object') {
351
+ let n = 0;
352
+ for (const k of Object.keys(e)) {
353
+ if (matchesId(k, c)) { delete e[k]; n += 1; }
354
+ }
355
+ if (n > 0) {
356
+ out.ledger = n;
357
+ try { writeJsonAtomic(lp, doc); } catch (error) { /* 只读时忽略 */ }
358
+ }
359
+ }
360
+ }
361
+ return out;
362
+ }
363
+
364
+ // ───────────────── HTTP 基础 ─────────────────
365
+ function respond(res, status, body) {
366
+ const payload = JSON.stringify(body);
367
+ res.writeHead(status, {
368
+ 'content-type': 'application/json; charset=utf-8',
369
+ 'cache-control': 'no-store',
370
+ 'content-length': Buffer.byteLength(payload),
371
+ });
372
+ res.end(payload);
373
+ }
374
+
375
+ function readBody(req, limit = BODY_LIMIT) {
376
+ return new Promise((resolve_) => {
377
+ const chunks = [];
378
+ let size = 0;
379
+ let settled = false;
380
+ const finish = (value) => {
381
+ if (settled) return;
382
+ settled = true;
383
+ resolve_(value);
384
+ };
385
+ req.on('data', (chunk) => {
386
+ if (settled) return;
387
+ size += chunk.length;
388
+ if (size > limit) {
389
+ finish({ ok: false, reason: 'body-too-large' });
390
+ return;
391
+ }
392
+ chunks.push(chunk);
393
+ });
394
+ req.on('end', () => {
395
+ if (settled) return;
396
+ try {
397
+ finish({ ok: true, text: Buffer.concat(chunks).toString('utf8') });
398
+ } catch (error) {
399
+ finish({ ok: false, reason: 'invalid-body' });
400
+ }
401
+ });
402
+ req.on('error', () => finish({ ok: false, reason: 'request-error' }));
403
+ });
404
+ }
405
+
406
+ async function parsePayload(req, res, allowedMethods) {
407
+ const method = String(req.method || 'GET').toUpperCase();
408
+ if (allowedMethods.indexOf(method) === -1) {
409
+ respond(res, 405, { ok: false, error: 'method-not-allowed', message: '请求方法不被允许。' });
410
+ return null;
411
+ }
412
+ const read = await readBody(req);
413
+ if (!read.ok) {
414
+ respond(res, 413, { ok: false, error: read.reason, message: '请求体过大或读取失败。' });
415
+ return null;
416
+ }
417
+ try {
418
+ return JSON.parse(read.text || '{}');
419
+ } catch (error) {
420
+ respond(res, 400, { ok: false, error: 'invalid-json', message: '请求体不是合法 JSON。' });
421
+ return null;
422
+ }
423
+ }
424
+
425
+ // 统一准入:仅当 requestRejection 返回 undefined 时放行,不放行一律 403。
426
+ function guard(req, res, ctx) {
427
+ if (ctx.connection.requestRejection(req) === undefined) return true;
428
+ respond(res, 403, { ok: false, error: 'forbidden', message: '请求未通过 DSH 准入裁决。' });
429
+ return false;
430
+ }
431
+
432
+ // ───────────────── 路由 handlers ─────────────────
433
+ function makeHealthHandler(ctx) {
434
+ return function healthHandler(req, res) {
435
+ if (!guard(req, res, ctx)) return;
436
+ const method = String(req.method || 'GET').toUpperCase();
437
+ if (method !== 'GET' && method !== 'POST') {
438
+ respond(res, 405, { ok: false, error: 'method-not-allowed' });
439
+ return;
440
+ }
441
+ respond(res, 200, { ok: true, version: VERSION });
442
+ };
443
+ }
444
+
445
+ /**
446
+ * 一次性迁移清单:旧版「删除=归档」遗留、已由 cleanup 脚本从磁盘清除、
447
+ * 但会因 append-only catalog 重建而复现的 id。/config 把它们下发给 client,
448
+ * client 在启动时并入持久化隐藏清单。文件不存在/损坏一律返回空数组。
449
+ */
450
+ export function legacyHiddenIds() {
451
+ try {
452
+ const url = new URL('./legacy-hidden-ids.json', import.meta.url);
453
+ const doc = JSON.parse(readFileSync(url, 'utf8'));
454
+ const list = doc && Array.isArray(doc.ids) ? doc.ids : [];
455
+ return list.filter((x) => typeof x === 'string' && x).map(coreId);
456
+ } catch (error) {
457
+ return [];
458
+ }
459
+ }
460
+
461
+ function makeConfigHandler(ctx, cfg) {
462
+ return async function configHandler(req, res) {
463
+ if (!guard(req, res, ctx)) return;
464
+ const payload = await parsePayload(req, res, ['POST']);
465
+ if (payload === null) return;
466
+ respond(res, 200, { ok: true, config: cfg, legacyHiddenIds: legacyHiddenIds() });
467
+ };
468
+ }
469
+
470
+ // 可选增强:ctx.get('subagents').listDescendants(rootSessionId) 返回子智能体列表。
471
+ // 服务缺失或抛错都收敛成 service-unavailable,绝不抛到外层。
472
+ function makeListHandler(ctx) {
473
+ return async function listHandler(req, res) {
474
+ if (!guard(req, res, ctx)) return;
475
+ const payload = await parsePayload(req, res, ['POST']);
476
+ if (payload === null) return;
477
+ const rootSessionId = typeof payload.rootSessionId === 'string' ? payload.rootSessionId : '';
478
+ try {
479
+ const service = typeof ctx.get === 'function' ? ctx.get('subagents') : null;
480
+ if (!service || typeof service.listDescendants !== 'function') {
481
+ respond(res, 200, { ok: false, error: 'service-unavailable', rows: [] });
482
+ return;
483
+ }
484
+ const rows = await service.listDescendants(rootSessionId);
485
+ respond(res, 200, { ok: true, rows: Array.isArray(rows) ? rows : [] });
486
+ } catch (error) {
487
+ respond(res, 200, { ok: false, error: 'service-unavailable', rows: [] });
488
+ }
489
+ };
490
+ }
491
+
492
+ /** 真实会话优先复用现成的 dsh-session-archive 物理删除管线。 */
493
+ async function tryOfficialDelete(ctx, id) {
494
+ try {
495
+ const ws = typeof ctx.get === 'function' ? ctx.get('webServer') : null;
496
+ const origin = ws && typeof ws.getOrigin === 'function' ? ws.getOrigin() : '';
497
+ if (!origin || typeof fetch !== 'function') {
498
+ return { ok: false, reason: 'webServer/fetch 不可用' };
499
+ }
500
+ const canonical = String(id).startsWith('session-') ? id : 'session-' + id;
501
+ const resp = await fetch(origin.replace(/\/$/, '') + DELETE_API, {
502
+ method: 'POST',
503
+ headers: { 'content-type': 'application/json' },
504
+ body: JSON.stringify({ ids: [canonical] }),
505
+ signal: AbortSignal.timeout(15_000),
506
+ });
507
+ if (!resp.ok) return { ok: false, reason: 'HTTP ' + resp.status };
508
+ return { ok: true };
509
+ } catch (error) {
510
+ return { ok: false, reason: String((error && error.message) || error) };
511
+ }
512
+ }
513
+
514
+ /**
515
+ * 删除一条子智能体的核心逻辑(HTTP handler 与模型工具共用)。
516
+ *
517
+ * 在 turn 2 物理删除(会话目录 / projcache / 归档标记 / 工作区记账 / 台账)之上,
518
+ * turn 3 增加决定性的一步:把父会话日志里的 `subagent/catalog` 事件「原位退休」。
519
+ * 这是根治 append-only 残留的唯一办法 —— 只清 projcache 缓存,运行中的宿主
520
+ * 下次 refreshProjections 仍会按日志把行 fold 回来(19:57 实证)。改写日志后
521
+ * 再删投影缓存,宿主重建时就再也看不到这一行,侧边栏残留同步消失。
522
+ *
523
+ * @returns 纯 JSON 结果对象(不写 HTTP 响应),供 handler / 工具复用。
524
+ */
525
+ export async function performPurge(ctx, sessionId, rootSessionId, stopActivity, options) {
526
+ const opts = options && typeof options === 'object' ? options : {};
527
+ const virtual = isVirtualId(sessionId);
528
+ const dshHome = resolveDshHome(ctx);
529
+ const warnings = [];
530
+
531
+ // running 时先停活(尽力而为,两种拼写都试)。
532
+ if (stopActivity) {
533
+ const sub = typeof ctx.get === 'function' ? ctx.get('subagents') : null;
534
+ if (sub && typeof sub.interruptByUser === 'function') {
535
+ for (const s of spellings(sessionId)) {
536
+ try { sub.interruptByUser(s); break; } catch (error) { /* 换拼写再试 */ }
537
+ }
538
+ }
539
+ }
540
+
541
+ // 真实会话:优先走现成物理删除管线(会清 sqlite rdb / projcache index)。
542
+ let official = null;
543
+ if (!virtual) official = await tryOfficialDelete(ctx, sessionId);
544
+
545
+ // 归档标记:用公开 seam 从 workspaceRegistry 摘掉(比直接改文件更可靠)。
546
+ const registry = typeof ctx.get === 'function' ? ctx.get('workspaceRegistry') : null;
547
+ let unarchived = 0;
548
+ if (registry && typeof registry.unarchiveSession === 'function') {
549
+ for (const s of spellings(sessionId)) {
550
+ try { await registry.unarchiveSession(s); unarchived += 1; } catch (error) { /* 不在归档集合 */ }
551
+ }
552
+ }
553
+
554
+ // 本地兜底清理:会话目录 / projcache / catalog 引用 / workspace 记账 / 台账。
555
+ const report = purgePhysical(dshHome, sessionId, rootSessionId);
556
+ if (!dshHome) warnings.push('未解析到 DSH_HOME,文件级清理已跳过;客户端仍会隐藏该条目。');
557
+ if (official && !official.ok) warnings.push('现成删除管线未生效:' + official.reason);
558
+
559
+ // 根治 append-only catalog:把日志里的 catalog 行原位退休(全库扫,父日志优先)。
560
+ // 失败(并发写 / 解码错)会排队到 pending 文件,宿主下次启动补做;本地隐藏
561
+ // 清单仍会立即生效,用户当下不会再看到该行。
562
+ // 批量清理时 skipCatalogRetire=true:由调用方最后统一改写一次日志,避免 N 次全库解压。
563
+ let catalog = { retired: false, removed: 0, queued: false };
564
+ if (dshHome && !opts.skipCatalogRetire) {
565
+ try {
566
+ catalog = retireCatalogRow(dshHome, rootSessionId, sessionId, pendingFilePath(dshHome), true);
567
+ if (catalog.queued) warnings.push('日志改写遇到并发写入,已排队到下次启动补做;本地隐藏清单立即生效。');
568
+ // 删投影缓存,促使宿主按(已改写的)日志重新 fold,而不是复用旧缓存。
569
+ if (rootSessionId) dropProjectionCache(dshHome, rootSessionId);
570
+ } catch (error) {
571
+ warnings.push('日志改写失败:' + String((error && error.message) || error));
572
+ }
573
+ }
574
+
575
+ const deleted = report.dirs.length + report.files.length + report.archived
576
+ + report.members + report.ledger + report.catalogRemoved + report.index + unarchived;
577
+ return {
578
+ ok: true,
579
+ sessionId,
580
+ virtual,
581
+ rootSessionId,
582
+ deleted,
583
+ unarchived,
584
+ catalogRemoved: report.catalogRemoved,
585
+ catalogRetired: catalog.removed,
586
+ catalogQueued: catalog.queued,
587
+ official: official ? official.ok : null,
588
+ warnings,
589
+ message: (catalog.removed > 0 || catalog.queued)
590
+ ? '已物理删除并从父会话日志根除该子智能体,侧边栏残留行不会再被重建。'
591
+ : (virtual
592
+ ? '已清理虚拟委派条目(会话目录 / 缓存 / 归档标记),并从列表隐藏。'
593
+ : '已物理删除会话数据,并清理 catalog 引用与工作区记账。'),
594
+ report,
595
+ };
596
+ }
597
+
598
+ /**
599
+ * 物理删除 handler。/purge 是正式入口,/archive 保留为旧 client 的兼容别名。
600
+ * client 传 {sessionId, rootSessionId, stopActivity}。
601
+ */
602
+ export function makeDeleteHandler(ctx) {
603
+ return async function deleteHandler(req, res) {
604
+ if (!guard(req, res, ctx)) return;
605
+ const payload = await parsePayload(req, res, ['POST']);
606
+ if (payload === null) return;
607
+ const sessionId = payload.sessionId;
608
+ const rootSessionId = typeof payload.rootSessionId === 'string' ? payload.rootSessionId : '';
609
+ const stopActivity = payload.stopActivity === true;
610
+ if (!isSafeSessionId(sessionId)) {
611
+ respond(res, 200, { ok: false, error: 'invalid-session-id', message: '会话 ID 不合法。' });
612
+ return;
613
+ }
614
+ if (rootSessionId && !isSafeSessionId(rootSessionId)) {
615
+ respond(res, 200, { ok: false, error: 'invalid-root-session-id', message: '父会话 ID 不合法。' });
616
+ return;
617
+ }
618
+ try {
619
+ const result = await performPurge(ctx, sessionId, rootSessionId, stopActivity);
620
+ respond(res, 200, result);
621
+ } catch (error) {
622
+ respond(res, 200, {
623
+ ok: false,
624
+ error: 'purge-failed',
625
+ message: String((error && error.message) || error),
626
+ });
627
+ }
628
+ };
629
+ }
630
+
631
+ /** 从「日志真相源」列出父会话里仍会生成目录行的 catalog 事件(供一键清理 / 模型工具)。 */
632
+ export function makeCatalogHandler(ctx) {
633
+ return async function catalogHandler(req, res) {
634
+ if (!guard(req, res, ctx)) return;
635
+ const payload = await parsePayload(req, res, ['POST']);
636
+ if (payload === null) return;
637
+ const rootSessionId = typeof payload.rootSessionId === 'string' ? payload.rootSessionId : '';
638
+ if (rootSessionId && !isSafeSessionId(rootSessionId)) {
639
+ respond(res, 200, { ok: false, error: 'invalid-root-session-id', message: '父会话 ID 不合法。' });
640
+ return;
641
+ }
642
+ const dshHome = resolveDshHome(ctx);
643
+ const rows = dshHome && rootSessionId ? readCatalogEvents(dshHome, rootSessionId) : [];
644
+ respond(res, 200, { ok: true, rootSessionId, rows, count: rows.length });
645
+ };
646
+ }
647
+
648
+ /** 清理残留行:把父会话里「子会话目录已不在、catalog 行还在」的条目全部原位退休。 */
649
+ export function makeScrubHandler(ctx) {
650
+ return async function scrubHandler(req, res) {
651
+ if (!guard(req, res, ctx)) return;
652
+ const payload = await parsePayload(req, res, ['POST']);
653
+ if (payload === null) return;
654
+ const rootSessionId = typeof payload.rootSessionId === 'string' ? payload.rootSessionId : '';
655
+ const everywhere = payload.everywhere === true;
656
+ if (!everywhere && !isSafeSessionId(rootSessionId)) {
657
+ respond(res, 200, { ok: false, error: 'invalid-root-session-id', message: '父会话 ID 不合法。' });
658
+ return;
659
+ }
660
+ try {
661
+ const dshHome = resolveDshHome(ctx);
662
+ const result = dshHome ? scrubCatalogRows(dshHome, rootSessionId, everywhere) : { removed: 0, files: [] };
663
+ if (dshHome && rootSessionId) dropProjectionCache(dshHome, rootSessionId);
664
+ respond(res, 200, {
665
+ ok: true,
666
+ rootSessionId,
667
+ everywhere,
668
+ removed: result.removed || 0,
669
+ files: result.files || [],
670
+ message: result.removed > 0
671
+ ? '已根除 ' + result.removed + ' 条残留目录行,侧边栏同步刷新后不会再出现。'
672
+ : '没有发现需要清理的残留行。',
673
+ });
674
+ } catch (error) {
675
+ respond(res, 200, { ok: false, error: 'scrub-failed', message: String((error && error.message) || error) });
676
+ }
677
+ };
678
+ }
679
+
680
+ /** 一键清理:对指定 ids(缺省 = 父会话 catalog 全部条目)逐个执行完整物理删除。 */
681
+ /**
682
+ * 批量清理核心(HTTP /cleanup 与模型工具共用):
683
+ * 逐个做物理删除但跳过日志改写(skipCatalogRetire),最后对齐上游「一键清理只
684
+ * 重写一次日志」—— 一次全库改写退休所有命中 id,避免 N 次全量解压。
685
+ */
686
+ export async function runCleanup(ctx, ids, rootSessionId, stopActivity) {
687
+ const dshHome = resolveDshHome(ctx);
688
+ const results = [];
689
+ for (const id of ids) {
690
+ try { results.push(await performPurge(ctx, id, rootSessionId, stopActivity, { skipCatalogRetire: true })); }
691
+ catch (error) { results.push({ ok: false, sessionId: id, error: String((error && error.message) || error) }); }
692
+ }
693
+ // 一次改写:把所有成功删除的 id 的 catalog 行一起原位退休。
694
+ let catalog = { removed: 0, failed: [], files: [] };
695
+ const okIds = results.filter((r) => r.ok).map((r) => r.sessionId);
696
+ if (dshHome && okIds.length > 0) {
697
+ try {
698
+ catalog = retireCatalogRowsEverywhere(dshHome, okIds, rootSessionId);
699
+ for (const f of catalog.files || []) dropProjectionCache(dshHome, f.parentId);
700
+ if (rootSessionId) dropProjectionCache(dshHome, rootSessionId);
701
+ if (catalog.failed && catalog.failed.length > 0) {
702
+ const pp = pendingFilePath(dshHome);
703
+ for (const f of catalog.failed) {
704
+ for (const id of okIds) {
705
+ try { queueCatalogRetire(pp, f.parentId, coreId(id)); } catch (e2) { /* 排队尽力而为 */ }
706
+ }
707
+ }
708
+ }
709
+ } catch (error) {
710
+ catalog = { removed: 0, failed: [{ parentId: rootSessionId, reason: String((error && error.message) || error) }], files: [] };
711
+ }
712
+ }
713
+ return {
714
+ ok: true,
715
+ rootSessionId,
716
+ requested: ids.length,
717
+ succeeded: results.filter((r) => r.ok).length,
718
+ catalogRemoved: catalog.removed || 0,
719
+ results,
720
+ message: '已清理 ' + results.filter((r) => r.ok).length + '/' + ids.length
721
+ + ' 个子智能体,' + (catalog.removed || 0) + ' 条残留目录行已从日志根除。',
722
+ };
723
+ }
724
+
725
+ export function makeCleanupHandler(ctx) {
726
+ return async function cleanupHandler(req, res) {
727
+ if (!guard(req, res, ctx)) return;
728
+ const payload = await parsePayload(req, res, ['POST']);
729
+ if (payload === null) return;
730
+ const rootSessionId = typeof payload.rootSessionId === 'string' ? payload.rootSessionId : '';
731
+ if (!isSafeSessionId(rootSessionId)) {
732
+ respond(res, 200, { ok: false, error: 'invalid-root-session-id', message: '父会话 ID 不合法。' });
733
+ return;
734
+ }
735
+ const dshHome = resolveDshHome(ctx);
736
+ let ids = Array.isArray(payload.ids) ? payload.ids.filter(isSafeSessionId) : [];
737
+ if (ids.length === 0 && dshHome) {
738
+ ids = readCatalogEvents(dshHome, rootSessionId).map((r) => r.childId);
739
+ }
740
+ const result = await runCleanup(ctx, ids, rootSessionId, payload.stopActivity === true);
741
+ respond(res, 200, result);
742
+ };
743
+ }
744
+
745
+ // ───────────────── 模型工具(对应「方法一」:让 Agent 一句话清理) ─────────────────
746
+ // 说明:用户建议的 mnemon_forget 删的是 Mnemon 记忆洞察(insight),不是子智能体
747
+ // 会话 / catalog 行,按字面调用不对症。其真实意图「让 Agent 也能触发清理」由这里
748
+ // 注册的模型工具承接:列清单 → 按标签 / 空闲状态筛选 → 逐个走 performPurge
749
+ // (物理删除 + 日志原位退休),与网页端「一键清理」共用同一套逻辑。
750
+ //
751
+ // 用纯对象注册而非 import defineTool('@deepseek-ai/dsh-tools'):该包是宿主提供的
752
+ // peerDependency,在本 profile 解析为 MODULE_NOT_FOUND。静态 bundle 插件拿到的是
753
+ // 真实 ToolsService,register(纯对象) 完全可用(仅校验 output.render / output.schema
754
+ // / timeoutMs / name,不校验 parameters 与 execute 的类型品牌)。
755
+ // 最稳的 output.schema:{type:'object', additionalProperties:true}。
756
+
757
+ function renderJson(_args, value) {
758
+ return [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }];
759
+ }
760
+ const OUTPUT_SCHEMA = { type: 'object', additionalProperties: true };
761
+
762
+ function callerSessionId(exec, args) {
763
+ const explicit = args && typeof args.root_session_id === 'string' ? args.root_session_id : '';
764
+ if (explicit) return explicit;
765
+ const id = exec && exec.agent && typeof exec.agent.id === 'string' ? exec.agent.id : '';
766
+ return id;
767
+ }
768
+
769
+ /** 尽力判断某个子智能体是否仍在运行(用于「只清理空闲」)。取不到就当未运行。 */
770
+ function isRunningSubagent(ctx, childId) {
771
+ try {
772
+ const agents = typeof ctx.get === 'function' ? ctx.get('agents') : undefined;
773
+ if (!agents || typeof agents.get !== 'function') return false;
774
+ for (const v of spellings(childId)) {
775
+ const a = agents.get(v);
776
+ if (a && a.status === 'running') return true;
777
+ }
778
+ } catch (error) { /* 服务不可用时按未运行处理 */ }
779
+ return false;
780
+ }
781
+
782
+ /** 工具:列出当前会话目录里仍会生成行的子智能体(日志是真相源)。 */
783
+ function makeListTool(ctx) {
784
+ return {
785
+ name: 'yanyan_list_subagents',
786
+ description: '列出当前会话(或指定 root_session_id)的子智能体目录行,来源为父会话日志,'
787
+ + '比会被重建的投影缓存可靠。可按标签子串过滤,并标注是否正在运行。'
788
+ + '用返回的 child_id 传给 yanyan_delete_subagent 或 yanyan_cleanup_subagents。',
789
+ parameters: {
790
+ type: 'object',
791
+ properties: {
792
+ root_session_id: { type: 'string', description: '父会话 id;省略时取调用者会话。' },
793
+ label_contains: { type: 'string', description: '只保留标签 / 名称包含该子串的行(不区分大小写)。' },
794
+ },
795
+ },
796
+ output: { schema: OUTPUT_SCHEMA, render: renderJson },
797
+ async execute(args, exec) {
798
+ const root = callerSessionId(exec, args);
799
+ const dshHome = resolveDshHome(ctx);
800
+ let rows = dshHome && root ? readCatalogEvents(dshHome, root) : [];
801
+ const needle = args && typeof args.label_contains === 'string' ? args.label_contains.toLowerCase() : '';
802
+ rows = rows.map((r) => ({
803
+ child_id: r.childId, label: r.label, name: r.name, mode: r.mode,
804
+ created_at: r.createdAt, seq: r.seq, running: isRunningSubagent(ctx, r.childId),
805
+ }));
806
+ if (needle) rows = rows.filter((r) => (r.label + ' ' + r.name).toLowerCase().includes(needle));
807
+ return { root_session_id: root, rows, count: rows.length };
808
+ },
809
+ };
810
+ }
811
+
812
+ /** 工具:物理删除单个子智能体(含 append-only catalog 根治)。 */
813
+ function makeDeleteTool(ctx) {
814
+ return {
815
+ name: 'yanyan_delete_subagent',
816
+ description: '彻底删除一个子智能体:停止运行(可选)、删除会话数据 / 缓存 / 工作区记账 / 归档标记,'
817
+ + '并把父会话日志里的 subagent/catalog 行原位退休,使侧边栏残留行不再被重建。不可逆。',
818
+ parameters: {
819
+ type: 'object',
820
+ properties: {
821
+ subagent_id: { type: 'string', description: '子智能体 id(裸 id 或 session- 前缀,来自 yanyan_list_subagents)。' },
822
+ root_session_id: { type: 'string', description: '父会话 id;省略时取调用者会话。' },
823
+ stop_activity: { type: 'boolean', description: '先停止正在运行的该子智能体。' },
824
+ },
825
+ required: ['subagent_id'],
826
+ },
827
+ output: { schema: OUTPUT_SCHEMA, render: renderJson },
828
+ async execute(args, exec) {
829
+ const id = args && args.subagent_id;
830
+ if (typeof id !== 'string' || !isSafeSessionId(id)) {
831
+ return { ok: false, error: 'invalid-subagent-id', message: 'subagent_id 不合法。' };
832
+ }
833
+ const root = callerSessionId(exec, args);
834
+ return performPurge(ctx, id, root, args && args.stop_activity === true);
835
+ },
836
+ };
837
+ }
838
+
839
+ /** 工具:一句话批量清理(对应「列出所有空闲的 Mnemon idle checkpoint review 并删掉」)。 */
840
+ function makeCleanupTool(ctx) {
841
+ return {
842
+ name: 'yanyan_cleanup_subagents',
843
+ description: '批量清理当前会话的子智能体。不传 label_contains 时清理全部目录行;'
844
+ + '传 label_contains(如 "Mnemon idle checkpoint review")只清理匹配的;'
845
+ + 'idle_only 为 true 时跳过正在运行的;dry_run 为 true 时只返回将要清理的清单而不删除。'
846
+ + '每个目标都走完整物理删除 + 日志原位退休。',
847
+ parameters: {
848
+ type: 'object',
849
+ properties: {
850
+ root_session_id: { type: 'string', description: '父会话 id;省略时取调用者会话。' },
851
+ label_contains: { type: 'string', description: '只清理标签 / 名称包含该子串的行。' },
852
+ idle_only: { type: 'boolean', description: '只清理未在运行的子智能体。' },
853
+ dry_run: { type: 'boolean', description: '只列清单,不执行删除。' },
854
+ limit: { type: 'integer', description: '最多清理的条数(默认全部)。' },
855
+ },
856
+ },
857
+ output: { schema: OUTPUT_SCHEMA, render: renderJson },
858
+ async execute(args, exec) {
859
+ const a = args && typeof args === 'object' ? args : {};
860
+ const root = callerSessionId(exec, a);
861
+ const dshHome = resolveDshHome(ctx);
862
+ let rows = dshHome && root ? readCatalogEvents(dshHome, root) : [];
863
+ const needle = typeof a.label_contains === 'string' ? a.label_contains.toLowerCase() : '';
864
+ if (needle) rows = rows.filter((r) => (r.label + ' ' + r.name).toLowerCase().includes(needle));
865
+ if (a.idle_only === true) rows = rows.filter((r) => !isRunningSubagent(ctx, r.childId));
866
+ const limit = Number.isFinite(a.limit) && a.limit > 0 ? Math.floor(a.limit) : rows.length;
867
+ const targets = rows.slice(0, limit).map((r) => r.childId);
868
+ if (a.dry_run === true) {
869
+ return { root_session_id: root, dry_run: true, matched: targets.length, targets };
870
+ }
871
+ const result = await runCleanup(ctx, targets, root, false);
872
+ return {
873
+ root_session_id: root,
874
+ matched: targets.length,
875
+ succeeded: result.succeeded,
876
+ catalog_removed: result.catalogRemoved,
877
+ results: result.results,
878
+ };
879
+ },
880
+ };
881
+ }
882
+
883
+ /** tools 是可选注入:没有该服务时静默降级,绝不让 8 条 HTTP 路由一起加载失败。 */
884
+ function registerTools(ctx) {
885
+ const definitions = [makeListTool(ctx), makeDeleteTool(ctx), makeCleanupTool(ctx)];
886
+ const attach = (tools) => {
887
+ if (!tools || typeof tools.register !== 'function') return;
888
+ for (const def of definitions) tools.register(def);
889
+ };
890
+ try {
891
+ if (typeof ctx.inject === 'function') {
892
+ ctx.inject(['tools'], (sub) => {
893
+ try { attach(sub.tools); } catch (error) { /* 工具注册失败只降级 */ }
894
+ });
895
+ } else {
896
+ attach(typeof ctx.get === 'function' ? ctx.get('tools') : undefined);
897
+ }
898
+ } catch (error) {
899
+ // 可选能力:注册失败不影响 HTTP 路由。
900
+ }
901
+ }
902
+
903
+ /** 尽力采集当前 live 会话 id,供启动重放跳过仍在活动的父会话。取不到返回空集合。 */
904
+ function collectLiveIds(ctx) {
905
+ const live = new Set();
906
+ try {
907
+ const agents = typeof ctx.get === 'function' ? ctx.get('agents') : undefined;
908
+ if (agents) {
909
+ if (typeof agents.keys === 'function') {
910
+ for (const k of agents.keys()) live.add(String(k));
911
+ } else if (agents.sessions && typeof agents.sessions === 'object') {
912
+ for (const k of Object.keys(agents.sessions)) live.add(k);
913
+ }
914
+ }
915
+ } catch (error) { /* 尽力而为 */ }
916
+ return live;
917
+ }
918
+
919
+ export function apply(ctx, config) {
920
+ const cfg = normalizeConfig(config);
921
+ const deleteHandler = makeDeleteHandler(ctx);
922
+ const routes = [
923
+ { path: HEALTH_ROUTE, handler: makeHealthHandler(ctx) },
924
+ { path: CONFIG_ROUTE, handler: makeConfigHandler(ctx, cfg) },
925
+ { path: LIST_ROUTE, handler: makeListHandler(ctx) },
926
+ { path: CATALOG_ROUTE, handler: makeCatalogHandler(ctx) },
927
+ { path: ARCHIVE_ROUTE, handler: deleteHandler },
928
+ { path: PURGE_ROUTE, handler: deleteHandler },
929
+ { path: SCRUB_ROUTE, handler: makeScrubHandler(ctx) },
930
+ { path: CLEANUP_ROUTE, handler: makeCleanupHandler(ctx) },
931
+ ];
932
+ // register 的返回值直接作为 effect 清理函数,保证禁用 / HMR 时路由自动撤销。
933
+ for (const route of routes) {
934
+ ctx.effect(
935
+ () => ctx.webServer.register({ kind: 'exact', path: route.path, handler: route.handler }),
936
+ `yanyan-subagent: route ${route.path}`,
937
+ );
938
+ }
939
+ // 可选模型工具(方法一:让 Agent 一句话清理空闲 Mnemon review 子代理)。
940
+ registerTools(ctx);
941
+ // 启动时重放上次失败的日志退休(并发写 / 解码错留下的 pending)。
942
+ try {
943
+ const dshHome = resolveDshHome(ctx);
944
+ if (dshHome) flushPendingRewrites(dshHome, collectLiveIds(ctx));
945
+ } catch (error) { /* 重放失败不影响本次运行 */ }
946
+ try {
947
+ ctx.logger?.info?.('[dsh-plugin-yanyan-subagent] host routes ready (v' + VERSION + ')');
948
+ } catch (error) {
949
+ // logger 不可用时不影响路由注册。
950
+ }
951
+ }