dsh-plugin-yanyan-toolkit 1.2.0 → 1.4.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 +55 -0
  2. package/README.en.md +82 -0
  3. package/README.i18n.yaml +7 -6
  4. package/README.md +53 -37
  5. package/README.zh.md +24 -8
  6. package/cordis.patch.yml +10 -2
  7. package/icon.svg +5 -8
  8. package/lib/client.js +179 -4
  9. package/lib/index.js +120 -15
  10. package/locale/en.json +1 -1
  11. package/locale/zh.json +1 -1
  12. package/package.json +5 -2
  13. package/packages/dsh-plugin-yanyan-icon/README.md +33 -0
  14. package/packages/dsh-plugin-yanyan-icon/README.zh.md +33 -0
  15. package/packages/dsh-plugin-yanyan-icon/cordis.patch.yml +7 -0
  16. package/packages/dsh-plugin-yanyan-icon/icon.svg +7 -0
  17. package/packages/dsh-plugin-yanyan-icon/lib/client.js +388 -0
  18. package/packages/dsh-plugin-yanyan-icon/lib/index.js +237 -0
  19. package/packages/dsh-plugin-yanyan-icon/locale/en.json +6 -0
  20. package/packages/dsh-plugin-yanyan-icon/locale/zh.json +6 -0
  21. package/packages/dsh-plugin-yanyan-icon/package.json +32 -0
  22. package/packages/dsh-plugin-yanyan-subagent/README.md +3 -2
  23. package/packages/dsh-plugin-yanyan-subagent/README.zh.md +3 -2
  24. package/packages/dsh-plugin-yanyan-subagent/lib/client.js +157 -33
  25. package/packages/dsh-plugin-yanyan-subagent/lib/index.js +45 -4
  26. package/packages/dsh-plugin-yanyan-subagent/lib/legacy-hidden-ids.json +29 -9
  27. package/packages/dsh-plugin-yanyan-subagent/lib/log-rewrite.js +29 -0
  28. package/packages/dsh-plugin-yanyan-subagent/locale/en.json +1 -1
  29. package/packages/dsh-plugin-yanyan-subagent/locale/zh.json +1 -1
  30. package/packages/dsh-plugin-yanyan-subagent/package.json +2 -2
  31. package/packages/dsh-plugin-yanyan-update/README.md +1 -0
  32. package/packages/dsh-plugin-yanyan-update/README.zh.md +1 -0
  33. package/packages/dsh-plugin-yanyan-update/lib/index.js +86 -2
  34. package/packages/dsh-plugin-yanyan-update/package.json +1 -1
@@ -8,7 +8,7 @@ The third plugin in the "Yanyan Toolkit" aggregate bundle. It lists and manages
8
8
  - Clicking the tab opens an overlay. The overlay is mounted through `ReactDOM.createPortal` into `document.body` so the header's overflow clipping cannot cut it off; when `react-dom` is unavailable it falls back to appending a plain DOM overlay to `document.body`.
9
9
  - Lists every subagent of the current session with name, status (running / idle), mode and an estimated idle duration.
10
10
  - A trailing delete button (U+2715 MULTIPLICATION X, a plain Unicode math symbol, not an emoji) deletes one subagent after a confirmation. Running subagents are stopped before deletion. The root session (lead) is never deleted.
11
- - The bottom "Clean All Idle" button batch-deletes idle subagents over the idle threshold and reports the count.
11
+ - The bottom "Clean All Idle" button immediately batch-deletes every entry shown as idle (anything not running), regardless of the idle threshold (the threshold only applies to timed auto-clean); its criteria match the list and the host /cleanup route, and it reports the count (or a "nothing to clean" hint).
12
12
  - The "Settings" section enables "Auto-clean idle subagents" (off by default), with "Idle over (minutes)" and "Check interval (minutes)". When enabled, each check only deletes idle subagents that reached the threshold; a failed list read is fail-closed (nothing is deleted).
13
13
 
14
14
  ## Delete semantics (physical deletion, irreversible)
@@ -20,6 +20,7 @@ Deleting **really removes the session data** and erases the catalog row at its s
20
20
  3. mnemon virtual delegations (ids like `mnemon-run-v1-<uuid>`, not real `session-<uuid>` sessions) go straight to layer 2 — calling `archiveSession` on such an id reports `session/not-found`.
21
21
  4. **Catalog root-cause fix (v1.2.0)**: `subagentCatalog` is an **append-only projection** of `subagent/catalog` events in the parent session log (`session.v4.jsonl.zstd`); the official `@deepseek-ai/dsh-chunked-list` has no delete API, so clearing caches or archiving can never remove the source reference and the host rebuilds the row on the next `refreshProjections`. Borrowing the in-place "retirement" idea from `huajiao1998/dsh-plugin-subagent-clean@0.3.0` (MIT, originally `heiheiha798/dsh-plugin-subagent-delete@0.2.3`), `/purge` rewrites the matching `subagent/catalog` event **in place** into `{"type":"subagent-delete/retired","seq":<same>,"time":<same>,"ignorable":true,"data":{"childId","retiredAt"}}`. The `seq`/`time` are preserved (log seq continuity and compaction references stay valid), only `type === "subagent/catalog"` produces directory rows, so the projection never recreates the row. Log decoding/encoding uses `node:zlib` zstd (Node >= 22.15 / 24), with two `stat` checks around an atomic rename for concurrency safety; a failed rewrite is queued to `yanyan-subagent-pending.json` and replayed on next host start.
22
22
  5. **Fallback hidden list**: the client still keeps a persistent hidden list in `localStorage['yanyan-subagent.deletedIds']` (core ids with the `session-` prefix stripped). It filters during rendering, batch cleaning, auto-cleaning and the official list row-tail action, so even a concurrent-write / decode failure cannot make a deleted row reappear.
23
+ 6. **In-memory projection root fix (v1.2.1)**: the official "N subagents" popover (`SubagentHeaderLineage`) reads only the client in-memory store `ctx.sessions.list.getSnapshot().projectionsBySession[*].values.subagentCatalog`; the host reuses the in-memory fold state for a loaded live parent session, so rewriting the on-disk zstd log / deleting projcache never triggers a recompute — already-retired residual rows keep showing and clicking one reports `subagent is unavailable (subagent/not-found)`. To fix this, the host adds `collectRetiredIds` to scan the whole library for `subagent-delete/retired` log rows and ships `hiddenIds` via `/config` (compatibility bridge: an extended `legacy-hidden-ids.json` makes the old host process deliver them immediately); the client's `patchProjections` subscribes to `sessions.list` and, using the host's own `list.set()`, continuously removes hidden childIds from every `projectionsBySession[*].values.subagentCatalog` (idempotent, no self-loop), so the official popover's `directCount` drops to zero and hides itself. This is the real root-cause layer behind "never able to delete".
23
24
 
24
25
  **This operation is irreversible**: a confirmation is required; running subagents are stopped first. The root session (lead) is never deleted.
25
26
 
@@ -69,4 +70,4 @@ The plugin makes a best-effort attempt with a `MutationObserver`: when the offic
69
70
  node --test test/*.test.mjs
70
71
  ```
71
72
 
72
- Pure unit tests cover `normalizeConfig`, `parseCatalog`, `idleFilter` and deletion id validation; no DSH service is contacted.
73
+ Pure unit tests cover `normalizeConfig`, `parseCatalog`, `idleFilter`, deletion id validation, in-place log retirement, `collectRetiredIds` / `collectHiddenIds` and `patchProjections` in-memory projection filtering; no DSH service is contacted (68/68 tree-wide tests pass).
@@ -8,7 +8,7 @@
8
8
  - 点击标签打开浮层(浮层通过 `ReactDOM.createPortal` 挂到 `document.body`,避免被顶部操作区的溢出裁剪裁掉;`react-dom` 不可用时降级为把浮层直接追加到 `document.body` 并用原生 DOM 渲染)。
9
9
  - 列出当前会话的所有子智能体,显示名称、状态(运行中 / 空闲)、模式以及估算的空闲时长。
10
10
  - 行尾的删除按钮(U+2715 乘号,普通 Unicode 数学符号,非 emoji)可逐个删除,删除前二次确认;正在运行的子智能体删除时会先停止。根会话(lead)本身永不删除。
11
- - 底部「清理全部空闲」按空闲阈值批量删除空闲子智能体,并显示清理数量。
11
+ - 底部「清理全部空闲」立即清理当前列表里所有显示为空闲(非运行中)的子智能体,并显示清理数量;这是手动操作,**不受「空闲阈值」限制**(阈值只用于定时自动清理)。判定与列表「空闲」标签、host `/cleanup` 语义一致:排除运行中、诊断行、根会话与已在隐藏清单中的条目,不再出现「已清理 0 个」却清不掉条目的情况。
12
12
  - 「设置」区可开启「自动清理空闲子智能体」(默认关闭),设置「空闲超过(分钟)」与「巡检间隔(分钟)」。开启后按间隔巡检,只清理达到阈值且处于空闲的子智能体;读取列表数据失败时保守不删(fail-closed)。
13
13
 
14
14
  ## 删除语义(物理删除,不可逆)
@@ -20,6 +20,7 @@
20
20
  3. 对 mnemon 虚拟委派(id 形如 `mnemon-run-v1-<uuid>`,不是真实 `session-<uuid>` 会话)直接走第 2 层——这类 id 调用 `archiveSession` 会报 `session/not-found`。
21
21
  4. **catalog 根治(v1.2.0)**:`subagentCatalog` 是父会话日志 `session.v4.jsonl.zstd` 里 `subagent/catalog` 事件的 **append-only 投影**(官方 `@deepseek-ai/dsh-chunked-list` 只追加、无删除 API)。因此清缓存、归档、删目录都移除不了底层日志引用,宿主下次 `refreshProjections` 会把条目重建回来。借鉴 `huajiao1998/dsh-plugin-subagent-clean@0.3.0` 的「原位退休」思路(MIT,原始实现 `heiheiha798/dsh-plugin-subagent-delete@0.2.3`),`/purge` 把命中的 `subagent/catalog` 事件**原位改写**为 `{"type":"subagent-delete/retired","seq":<原值>,"time":<原值>,"ignorable":true,"data":{"childId","retiredAt"}}`:`seq`/`time` 原样保留(日志 seq 连续性与压缩事件引用不受影响),而目录投影只在 `type === "subagent/catalog"` 时生成行,于是投影再也不会重建该行。日志解码/编码用 `node:zlib` 的 zstd(Node >= 22.15 / 24),原子 rename 前后各做一次 stat 并发校验;改写失败会排队到 `yanyan-subagent-pending.json`,宿主下次启动补做。
22
22
  5. **隐藏清单兜底**:client 侧仍保留持久化隐藏清单 `localStorage['yanyan-subagent.deletedIds']`(存去 `session-` 前缀后的核心 id),在渲染、批量清理、自动清理与官方列表行尾按钮里持续过滤。即便日志改写遇到并发写入/解码失败,已删条目也不会重新出现。
23
+ 6. **内存投影根治(v1.2.1)**:官方「N 个子智能体」浮层(`SubagentHeaderLineage`)只读 client 内存 store `ctx.sessions.list.getSnapshot().projectionsBySession[*].values.subagentCatalog`;宿主对已加载的 live 父会话复用内存 fold state,单纯改磁盘 zstd 日志 / 删 projcache 都不会让它重算,于是磁盘已退休的残留行仍会显示、点进去报 `subagent is unavailable (subagent/not-found)`。为此 host 新增 `collectRetiredIds` 扫全库 `subagent-delete/retired` 日志行,经 `/config` 下发 `hiddenIds`(兼容桥:扩充 `legacy-hidden-ids.json` 让旧 host 进程即时下发);client 的 `patchProjections` 订阅 `sessions.list`,用宿主自身的 `list.set()` 把隐藏清单里的 childId 从所有 `projectionsBySession[*].values.subagentCatalog` 中持续剔除(幂等收敛、不自激),官方浮层 `directCount` 归零后自动隐藏。这是「一直删不掉」的真正根因层。
23
24
 
24
25
  **该操作不可逆**,删除前会二次确认;正在运行的子智能体删除时先停止。根会话(lead)本身永不删除。
25
26
 
@@ -69,4 +70,4 @@ profile 提供 `tools` 服务时注册三个纯对象工具,让 Agent 一句
69
70
  node --test test/*.test.mjs
70
71
  ```
71
72
 
72
- 纯单元测试覆盖 `normalizeConfig`、`parseCatalog`、`idleFilter` 与删除 id 安全校验,不连接任何 DSH 服务。
73
+ 纯单元测试覆盖 `normalizeConfig`、`parseCatalog`、`idleFilter`、删除 id 安全校验、日志原位退休、`collectRetiredIds` / `collectHiddenIds` 与 `patchProjections` 内存投影剔除,不连接任何 DSH 服务(全树 68/68 通过)。
@@ -57,6 +57,8 @@
57
57
  cleanIdle: '清理全部空闲',
58
58
  cleaning: '清理中…',
59
59
  cleanDone: '已清理 {count} 个空闲子智能体',
60
+ cleanNoIdle: '当前没有可清理的空闲子智能体',
61
+ manualCleanHint: '手动清理不受空闲阈值限制;阈值仅用于定时自动清理',
60
62
  failed: '失败',
61
63
  close: '关闭',
62
64
  rename: '重命名标签',
@@ -102,6 +104,8 @@
102
104
  cleanIdle: 'Clean All Idle',
103
105
  cleaning: 'Cleaning...',
104
106
  cleanDone: 'Cleaned {count} idle subagents',
107
+ cleanNoIdle: 'No idle subagents to clean',
108
+ manualCleanHint: 'Manual clean ignores the idle threshold; the threshold applies to auto-clean only',
105
109
  failed: 'Failed',
106
110
  close: 'Close',
107
111
  rename: 'Rename Tab',
@@ -204,6 +208,35 @@
204
208
  return out;
205
209
  }
206
210
 
211
+ // 手动「清理全部空闲」的目标筛选:与 renderRow 的「空闲」标签、host /cleanup
212
+ // 的「清理所有非 running」语义对齐。列表里显示为空闲(非运行中)的条目都应能
213
+ // 被一键清掉,而不是要求 activity==='inactive' 且空闲满 idleMinutes —— 官方
214
+ // catalog 行根本没有 idleSince / lastActiveAt 字段,客户端 firstSeen 又是打开
215
+ // 浮层后才起步,套阈值会让按钮几乎永远「已清理 0 个空闲子智能体」。
216
+ // 排除:非对象、无 id、diagnostic、running、rootId 自身、已在隐藏清单里的 id。
217
+ // 不套 idleMinutes 阈值:阈值只约束定时自动清理(autoCleanTick)。
218
+ function manualCleanTargets(rows, options) {
219
+ var opts = options || {};
220
+ var rootId = opts.rootId;
221
+ var isHidden = typeof opts.isHidden === 'function' ? opts.isHidden : function () { return false; };
222
+ var out = [];
223
+ var list = Array.isArray(rows) ? rows : [];
224
+ for (var i = 0; i < list.length; i++) {
225
+ var row = list[i];
226
+ if (!row || typeof row !== 'object' || Array.isArray(row)) continue;
227
+ if (row.kind === 'diagnostic') continue;
228
+ if (row.activity === 'running') continue;
229
+ // 主键统一用 row.id:parseCatalogLocal 已给每行补齐 id,且与 renderRow /
230
+ // deleteOne / onRowDelete 用同一个主键,避免「选中了却删不掉」。
231
+ var id = row.id;
232
+ if (!id || typeof id !== 'string') continue;
233
+ if (rootId != null && (id === rootId || coreId(id) === coreId(rootId))) continue;
234
+ if (isHidden(id) || (row.childId && isHidden(row.childId))) continue;
235
+ out.push(row);
236
+ }
237
+ return out;
238
+ }
239
+
207
240
  function readLS(key) {
208
241
  try { return root.localStorage.getItem(key); } catch (e) { return null; }
209
242
  }
@@ -261,6 +294,55 @@
261
294
  return out;
262
295
  }
263
296
 
297
+ // 根治「删不掉」:官方「N 个子智能体」浮层 SubagentHeaderLineage 只读
298
+ // client 内存 store `projectionsBySession[*].values.subagentCatalog`。宿主对
299
+ // 已加载的 live 父会话复用内存 fold state,改磁盘日志 / 删 projcache 都不会
300
+ // 让它重算 —— 这正是残留行「磁盘已干净却仍显示」的真正原因。本函数把
301
+ // 持久化隐藏清单(LS_DELETED)同样应用到这份内存投影:命中已删除 id 的
302
+ // catalog 条目直接剔除,并用 list.set 写回同一个 store(宿主的 projectList /
303
+ // publishRetention 自己就用 list.set,是合法写入口)。
304
+ // 幂等收敛:写回后再触发 subscribe 时已无命中,不再 set,不会自激循环;
305
+ // manager 的 projectList 若把旧值重新 set 回来,紧随的 subscribe 会再次剔除。
306
+ function patchProjections(ctx) {
307
+ try {
308
+ var hidden = readDeletedIds();
309
+ if (!hidden.length) return;
310
+ var list = ctx.sessions && ctx.sessions.list;
311
+ if (!list || typeof list.getSnapshot !== 'function' || typeof list.set !== 'function') return;
312
+ var snap = list.getSnapshot();
313
+ var projections = snap && snap.projectionsBySession;
314
+ if (!projections || typeof projections !== 'object') return;
315
+ var merged = null;
316
+ for (var sid in projections) {
317
+ if (!Object.prototype.hasOwnProperty.call(projections, sid)) continue;
318
+ var proj = projections[sid];
319
+ var values = proj && proj.values;
320
+ var catalog = values && values.subagentCatalog;
321
+ if (!Array.isArray(catalog)) continue;
322
+ var kept = [], changed = false;
323
+ for (var i = 0; i < catalog.length; i++) {
324
+ var e = catalog[i];
325
+ var cid = e && coreId(e.id || e.childId);
326
+ if (cid && hidden.indexOf(cid) !== -1) { changed = true; continue; }
327
+ kept.push(e);
328
+ }
329
+ if (!changed) continue;
330
+ if (!merged) {
331
+ merged = {};
332
+ for (var k in projections) {
333
+ if (Object.prototype.hasOwnProperty.call(projections, k)) merged[k] = projections[k];
334
+ }
335
+ }
336
+ // 浅拷贝:保留 state/error 等字段与 values 里其它投影键。
337
+ merged[sid] = Object.assign({}, proj, {
338
+ values: Object.assign({}, values, { subagentCatalog: kept }),
339
+ });
340
+ }
341
+ if (!merged) return; // 本轮无命中,不写回(幂等收敛)
342
+ list.set(Object.assign({}, snap, { projectionsBySession: merged }));
343
+ } catch (e) {}
344
+ }
345
+
264
346
  // 服务懒查:一律走 ctx.get / ctx.reflect.get / ctx[name],不读未 inject 的属性。
265
347
  var rootCtx = null;
266
348
  function lookup(name) {
@@ -476,13 +558,17 @@
476
558
  // 读取 host /config 补齐本地从未存过的缺省值(标签文字等配置可读)。
477
559
  return postJson(ROUTE_BASE + '/config', {})
478
560
  .then(function (data) {
479
- // 一次性迁移清单(旧版归档残留):并入持久化隐藏清单,
480
- // 防止 append-only catalog 重建后这些幽灵条目重新出现。
481
- // 放在 cfg 检查之前,保证即便 /config 只回清单也能生效。
482
- if (data && Array.isArray(data.legacyHiddenIds)) {
483
- for (var k = 0; k < data.legacyHiddenIds.length; k++) {
484
- var lid = data.legacyHiddenIds[k];
485
- if (typeof lid === 'string' && lid) markDeleted(lid);
561
+ // host 下发的隐藏清单:一次性迁移(legacyHiddenIds)+ 全库已退休
562
+ // (hiddenIds,扫全库 zstd 日志里的 subagent-delete/retired 得到)。
563
+ // 并入持久化隐藏清单 LS_DELETED;随后 patchProjections 会据此持续
564
+ // 过滤官方浮层所读的内存投影。放在 cfg 检查之前,保证即便 /config
565
+ // 只回清单也能生效。
566
+ if (data) {
567
+ var hidden = [];
568
+ if (Array.isArray(data.hiddenIds)) hidden = hidden.concat(data.hiddenIds);
569
+ if (Array.isArray(data.legacyHiddenIds)) hidden = hidden.concat(data.legacyHiddenIds);
570
+ for (var k = 0; k < hidden.length; k++) {
571
+ if (typeof hidden[k] === 'string' && hidden[k]) markDeleted(hidden[k]);
486
572
  }
487
573
  }
488
574
  var cfg = data && data.config;
@@ -500,14 +586,6 @@
500
586
  shared.checkIntervalMin = clampInt(cfg.checkIntervalMin, 1, 1440, 1);
501
587
  shared.autoDraft.checkIntervalMin = shared.checkIntervalMin;
502
588
  }
503
- // host 下发的一次性迁移清单(旧版归档残留):并入持久化隐藏清单,
504
- // 防止 append-only catalog 重建后这些幽灵条目重新出现。
505
- if (Array.isArray(data.legacyHiddenIds)) {
506
- for (var k = 0; k < data.legacyHiddenIds.length; k++) {
507
- var lid = data.legacyHiddenIds[k];
508
- if (typeof lid === 'string' && lid) markDeleted(lid);
509
- }
510
- }
511
589
  })
512
590
  .catch(function () {});
513
591
  }
@@ -535,6 +613,9 @@
535
613
  }
536
614
  // 删除已落地:写入持久化隐藏清单,并从当前列表立即移除该行。
537
615
  markDeleted(id);
616
+ // 立即把该 id 从官方浮层所读的内存投影里抹掉(不等 refreshProjections,
617
+ // 因为 live 父会话不会重算磁盘;幂等收敛,见 patchProjections 注释)。
618
+ patchProjections(ctx);
538
619
  shared.rows = filterDeleted(shared.rows);
539
620
  shared.rowStatus[id] = { state: 'deleted' };
540
621
  // host 已把父会话日志里的 catalog 行原位退休(append-only 根治),
@@ -588,13 +669,20 @@
588
669
  }
589
670
 
590
671
  function cleanAllIdle(ctx) {
591
- var targets = idleFilterLocal(filterDeleted(shared.rows), {
672
+ // 手动清理:清掉列表里所有显示为空闲(非运行中)的条目,不套 idleMinutes
673
+ // 阈值(阈值只用于定时自动清理)。见 manualCleanTargets 注释:旧实现要求
674
+ // activity==='inactive' 且空闲 ≥30 分钟,而官方 catalog 无 idleSince 字段、
675
+ // 客户端 firstSeen 刚起步,导致按钮几乎永远「已清理 0 个」。
676
+ var targets = manualCleanTargets(filterDeleted(shared.rows), {
592
677
  rootId: shared.rootId,
593
- idleMinutes: shared.idleMinutes,
594
- now: Date.now(),
595
- idleSince: idleSince,
678
+ isHidden: isDeleted,
596
679
  });
597
- shared.cleanStatus = targets.length ? t('cleaning') : '';
680
+ if (!targets.length) {
681
+ shared.cleanStatus = t('cleanNoIdle');
682
+ renderAll();
683
+ return Promise.resolve();
684
+ }
685
+ shared.cleanStatus = t('cleaning');
598
686
  renderAll();
599
687
  var chain = Promise.resolve();
600
688
  var count = 0;
@@ -809,7 +897,10 @@
809
897
  function updateView(view) {
810
898
  var refs = view.refs;
811
899
  refs.settings.style.display = view.expandedSettings ? '' : 'none';
812
- refs.threshold.textContent = t('idleThreshold') + ' ' + shared.idleMinutes + ' ' + t('minutes');
900
+ // 阈值只约束定时自动清理,手动「清理全部空闲」不受其限制,明确告知用户,
901
+ // 避免误以为「空闲 0 分钟 / 未满阈值」就是清理按钮没反应的原因。
902
+ refs.threshold.textContent = t('idleThreshold') + ' ' + shared.idleMinutes + ' '
903
+ + t('minutes') + ' · ' + t('manualCleanHint');
813
904
  refs.result.textContent = shared.cleanStatus || '';
814
905
  refs.settingsStatus.textContent = shared.settingsStatus || '';
815
906
  // 回填设置输入(骨架不重建,避免覆盖用户正在输入的值时丢焦点)。
@@ -879,7 +970,10 @@
879
970
  }
880
971
 
881
972
  // ───────────────── React 路径(slots + createPortal) ─────────────────
882
- var canReact = !!(React && React.createElement && React.useState && React.useEffect && React.useRef);
973
+ // React.Fragment 是 Fix 1 的前提:用它把 <button> 与 portal 并列成兄弟,
974
+ // 断开「浮层点击 → fiber 上溯到 button → toggle 关闭」的事件链。
975
+ // 缺少 Fragment 时直接走纯 DOM 降级(native overlay),不复发该 bug。
976
+ var canReact = !!(React && React.createElement && React.useState && React.useEffect && React.useRef && React.Fragment);
883
977
  var canPortal = !!(ReactDOM && ReactDOM.createPortal);
884
978
 
885
979
  function PortalHost(props) {
@@ -895,7 +989,21 @@
895
989
  try { if (ref.current) ref.current.innerHTML = ''; } catch (e) {}
896
990
  };
897
991
  }, []);
898
- return React.createElement('div', { ref: ref, className: 'yanyan-portal-host' });
992
+ // 双保险:浮层内所有事件都不再向外冒泡。即便宿主换了 React 版本或把
993
+ // portal 重新挂成 <button> 的后代,合成事件沿 fiber 上溯时也会在这里被
994
+ // 截断,不会触发顶部按钮的 onClick toggle。只用 stopPropagation、绝不
995
+ // preventDefault,输入框聚焦 / 勾选 / 按钮点击等原生行为完全不受影响。
996
+ var swallow = function (ev) {
997
+ if (ev && typeof ev.stopPropagation === 'function') ev.stopPropagation();
998
+ };
999
+ return React.createElement('div', {
1000
+ ref: ref,
1001
+ className: 'yanyan-portal-host',
1002
+ onClick: swallow,
1003
+ onMouseDown: swallow,
1004
+ onPointerDown: swallow,
1005
+ onTouchStart: swallow,
1006
+ });
899
1007
  }
900
1008
 
901
1009
  // ActionButton 组件只创建一次:槽的 render 工厂每次返回新组件类型会反复
@@ -915,18 +1023,26 @@
915
1023
  if (canPortal) setOpen(function (v) { return !v; });
916
1024
  else toggleOverlayNative(ctx);
917
1025
  };
918
- var kids = [React.createElement('span', { key: 't' }, lbl || t('tab'))];
919
- if (open && canPortal) {
920
- kids.push(ReactDOM.createPortal(
921
- React.createElement(PortalHost, { ctx: ctx }), doc.body));
922
- }
923
- return React.createElement('button', {
1026
+ // 关键:portal 必须与 <button> 是「兄弟」,不能是它的 children。
1027
+ // 旧实现把 createPortal(...) 塞进 <button> 的 kids,React 合成事件沿
1028
+ // fiber 树(不是 DOM 树)冒泡时,浮层内任意点击都会上溯到这个 button,
1029
+ // 触发 onClick → setOpen(!v) → 浮层被 toggle 卸载:点「设置」/「重命名」
1030
+ // 输入框「直接退出」,点「清理全部空闲」清完却立刻关闭看不见结果。
1031
+ // 用 Fragment 并列后,portal 的 fiber 父级是 Fragment,链路不再经过 button;
1032
+ // DOM 上浮层依旧 createPortal 到 document.body,位置与样式完全不变。
1033
+ var tabBtn = React.createElement('button', {
924
1034
  'data-dsh-plugin': 'yanyan-subagent',
925
1035
  'data-dsh-part': 'session-tab',
926
1036
  className: 'yanyan-tab-btn',
927
1037
  title: t('title'),
928
1038
  onClick: onClick,
929
- }, kids);
1039
+ }, React.createElement('span', null, lbl || t('tab')));
1040
+ var portal = null;
1041
+ if (open && canPortal) {
1042
+ portal = ReactDOM.createPortal(
1043
+ React.createElement(PortalHost, { ctx: ctx }), doc.body);
1044
+ }
1045
+ return React.createElement(React.Fragment, null, tabBtn, portal);
930
1046
  };
931
1047
  }
932
1048
 
@@ -1152,8 +1268,9 @@
1152
1268
 
1153
1269
  try { injectStyles(ctx); } catch (e) {}
1154
1270
 
1155
- // 设置(异步读取 host /config 补齐缺省值)。
1156
- loadSettings(ctx).then(function () { renderAll(); });
1271
+ // 设置(异步读取 host /config 补齐缺省值 + 全库已退休隐藏清单)。
1272
+ // 隐藏清单到位后立即过滤官方浮层所读的内存投影,首屏就不会再显示幽灵行。
1273
+ loadSettings(ctx).then(function () { patchProjections(ctx); renderAll(); });
1157
1274
 
1158
1275
  // 顶部标签(决定性 Slot)。绝不复用官方 subagent-catalog id;官方 order=10,
1159
1276
  // 用 12 排在旁边。slots 不可用或 React 不可用时降级纯 DOM 按钮。
@@ -1195,10 +1312,17 @@
1195
1312
 
1196
1313
  // 订阅 sessions.list 数据变化,触发重渲染(只重读快照,不主动
1197
1314
  // refreshProjections,避免订阅回调自激循环)。
1315
+ // 同时持续把隐藏清单应用到内存投影:宿主对 live 父会话复用内存
1316
+ // fold state,推送帧 / projectList 随时可能把含残留行的 subagentCatalog
1317
+ // 重新 set 回来,必须在每次快照变化后立刻剔除(幂等收敛,无命中不写回)。
1198
1318
  try {
1199
1319
  var list = ctx.sessions && ctx.sessions.list;
1200
1320
  if (list && typeof list.subscribe === 'function') {
1201
- var sub = list.subscribe(function () { if (!disposed) refresh(ctx, false); });
1321
+ var sub = list.subscribe(function () {
1322
+ if (disposed) return;
1323
+ patchProjections(ctx);
1324
+ refresh(ctx, false);
1325
+ });
1202
1326
  unsubs.push(function () {
1203
1327
  try {
1204
1328
  if (typeof sub === 'function') sub();
@@ -28,10 +28,20 @@ import {
28
28
  readCatalogEvents, allSessionLogPaths, retireCatalogRow,
29
29
  retireCatalogRowsEverywhere, scrubCatalogRows, flushPendingRewrites,
30
30
  pendingFilePath, dropProjectionCache, queueCatalogRetire,
31
+ collectRetiredIds,
31
32
  } from './log-rewrite.js';
32
33
 
33
34
  export const name = 'dsh-plugin-yanyan-subagent';
34
- export const VERSION = '1.2.0';
35
+ // 版本从子包自身 package.json 动态读取,避免 bump 时与硬编码常量失同步
36
+ // (与聚合根包 lib/index.js 的 VERSION 同源策略,readFileSync 已在上方导入)。
37
+ function readOwnVersion() {
38
+ try {
39
+ return String(JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version || '0.0.0');
40
+ } catch (error) {
41
+ return '0.0.0';
42
+ }
43
+ }
44
+ export const VERSION = readOwnVersion();
35
45
 
36
46
  // 顶层硬依赖只保留路由与准入所必需的;模型工具通过可选的 ctx.inject(['tools'])
37
47
  // 注册,没有该服务时静默降级,绝不影响下面这些 HTTP 路由的加载。
@@ -458,12 +468,36 @@ export function legacyHiddenIds() {
458
468
  }
459
469
  }
460
470
 
461
- function makeConfigHandler(ctx, cfg) {
471
+ /**
472
+ * 全库「应被隐藏」清单 = 一次性迁移清单 ∪ 日志里所有已原位退休的 childId。
473
+ *
474
+ * 关键:官方「N 个子智能体」浮层读宿主进程内存里的 live 投影,磁盘日志改写 /
475
+ * 删 projcache 都不会让已加载的父会话重算,残留行因此删不掉。重开 GUI 后,只要
476
+ * 这些 id 仍在日志的 `subagent-delete/retired` 事件里,就必须继续隐藏 —— 故在
477
+ * 启动 /config 时把它们一并发给 client,由 client 持续过滤内存投影。
478
+ * 纯函数(dshHome 显式传参),便于单测。
479
+ */
480
+ export function collectHiddenIds(dshHome) {
481
+ const ids = new Set(legacyHiddenIds());
482
+ if (dshHome) {
483
+ try {
484
+ for (const id of collectRetiredIds(dshHome)) ids.add(coreId(id));
485
+ } catch (error) { /* 单份日志损坏不影响其余清单 */ }
486
+ }
487
+ return [...ids];
488
+ }
489
+
490
+ function makeConfigHandler(ctx, cfg, hiddenIds) {
462
491
  return async function configHandler(req, res) {
463
492
  if (!guard(req, res, ctx)) return;
464
493
  const payload = await parsePayload(req, res, ['POST']);
465
494
  if (payload === null) return;
466
- respond(res, 200, { ok: true, config: cfg, legacyHiddenIds: legacyHiddenIds() });
495
+ respond(res, 200, {
496
+ ok: true,
497
+ config: cfg,
498
+ legacyHiddenIds: legacyHiddenIds(),
499
+ hiddenIds: hiddenIds || [],
500
+ });
467
501
  };
468
502
  }
469
503
 
@@ -919,9 +953,16 @@ function collectLiveIds(ctx) {
919
953
  export function apply(ctx, config) {
920
954
  const cfg = normalizeConfig(config);
921
955
  const deleteHandler = makeDeleteHandler(ctx);
956
+ // 启动时一次性扫全库日志算出「全库已退休清单」,随 /config 下发给 client,
957
+ // 用于持续过滤官方浮层所读的内存投影(根治 live 会话不重算磁盘的问题)。
958
+ let hiddenIds = [];
959
+ try {
960
+ const dshHome = resolveDshHome(ctx);
961
+ hiddenIds = collectHiddenIds(dshHome);
962
+ } catch (error) { /* 不影响路由注册 */ }
922
963
  const routes = [
923
964
  { path: HEALTH_ROUTE, handler: makeHealthHandler(ctx) },
924
- { path: CONFIG_ROUTE, handler: makeConfigHandler(ctx, cfg) },
965
+ { path: CONFIG_ROUTE, handler: makeConfigHandler(ctx, cfg, hiddenIds) },
925
966
  { path: LIST_ROUTE, handler: makeListHandler(ctx) },
926
967
  { path: CATALOG_ROUTE, handler: makeCatalogHandler(ctx) },
927
968
  { path: ARCHIVE_ROUTE, handler: deleteHandler },
@@ -1,9 +1,29 @@
1
- {
2
- "_comment": "一次性迁移:旧版「删除=归档」遗留、已由 scripts/cleanup-ghost-subagents.mjs 从磁盘清除的 mnemon 虚拟委派 id。subagentCatalog 由 append-only 会话日志重建,清缓存后宿主仍可能把它们带回来;host /config 把本清单下发给 client,client 在启动时写入 yanyan-subagent.deletedIds 持久化隐藏清单,保证这些条目不再显示。id 均为去 session- 前缀后的核心 id。前 3 条来自「已归档」残留,最后 1 条是用户报错里的 session/not-found 条目。",
3
- "ids": [
4
- "mnemon-run-v1-47f5a5fc-1659-46da-9b66-8dbdbed10a0f",
5
- "mnemon-run-v1-b59053b1-57bb-4a23-bbd8-972e210c1cb0",
6
- "mnemon-run-v1-8a02e7a3-b67f-44ec-8277-ef733fc6c252",
7
- "mnemon-run-v1-debd95cd-2980-4801-9299-c1da04bc5775"
8
- ]
9
- }
1
+ {
2
+ "_comment": "一次性迁移清单 ∪ 全库日志中已原位退休的 childId;host /config 把本清单作为 legacyHiddenIds 下发给 client,client 写入 yanyan-subagent.deletedIds 持久化隐藏清单。新版 host 另经 hiddenIds 字段下发 collectHiddenIds() 动态全集,本文件作为旧 host 进程兼容桥:legacyHiddenIds() 每次请求从磁盘读本文件,故刷新页面即可即时生效。",
3
+ "ids": [
4
+ "bfde730b-e00c-40db-ac9b-1a243f66c279",
5
+ "d62abba7-468b-4f57-8605-e65118c7835c",
6
+ "mnemon-run-v1-0b3db3e5-b6f0-4d74-82f5-124822a15c53",
7
+ "mnemon-run-v1-28354b4a-4859-4c8e-aebd-e9281f13bde4",
8
+ "mnemon-run-v1-2e7c7461-1d43-4abb-a5f2-71ea29159808",
9
+ "mnemon-run-v1-3681c2e1-10f8-4b69-a270-13a2cbb319ec",
10
+ "mnemon-run-v1-3ca1a5c0-b695-4241-9a19-981fd67524bb",
11
+ "mnemon-run-v1-47f5a5fc-1659-46da-9b66-8dbdbed10a0f",
12
+ "mnemon-run-v1-528b11c1-88e8-4077-963b-d23a93e6aa15",
13
+ "mnemon-run-v1-619748db-5cd2-42b2-a649-212ab7280466",
14
+ "mnemon-run-v1-62cde497-193e-4224-9f72-1b2d9c34efb8",
15
+ "mnemon-run-v1-7006f948-4266-456e-85d3-9a61232f6090",
16
+ "mnemon-run-v1-702601c3-827b-444e-bece-421700b856d2",
17
+ "mnemon-run-v1-7c09b172-06c4-43a1-97ad-3e312c41af80",
18
+ "mnemon-run-v1-8a02e7a3-b67f-44ec-8277-ef733fc6c252",
19
+ "mnemon-run-v1-a3382a0a-8398-4a6c-84e8-545a8374537b",
20
+ "mnemon-run-v1-b0016960-86d9-4c46-a4b3-3ef223f1519e",
21
+ "mnemon-run-v1-b59053b1-57bb-4a23-bbd8-972e210c1cb0",
22
+ "mnemon-run-v1-be28ac2b-5cb7-4fbe-8243-834b2196fdf9",
23
+ "mnemon-run-v1-daf281d9-4f59-4936-81d9-f7d721a4b990",
24
+ "mnemon-run-v1-debd95cd-2980-4801-9299-c1da04bc5775",
25
+ "mnemon-run-v1-ec8be48b-e49f-4970-880d-c9191227e03a",
26
+ "mnemon-run-v1-fd34e0d3-2f28-43e1-a50a-a0b1881d0610",
27
+ "mnemon-run-v1-ffbcb086-9796-425c-a6d8-26ce7d1209ea"
28
+ ]
29
+ }
@@ -380,3 +380,32 @@ export function scrubCatalogRows(dshHome, parentSessionId, everywhere = false) {
380
380
  const result = rewriteCatalogRows(dshHome, parentSessionId, gone);
381
381
  return { files: result.removed > 0 ? [{ parentId: parentSessionId, removed: result.removed }] : [], removed: result.removed, ok: result.ok, reason: result.reason };
382
382
  }
383
+
384
+ /**
385
+ * 扫全库 zstd 会话日志,收集所有已被「原位退休」的 childId(核心 id)。
386
+ *
387
+ * 用途:官方「N 个子智能体」浮层读的是宿主进程内存里的 live 投影 fold state,
388
+ * 改磁盘日志 / 删 projcache 都不会让已经加载的父会话重算,残留行因此「删不掉」。
389
+ * host 把这份「全库已退休清单」经 /config 下发给 client,client 据此持续过滤
390
+ * 内存投影 projectionsBySession,才能真正让官方浮层里的幽灵行消失。
391
+ * 日志是 append-only 真相源:凡 `type === 'subagent-delete/retired'` 的 data.childId
392
+ * 都曾被此插件删除过,重开 GUI 后也必须继续隐藏。
393
+ */
394
+ export function collectRetiredIds(dshHome) {
395
+ const ids = new Set();
396
+ for (const entry of allSessionLogPaths(dshHome)) {
397
+ let text;
398
+ try { text = decodeLog(readFileSync(entry.logPath)); } catch (error) { continue; }
399
+ // 预筛:整份日志里没有退休标记就跳过逐行 JSON.parse,省掉绝大多数开销。
400
+ if (text.indexOf('subagent-delete/retired') === -1) continue;
401
+ for (const line of text.split('\n')) {
402
+ if (line.indexOf('"subagent-delete/retired"') === -1) continue;
403
+ let ev;
404
+ try { ev = JSON.parse(line); } catch (error) { continue; }
405
+ if (ev && ev.type === 'subagent-delete/retired' && ev.data && ev.data.childId) {
406
+ ids.add(logCoreId(ev.data.childId));
407
+ }
408
+ }
409
+ }
410
+ return [...ids];
411
+ }
@@ -1 +1 @@
1
- {"meta":{"title":"Subagent Management","description":"List, delete, and auto-clean subagents"}}
1
+ {"meta":{"title":"Subagent Management","description":"List, permanently delete (log + in-memory projection root fix), and auto-clean subagents"}}
@@ -1 +1 @@
1
- {"meta":{"title":"子智能体管理","description":"查看、逐个删除与自动清理子智能体"}}
1
+ {"meta":{"title":"子智能体管理","description":"查看、彻底删除(日志+内存投影根治)与自动清理"}}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-plugin-yanyan-subagent",
3
- "version": "1.2.0",
4
- "description": "子智能体管理:查看、彻底删除(日志根除)、批量清理空闲与自动清理、清理残留行",
3
+ "version": "1.2.2",
4
+ "description": "子智能体管理:查看、彻底删除(日志根除 + 内存投影根治)、批量清理空闲与自动清理、清理残留行",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "icon": "icon.svg",
@@ -8,6 +8,7 @@ The first plugin of the "Yanyan Toolkit" aggregate bundle. It places a "Check up
8
8
  - Local plugins (`link:` / `file:`): for git repositories, fetch and compare local HEAD with `origin/<branch>`; without `.git`, honestly report "local source, cannot auto-update".
9
9
  - GitHub sources are best-effort; when they cannot be compared they are reported as "update GitHub sources on the plugin detail page" without blocking other checks.
10
10
  - Updatable rows prefer the official `pluginManager` service `updateBundle` (built-in automatic restore on failure/cancel, never removes first); when that API is absent it falls back to an over-install and only uses a two-phase remove+install when `already-installed`, with a hard guarantee of restoring the original version. Local git sources run `git fetch` + `git merge --ff-only`.
11
+ - After a successful update the plugin's enablement is restored (the official `updateBundle` only restores bundle selection and loses the entry-level enablement that the "Installed plugins" toggle reads): `setPluginEnabled` (entry-level) is preferred, falling back to the official-style `setBundleEnabled`; plugins the user deliberately disabled stay disabled, and a failed restore never changes the update result.
11
12
  - This package and the aggregate package are never updated; official `@deepseek-ai/*` packages are skipped.
12
13
 
13
14
  ## Constraints
@@ -8,6 +8,7 @@
8
8
  - 检查本地插件(`link:` / `file:` 目录):若是 git 仓库,fetch 后比对本地 HEAD 与 `origin/<branch>`;无 `.git` 时如实显示「本地源码,无法自动更新」。
9
9
  - GitHub 源尽力而为比对,取不到时显示「GitHub 源请在插件详情页更新」,不阻断其它插件检查。
10
10
  - 可更新行点击「更新」优先复用官方 `pluginManager` 服务的 `updateBundle`(内建失败/取消自动装回原版本,绝不先卸载);无该 API 时降级为覆盖安装、仅在 `already-installed` 时两阶段并硬保证回滚装回原版本,本地 git 源执行 `git fetch` + `git merge --ff-only`。
11
+ - 更新成功后自动把插件启用态放回(官方 `updateBundle` 只恢复 bundle 选择态,会丢失「已安装插件」页开关所依据的 entry 级 enablement):优先 `setPluginEnabled`(entry 级),缺失时回退官方同款 `setBundleEnabled`;用户主动关闭的插件尊重其关闭状态,恢复失败不影响更新结论。
11
12
  - 禁止更新本包与聚合包自身,跳过 `@deepseek-ai/*` 官方包。
12
13
 
13
14
  ## 约束