dsh-session-conductor 1.3.13 → 1.3.17

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 (87) hide show
  1. package/README.md +55 -1
  2. package/cordis.patch.yml +0 -0
  3. package/lib/apply.js +28 -1243
  4. package/lib/client-parts/apply.js +0 -0
  5. package/lib/client-parts/components/compaction.js +0 -0
  6. package/lib/client-parts/components/conductor-settings.js +0 -0
  7. package/lib/client-parts/components/group.js +0 -0
  8. package/lib/client-parts/components/panel.js +0 -0
  9. package/lib/client-parts/components/processing.js +0 -0
  10. package/lib/client-parts/components/session-icon.js +0 -0
  11. package/lib/client-parts/components/settings.js +0 -0
  12. package/lib/client-parts/foundation/bootstrap.js +0 -0
  13. package/lib/client-parts/foundation/i18n.js +0 -0
  14. package/lib/client-parts/foundation/list-cache.js +0 -0
  15. package/lib/client-parts/foundation/styles.js +0 -0
  16. package/lib/core/config.js +0 -0
  17. package/lib/core/domain.js +0 -0
  18. package/lib/core/index-store.js +0 -0
  19. package/lib/core/lazy.js +0 -0
  20. package/lib/core/state.js +0 -0
  21. package/lib/core.js +0 -0
  22. package/lib/eio-repair.js +0 -0
  23. package/lib/features/base-routes.js +156 -0
  24. package/lib/features/context-usage/index.js +0 -0
  25. package/lib/features/continue/eligibility.js +0 -0
  26. package/lib/features/continue/scan.js +0 -0
  27. package/lib/features/continue/session.js +0 -0
  28. package/lib/features/inject/preset.js +0 -0
  29. package/lib/features/member-model/batch.js +97 -0
  30. package/lib/features/member-model/clone-member.js +270 -0
  31. package/lib/features/member-model/command.js +66 -7
  32. package/lib/features/member-model/create-teammate.js +2 -2
  33. package/lib/features/member-model/index.js +17 -1
  34. package/lib/features/member-model/lead.js +73 -0
  35. package/lib/features/member-model/locate.js +82 -1
  36. package/lib/features/member-model/override.js +0 -0
  37. package/lib/features/member-model/permission-command.js +138 -0
  38. package/lib/features/member-model/permission.js +213 -0
  39. package/lib/features/member-model/setup.js +0 -0
  40. package/lib/features/member-model/switch.js +137 -0
  41. package/lib/features/member-model/tools.js +359 -0
  42. package/lib/features/memory-recall/index.js +0 -0
  43. package/lib/features/rename/analysis.js +0 -0
  44. package/lib/features/rename/title.js +0 -0
  45. package/lib/features/rename/value.js +0 -0
  46. package/lib/features/session-action-routes.js +435 -0
  47. package/lib/features/session-messaging/index.js +0 -0
  48. package/lib/features/session-messaging/logic.js +0 -0
  49. package/lib/features/session-messaging/tools.js +0 -0
  50. package/lib/features/session-ops-routes.js +0 -0
  51. package/lib/features/settings-routes.js +411 -0
  52. package/lib/features/trash.js +0 -0
  53. package/lib/features/value-index.js +0 -0
  54. package/lib/features/value-report-routes.js +211 -0
  55. package/lib/group.js +0 -0
  56. package/lib/i18n/en.json +0 -0
  57. package/lib/i18n/zh.json +0 -0
  58. package/lib/index.js +0 -0
  59. package/lib/repair.js +0 -0
  60. package/lib/seq-gap-repair.js +0 -0
  61. package/lib/session-codec.js +0 -0
  62. package/lib/session-log.js +0 -0
  63. package/lib/sessions/detach.js +0 -0
  64. package/lib/sessions/events.js +0 -0
  65. package/lib/sessions/interruption.js +0 -0
  66. package/lib/sessions/list-cache.js +0 -0
  67. package/lib/sessions/list.js +0 -0
  68. package/lib/sessions/lookup.js +0 -0
  69. package/lib/sessions/route.js +0 -0
  70. package/lib/sessions/session-id.js +0 -0
  71. package/lib/sessions/tail-io.js +30 -0
  72. package/lib/sessions/turn.js +0 -0
  73. package/lib/shared/atomic-file.js +0 -0
  74. package/lib/shared/constants.js +0 -0
  75. package/lib/shared/json-store.js +0 -0
  76. package/lib/shared/log.js +0 -0
  77. package/lib/shared/timing.js +0 -0
  78. package/lib/shared/util.js +0 -0
  79. package/lib/template-inject.js +0 -0
  80. package/lib/value.js +0 -0
  81. package/lib/zstd-frames.js +0 -0
  82. package/package.json +1 -1
  83. package/scripts/check-member-model.mjs +0 -0
  84. package/skills/dsh-session-conductor-functions.md +0 -0
  85. package/skills/dsh-session-conductor.md +0 -0
  86. package/skills/dsh-session-official-doc.md +0 -0
  87. package/skills/task-completion-report.md +0 -0
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
package/lib/core/lazy.js CHANGED
File without changes
package/lib/core/state.js CHANGED
File without changes
package/lib/core.js CHANGED
File without changes
package/lib/eio-repair.js CHANGED
File without changes
@@ -0,0 +1,156 @@
1
+ // dsh-session-conductor — 基础路由(语言包 / 会话列表)
2
+ //
3
+ // 【为什么单独成文件】原先内联在 lib/apply.js 的 `ctx.inject(["webServer"])` 回调里(该回调上千行,
4
+ // 审计 readability/max-function-length 拦截)。这里只搬「注册」这部分,handler 体逐字保留。
5
+ //
6
+ // ⚠️ 语言包路径:原先写 `./i18n/zh.json`(相对 lib/apply.js)。本文件位于 lib/features/,
7
+ // 必须改成 `../i18n/...`,否则运行时读不到语言包、页面文案会整块变空。
8
+ import { readFileSync } from 'node:fs';
9
+ import { readJson, send } from '../http.js';
10
+ import { log } from '../shared/log.js';
11
+ import { invalidateSessionListCache } from '../sessions/list-cache.js';
12
+ import { readListIndex } from '../features/session-messaging/index.js';
13
+ import { buildSessionListCached } from '../sessions/list.js';
14
+
15
+ /**
16
+ * 注册基础路由(语言包 + 会话列表)。
17
+ * @param {object} deps
18
+ * @param {object} deps.webServer 宿主 webServer 服务
19
+ * @param {object} deps.ctx 插件 ctx
20
+ * @returns {void}
21
+ */
22
+ export function registerBaseRoutes({ webServer, ctx }) {
23
+ // i18n 语言包:**只读一次并缓存**。原先每个请求都同步读两个 JSON(约 33 KB)并解析,
24
+ // 既阻塞事件循环(审计 io-risk 高风险),又纯属浪费 —— 语言包在一个进程生命周期内不会变。
25
+ let i18nCache = null;
26
+ const loadI18n = () => {
27
+ if (i18nCache !== null) return i18nCache;
28
+ i18nCache = {
29
+ zh: JSON.parse(readFileSync(new URL("../i18n/zh.json", import.meta.url), "utf8")),
30
+ en: JSON.parse(readFileSync(new URL("../i18n/en.json", import.meta.url), "utf8")),
31
+ };
32
+ return i18nCache;
33
+ };
34
+
35
+ // ---------- GET /api/session-conductor/i18n ----------
36
+ // 语言包外置 lib/i18n/{zh,en}.json(单份权威,改文案无需重打包 client bundle):
37
+ // 浏览器端 ModuleLoader 的 require 不支持相对路径 JSON(require("./i18n/*.json") 抛 missed-the-module-table),
38
+ // 故由宿主侧读外置 JSON 经 HTTP 暴露、客户端 fetch 拉取合并(语言包外置做法)。
39
+ webServer.register({
40
+ kind: "exact",
41
+ path: "/api/session-conductor/i18n",
42
+ handler: async (req, res) => {
43
+ try {
44
+ if ((req.method ?? "GET") !== "GET") {
45
+ return send(res, 405, { ok: false, error: { code: "METHOD", message: "Method Not Allowed" } });
46
+ }
47
+ const { zh, en } = loadI18n();
48
+ return send(res, 200, { ok: true, zh, en });
49
+ } catch (error) {
50
+ return send(res, 500, { ok: false, error: { code: "INTERNAL", message: String(error?.message ?? error) } });
51
+ }
52
+ },
53
+ });
54
+
55
+ // ---------- 预览页已移出本插件 ----------
56
+ // 原先这里用 /api/session-conductor/preview/<settings|panel> 同源服务 assets/preview-*.html
57
+ // (那两份 html 由已移除的 tools/preview-snapshot.sh 生成)。现在预览能力统一由独立插件
58
+ // dsh-useful-tools 的 `preview` 工具提供:gen 生成 + serve 带 backend 反代真实后端,
59
+ // 效果等价且本插件不必再托管静态页,故路由与产物一并移除。
60
+
61
+ // ---------- GET /api/session-conductor/list ----------
62
+ webServer.register({
63
+ kind: "exact",
64
+ path: "/api/session-conductor/list",
65
+ handler: async (req, res) => {
66
+ try {
67
+ // 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
68
+ if (req.method !== "GET") invalidateSessionListCache();
69
+ // 索引直读(`?fromIndex=true`):与 `list_sessions` 工具的 `fromIndex` **同义** ——
70
+ // 直接读落盘索引、**不做全量构建**,秒回精简列表(不含 live 状态与归档态)。
71
+ // 原先路由只认 job / stream 两个声明,传 fromIndex 会被**静默忽略** ⇒ 悄悄退化成
72
+ // 226 秒级的全量构建(本机实测一次 41 秒才回),调用方以为拿到了快照、实际被钉在慢路径上。
73
+ if (String(req.url ?? "").includes("fromIndex=true")) {
74
+ const items = await readListIndex(ctx);
75
+ if (!items) {
76
+ send(res, 200, { ok: true, fromIndex: true, sessions: [], hint: "落盘索引为空或尚未建立;去掉 fromIndex 走一次刷新(会重建索引,冷缓存可能很慢)" });
77
+ return;
78
+ }
79
+ send(res, 200, { ok: true, fromIndex: true, sessions: items, hint: "来自落盘索引,不含 live 状态与归档态;要实时状态请去掉 fromIndex 走刷新" });
80
+ return;
81
+ }
82
+ // job 模式(`?job=1` 或 `Accept: application/x-job`):冷缓存下全量构建很慢
83
+ // (本机实测 201 份日志 / 1.5 GB 时 226 秒),把构建交给宿主后台 job 并**立即**返回 jobId,
84
+ // 调用方改用 job_output / job_list 取结果,前台不再被钉住。
85
+ // 默认(无 job 声明)仍走下面的普通 JSON / NDJSON 流式分支 —— 一字不动,避免改坏面板。
86
+ const wantJob = /application\/x-job/i.test(req.headers?.accept ?? "")
87
+ || String(req.url ?? "").includes("job=1");
88
+ if (wantJob) {
89
+ const jobs = ctx?.get?.("jobs");
90
+ if (!jobs || typeof jobs.start !== "function") {
91
+ send(res, 501, { ok: false, error: { code: "unsupported", message: "宿主未挂载 jobs 服务,无法注册后台 job;请改用默认(流式/JSON)路径" } });
92
+ return;
93
+ }
94
+ // 构建只做一次;完成后顺手失效 TTL,保证随后任何一条路径读到的都是本次结果
95
+ const done = buildSessionListCached(ctx).then(
96
+ (sessions) => { invalidateSessionListCache(); return { status: "completed", output: JSON.stringify({ ok: true, sessions }) }; },
97
+ (error) => ({ status: "failed", detail: String(error?.message ?? error) }),
98
+ );
99
+ const spec = { kind: "session-conductor-list", label: "list(会话列表刷新)", run: () => ({ cancel: () => {}, done }) };
100
+ // HTTP 入口没有执行主体 ⇒ 用「无主」注册(宿主存在全局 job 控制器即可);
101
+ // 无主不可用时如实回 501,由调用方决定是否改用默认路径(不静默吞掉)。
102
+ try {
103
+ send(res, 200, { ok: true, async: true, jobId: jobs.start(spec), hint: "用宿主 job_output / job_list 取结果" });
104
+ } catch (error) {
105
+ // 后台 job 不可用(HTTP 入口**没有执行主体**,宿主 job 控制器会拒绝无主注册 ——
106
+ // 实测回的是「no job controller serves this agent」):不再直接 501 把调用方堵死;
107
+ // 退一步给**索引快照**(秒回、明确标注来源与「不含 live 状态」),索引也没有才如实 501。
108
+ const items = await readListIndex(ctx).catch(() => null);
109
+ if (items) {
110
+ send(res, 200, {
111
+ ok: true,
112
+ async: false,
113
+ fromIndex: true,
114
+ sessions: items,
115
+ jobUnavailable: String(error?.message ?? error),
116
+ hint: "后台 job 不可用,已回退为落盘索引快照(不含 live 状态);要实时状态请走默认或流式分支",
117
+ });
118
+ return;
119
+ }
120
+ send(res, 501, { ok: false, error: { code: "unsupported", message: `注册后台 job 失败:${String(error?.message ?? error)}(且落盘索引不可用,无回退)` } });
121
+ }
122
+ return;
123
+ }
124
+ // list 流式(仅当请求声明 Accept: application/x-ndjson)——live 会话先快速
125
+ // 写出,冷会话串行逐个解析边写边出,前端 fetch stream 逐行追加(每获取一个立即显示,
126
+ // 不等全部);排序由前端按 updatedAt 倒序插入保持(流式下后端不再整体排序)。
127
+ // 默认(无 Accept 头/旧前端/预览页垫片)仍返回普通 JSON——兼容不匹配的调用方。
128
+ const wantStream =
129
+ /application\/x-ndjson/i.test(req.headers?.accept ?? "") || String(req.url ?? "").includes("stream=1");
130
+ if (!wantStream) {
131
+ const sessions = await buildSessionListCached(ctx);
132
+ send(res, 200, { ok: true, sessions });
133
+ return;
134
+ }
135
+ res.setHeader("Content-Type", "application/x-ndjson; charset=utf-8");
136
+ res.setHeader("Cache-Control", "no-store");
137
+ if (res.socket?.setNoDelay) res.socket.setNoDelay(true);
138
+ res.write(JSON.stringify({ ok: true, stream: true }) + "\n"); // 头行(前端跳过非会话行)
139
+ // 立即把头行推出去:冷缓存时后面要逐份解压上百份日志(实测 41~226 秒),
140
+ // 若头行压在缓冲里,浏览器这侧会一直「没有响应」,看起来就像接口挂了。
141
+ // flushHeaders/flush 老版本不一定有,取不到就跳过(写操作本身已尽量及时)。
142
+ try {
143
+ if (typeof res.flushHeaders === "function") res.flushHeaders();
144
+ if (typeof res.flush === "function") res.flush();
145
+ } catch { /* 不支持 flush 的宿主不影响主流程 */ }
146
+ const onItem = (item) => { try { res.write(JSON.stringify(item) + "\n"); } catch { /* 连接已断 */ } };
147
+ await buildSessionListCached(ctx, { onItem, serial: true });
148
+ try { res.end(); } catch { /* 连接已断 */ }
149
+ } catch (error) {
150
+ try {
151
+ res.end(JSON.stringify({ ok: false, error: { code: "internal", message: String(error?.message ?? error) } }) + "\n");
152
+ } catch { /* 连接已断 */ }
153
+ }
154
+ },
155
+ });
156
+ }
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -0,0 +1,97 @@
1
+ // dsh-session-conductor — 批量把模型/权限应用到多个成员
2
+ //
3
+ // 【为什么单独抽一层】「对全部子代理统一模型/权限」在指令和 AI 工具两边都要用,
4
+ // 而且批量写会话日志有几个必须统一的纪律:
5
+ // ① **串行**执行:并发写多个会话日志容易互相干扰(每个都是一次读-改-写),串行最省心;
6
+ // ② **逐个隔离失败**:某个成员失败(日志损坏、名册状态异常)不能中断其余成员;
7
+ // ③ **回执必须带名单**:批量操作最怕「不知道改了谁」,所以结果逐条列出、并统计成功/失败数。
8
+ import { log } from "../../shared/log.js";
9
+ import { switchAgentModel } from "./switch.js";
10
+ import { switchMemberPermission } from "./permission.js";
11
+
12
+ /** 成员显示名(名字 + 短 id,便于人核对)。 */
13
+ export function memberDisplay(member, sessionId) {
14
+ const name = member?.name ?? null;
15
+ const shortId = sessionId ? `${sessionId.slice(0, 8)}…` : "?";
16
+ return name ? `${name}(${shortId})` : String(sessionId ?? "?");
17
+ }
18
+
19
+ /**
20
+ * 批量切换模型。
21
+ *
22
+ * @param {object} ctx 插件 context
23
+ * @param {Array<{sessionId: string, member?: object, agent?: object|null}>} targets 目标列表(已解析)
24
+ * @param {{provider: string, model: string}} route 目标路由
25
+ * @returns {Promise<{rows: Array<{sessionId: string, label: string, ok: boolean, detail: string}>, okCount: number, failCount: number}>}
26
+ */
27
+ export async function applyModelToMany(ctx, targets, { provider, model }) {
28
+ const rows = [];
29
+ for (const t of targets) {
30
+ const label = memberDisplay(t.member, t.sessionId);
31
+ try {
32
+ const result = await switchAgentModel(ctx, t.sessionId, provider, model, t.agent ?? null);
33
+ if (result?.error) {
34
+ rows.push({ sessionId: t.sessionId, label, ok: false, detail: String(result.error) });
35
+ } else {
36
+ rows.push({ sessionId: t.sessionId, label, ok: true, detail: String(result.via ?? "") });
37
+ }
38
+ } catch (error) {
39
+ rows.push({ sessionId: t.sessionId, label, ok: false, detail: String(error?.message ?? error) });
40
+ }
41
+ }
42
+ const okCount = rows.filter((r) => r.ok).length;
43
+ log(ctx, `批量切换模型:${okCount}/${rows.length} 成功(${provider}/${model})`);
44
+ return { rows, okCount, failCount: rows.length - okCount };
45
+ }
46
+
47
+ /**
48
+ * 批量切换权限预设。
49
+ *
50
+ * @param {object} ctx 插件 context
51
+ * @param {Array<{sessionId: string, member?: object}>} targets 目标列表(已解析)
52
+ * @param {string} preset 目标预设名
53
+ * @returns {Promise<{rows: Array<{sessionId: string, label: string, ok: boolean, detail: string}>, okCount: number, failCount: number}>}
54
+ */
55
+ export async function applyPermissionToMany(ctx, targets, preset) {
56
+ const rows = [];
57
+ for (const t of targets) {
58
+ const label = memberDisplay(t.member, t.sessionId);
59
+ try {
60
+ const result = await switchMemberPermission(ctx, t.sessionId, preset);
61
+ if (result?.error) {
62
+ rows.push({ sessionId: t.sessionId, label, ok: false, detail: String(result.error) });
63
+ } else {
64
+ const from = result.from ?? "(未记录)";
65
+ const note = result.note ? `;${result.note}` : "";
66
+ rows.push({ sessionId: t.sessionId, label, ok: true, detail: `${from} → ${result.to}${note}` });
67
+ }
68
+ } catch (error) {
69
+ rows.push({ sessionId: t.sessionId, label, ok: false, detail: String(error?.message ?? error) });
70
+ }
71
+ }
72
+ const okCount = rows.filter((r) => r.ok).length;
73
+ log(ctx, `批量切换权限:${okCount}/${rows.length} 成功(${preset})`);
74
+ return { rows, okCount, failCount: rows.length - okCount };
75
+ }
76
+
77
+ /**
78
+ * 渲染批量结果(先名单、后逐条结果、最后统计)。
79
+ *
80
+ * 批量操作的价值一半在「改了谁」的可核对性上,所以名单一定要完整列出,
81
+ * 不能只给一句「已处理 N 个」。
82
+ *
83
+ * @param {string} title 标题行(如「批量切换模型 → sa6400/deepseek-v4.1-flash」)
84
+ * @param {{rows: Array<{label: string, ok: boolean, detail: string}>, okCount: number, failCount: number}} report
85
+ * @param {string[]} [extra] 额外说明行
86
+ * @returns {string}
87
+ */
88
+ export function renderBatchReport(title, report, extra = []) {
89
+ const lines = [title, ""];
90
+ lines.push(`受影响成员(共 ${report.rows.length} 个):`);
91
+ for (const r of report.rows) lines.push(` ${r.ok ? "✅" : "❌"} ${r.label}`);
92
+ lines.push("", "逐条结果:");
93
+ for (const r of report.rows) lines.push(` ${r.ok ? "✅" : "❌"} ${r.label}:${r.detail || (r.ok ? "成功" : "失败")}`);
94
+ lines.push("", `统计:成功 ${report.okCount} 个 | 失败 ${report.failCount} 个`);
95
+ for (const line of extra) if (line) lines.push(line);
96
+ return lines.join("\n");
97
+ }
@@ -0,0 +1,270 @@
1
+ // dsh-session-conductor — 把成员「换名」成新成员(新建 + 搬会话内容)
2
+ //
3
+ // 【为什么需要它】宿主的 Team 成员名是**不可变身份字段**:`team/member` 投影里有断言
4
+ // if (prior.name !== member.name || prior.provider !== member.provider || prior.context !== member.context)
5
+ // throw new Error('changed immutable identity fields')
6
+ // 而且只允许 provisioning → active 这一次写入,已 active 的成员再写必被 phase 校验拒绝;
7
+ // 宿主侧也没有任何 rename 接口。所以「改名」唯一可行的路是:**新建一个成员,再把旧内容搬过去**。
8
+ //
9
+ // 【为什么这条路成立】成员名与会话内容是两份独立数据:
10
+ // · 成员名 → 存在 **Lead 会话日志**的 `team/member` 事件里(换名时由 spawn 写入新名)
11
+ // · 会话内容 → 存在 **成员自己的会话日志**里(本模块替换的就是这一份,其中没有 team/member);
12
+ // 实测该日志的事件里 0 处引用自己的 sessionId,因此**只换 header 就够**。
13
+ //
14
+ // 【搬运必须「原样复制」(一次真实事故换来的结论)】会话事件之间布满了跨事件引用:
15
+ // 顶层 `sourceEventSeqs`、顶层 `surfaceOp { startSeq, endSeq }`、`data.messageSeqs`、
16
+ // `data.shadowedSeqs`、`data.headerSeq`,以及 compaction 的 start → summary/prune → end 强配对。
17
+ // 任何 seq 重排或事件增删都会让这些关系断裂,宿主加载时直接判 corrupt。实测连续撞过三种:
18
+ // ① `sourceEventSeqs ranges must contain unique earlier seqs`
19
+ // ② `session/title messageSeqs must cite distinct earlier human user/message events`
20
+ // ③ `successful compaction/end requires one summary`
21
+ // 而搬运本质就是复制:源 seq 本来就是 0..N 连续,所以正确做法是
22
+ // **事件一条不改、seq 一个不动,只替换 header 里的身份字段**。
23
+ //
24
+ // 【必须保留的两条硬约束】
25
+ // ① 帧必须带校验和:宿主读帧会验 checksum,缺了就报「会话文件 总和检查码不匹配」直接拒读;
26
+ // ② 目标成员不能正在跑:宿主以内存态优先,对运行中的成员改磁盘文件不生效,还可能被覆盖回去。
27
+ import { readFileSync, writeFileSync, renameSync, copyFileSync, statSync } from 'node:fs';
28
+ import { join, dirname } from 'node:path';
29
+ import { log } from "../../shared/log.js";
30
+ import { findTargetAgent } from "./locate.js";
31
+ import { PLACEHOLDER_PROMPT, waitUntilIdle } from "./create-teammate.js";
32
+
33
+ /**
34
+ * 把源会话的事件原样搬到目标会话日志,**只替换 header 身份**。
35
+ *
36
+ * 设计要点(每一条都是为了避免破坏跨事件引用):
37
+ * · 源事件一条不丢、seq 一个不改 —— 引用关系因此天然保持,compaction 配对也完整;
38
+ * · header 用**目标**自己的(id / cwd / parentSession / origin / delegationDepth / isSeeded),
39
+ * 否则新成员会变成旧成员;
40
+ * · 只允许改 `subagent/descriptor` 里的 label / 模型路由(纯字符串字段,不参与任何引用)。
41
+ *
42
+ * @param {string} sourceFile 源成员会话日志
43
+ * @param {string} targetFile 目标成员会话日志
44
+ * @param {{label?: string, seedProvider?: string, seedModel?: string}} [opts]
45
+ * label 改写 descriptor 的描述;seedProvider/seedModel 改写 descriptor 的模型路由(省略则沿用源)
46
+ * @returns {Promise<{ok: true, events: number, backup: string, size: number} | {ok: false, error: string}>}
47
+ */
48
+ export async function transplantSessionLog(sourceFile, targetFile, opts = {}) {
49
+ const { decodeAllFrames } = await import("../../zstd-frames.js");
50
+ const { encodeSessionText } = await import("../../repair.js");
51
+
52
+ // ① 目标 header:新成员的身份,原样保留
53
+ let targetHead;
54
+ try {
55
+ const text = await decodeAllFrames(readFileSync(targetFile));
56
+ targetHead = JSON.parse(text.split("\n").find((l) => l.trim() !== ""));
57
+ } catch (error) {
58
+ return { ok: false, error: `读取目标会话 header 失败:${String(error?.message ?? error)}` };
59
+ }
60
+ if (!targetHead?.id) return { ok: false, error: "目标会话 header 缺少 id,无法确定新成员身份" };
61
+
62
+ // ② 源事件:全部读取,一条不丢(只跳过 header 那一行)
63
+ let sourceEvents;
64
+ try {
65
+ const text = await decodeAllFrames(readFileSync(sourceFile));
66
+ sourceEvents = [];
67
+ for (const line of text.split("\n")) {
68
+ if (line.trim() === "") continue;
69
+ let e;
70
+ try { e = JSON.parse(line); } catch { continue; }
71
+ if (e?.type === "session") continue; // header 用目标的
72
+ sourceEvents.push(e);
73
+ }
74
+ } catch (error) {
75
+ return { ok: false, error: `读取源会话失败:${String(error?.message ?? error)}` };
76
+ }
77
+ if (sourceEvents.length === 0) return { ok: false, error: "源会话没有可搬运的事件" };
78
+
79
+ // ③ 仅改写 descriptor 的人类可读字段(不碰 seq、不碰任何引用)
80
+ for (const e of sourceEvents) {
81
+ if (e?.type !== "subagent/descriptor" || !e.data) continue;
82
+ if (opts.label !== undefined) e.data.label = String(opts.label);
83
+ if (opts.seedProvider !== undefined) e.data.agentProvider = String(opts.seedProvider);
84
+ if (opts.seedModel !== undefined) e.data.agentModel = String(opts.seedModel);
85
+ }
86
+
87
+ const outText = [JSON.stringify(targetHead), ...sourceEvents.map((e) => JSON.stringify(e))].join("\n") + "\n";
88
+ const encoded = await encodeSessionText(outText); // 官方编码器:自带 checksum
89
+
90
+ // ④ 写前自检:解码回来**逐条与源对比**。这是唯一能证明「除 header 与 descriptor 外一字未改」的检查,
91
+ // 也是唯一能保证「源能加载 ⇒ 目标也能加载」的依据(跨事件引用全部原样)。
92
+ let verifyLines;
93
+ try {
94
+ const verifyText = await decodeAllFrames(encoded);
95
+ verifyLines = verifyText.split("\n").filter((l) => l.trim() !== "");
96
+ } catch (error) {
97
+ return { ok: false, error: `写前自检解码失败:${String(error?.message ?? error)}` };
98
+ }
99
+ if (verifyLines.length !== sourceEvents.length + 1) {
100
+ return { ok: false, error: `写前自检事件数不符(期望 ${sourceEvents.length + 1} 行,实际 ${verifyLines.length})` };
101
+ }
102
+ const verifyHead = JSON.parse(verifyLines[0]);
103
+ if (verifyHead.id !== targetHead.id) {
104
+ return { ok: false, error: `写前自检 header 身份不符(期望 ${targetHead.id},实际 ${verifyHead.id})` };
105
+ }
106
+ const normalize = (v) => JSON.stringify(v);
107
+ for (let i = 0; i < sourceEvents.length; i += 1) {
108
+ let got;
109
+ try { got = JSON.parse(verifyLines[i + 1]); } catch {
110
+ return { ok: false, error: `写前自检第 ${i + 2} 行不是合法 JSON` };
111
+ }
112
+ const want = sourceEvents[i];
113
+ if (normalize(got) === normalize(want)) continue;
114
+ // descriptor 允许 label / 模型路由不同,其余必须逐字段一致
115
+ if (want?.type === "subagent/descriptor" && got?.type === "subagent/descriptor") {
116
+ const a = { ...want.data }; const b = { ...got.data };
117
+ delete a.label; delete b.label; delete a.agentProvider; delete b.agentProvider;
118
+ delete a.agentModel; delete b.agentModel;
119
+ if (normalize(a) === normalize(b) && got.seq === want.seq) continue;
120
+ }
121
+ return { ok: false, error: `写前自检失败:第 ${i + 2} 行(seq ${want?.seq ?? "?"} / ${want?.type ?? "?"})与源不一致,放弃写入` };
122
+ }
123
+
124
+ // ⑤ 备份目标原日志 → 原子写回
125
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
126
+ const backup = join(dirname(targetFile), `session.before-clone-${stamp}.bak`);
127
+ try {
128
+ copyFileSync(targetFile, backup);
129
+ } catch (error) {
130
+ return { ok: false, error: `备份目标日志失败(未写入):${String(error?.message ?? error)}` };
131
+ }
132
+ const tmp = join(dirname(targetFile), ".session.clone.tmp");
133
+ writeFileSync(tmp, encoded, { mode: 0o600 });
134
+ renameSync(tmp, targetFile);
135
+
136
+ return { ok: true, events: sourceEvents.length, backup, size: statSync(targetFile).size };
137
+ }
138
+
139
+ /**
140
+ * 把成员 A 的会话内容克隆到一个新成员 B(B 用新名字,A 不动)。
141
+ *
142
+ * 四步:① 解析源成员 ② spawn 新成员(占位任务,只消耗极短提示)③ 中断并等它空闲
143
+ * ④ 把源会话内容原样搬进新成员日志。
144
+ *
145
+ * @param {object} ctx 插件 context
146
+ * @param {object} exec 执行上下文(含 agent)
147
+ * @param {{source: string, name: string, description?: string, withHistory?: boolean,
148
+ * provider?: string, model?: string, subagentProvider?: string}} args 参数
149
+ * @returns {Promise<object>} 回执对象(成功含 newMember,失败含 error)
150
+ */
151
+ export async function cloneMemberAs(ctx, exec, args) {
152
+ const caller = exec?.agent ?? null;
153
+ const source = String(args?.source ?? "").trim();
154
+ const name = String(args?.name ?? "").trim();
155
+ if (!source) return { error: "source 不能为空(成员名 / sessionId / 标题关键字)" };
156
+ if (!name) return { error: "name 不能为空(新成员名,lower-kebab-case)" };
157
+
158
+ // ① 源成员
159
+ const found = findTargetAgent(ctx, source, { caller });
160
+ if (found?.error) return { error: `找不到源成员:${found.error}` };
161
+ if (found.sessionId === name) return { error: "新成员名与源成员相同,无需克隆" };
162
+
163
+ // 目标会话日志路径要在 spawn 之前确认定位能力可用(否则白建一个成员)
164
+ const { findSessionLogById, sessionsRootOf } = await import("../../sessions/events.js");
165
+ const sourceFile = findSessionLogById(found.sessionId, sessionsRootOf());
166
+ if (!sourceFile) return { error: `源成员会话日志不存在(sessionId=${found.sessionId})` };
167
+
168
+ // ② 能力检查
169
+ const teams = ctx.get("agentTeams");
170
+ if (typeof teams?.spawnTeammate !== "function") return { error: "宿主未挂载 agentTeams.spawnTeammate,无法新建成员" };
171
+ const lead = caller;
172
+ if (!lead) return { error: "拿不到发起方 Agent(请从 Lead 会话调用)" };
173
+ let subagentProvider = String(args?.subagentProvider ?? "").trim();
174
+ if (!subagentProvider) {
175
+ try {
176
+ const list = ctx.get("subagents")?.list?.() ?? [];
177
+ subagentProvider = String(list[0] ?? "");
178
+ } catch { subagentProvider = ""; }
179
+ }
180
+ if (!subagentProvider) return { error: "拿不到子代理 provider(ctx.subagents.list() 为空),可显式传 subagentProvider" };
181
+
182
+ // ③ spawn + 中断 + 等空闲
183
+ let spawned;
184
+ try {
185
+ spawned = await teams.spawnTeammate(lead, {
186
+ name,
187
+ description: String(args?.description ?? `克隆自 ${found.member?.name ?? found.sessionId}`),
188
+ prompt: [{ type: "text", text: PLACEHOLDER_PROMPT }],
189
+ context: "fresh",
190
+ provider: subagentProvider,
191
+ });
192
+ } catch (error) {
193
+ return { error: `新建成员失败:${String(error?.message ?? error)}` };
194
+ }
195
+ const newId = spawned?.id ?? spawned?.sessionId ?? null;
196
+ if (!newId) return { error: "新建成员成功但拿不到 sessionId" };
197
+
198
+ try {
199
+ if (typeof teams.interrupt === "function") {
200
+ try { await teams.interrupt(lead, name); } catch { /* 可能已结束,继续等 */ }
201
+ }
202
+ const idle = await waitUntilIdle(teams, lead, name, null);
203
+ if (!idle.idle) {
204
+ return { error: `新成员仍在运行(status=${idle.status}),为避免覆盖其内存态,未搬内容;可稍后重试`, newMember: { name, sessionId: newId } };
205
+ }
206
+
207
+ // ④ 搬内容
208
+ if (args?.withHistory === false) {
209
+ return { ok: true, newMember: { name, sessionId: newId }, events: 0, note: "已按要求只建空成员,未搬历史" };
210
+ }
211
+ const targetFile = findSessionLogById(newId, sessionsRootOf());
212
+ if (!targetFile) return { error: `新成员会话日志不存在(sessionId=${newId})`, newMember: { name, sessionId: newId } };
213
+
214
+ const moved = await transplantSessionLog(sourceFile, targetFile, {
215
+ seedProvider: String(args?.provider ?? "").trim() || undefined,
216
+ seedModel: String(args?.model ?? "").trim() || undefined,
217
+ });
218
+ if (!moved.ok) return { error: `搬会话内容失败:${moved.error}`, newMember: { name, sessionId: newId } };
219
+
220
+ log(ctx, `成员克隆:${found.sessionId} → ${newId}(${moved.events} 事件,${(moved.size / 1048576).toFixed(2)}MB)`);
221
+ return {
222
+ ok: true,
223
+ newMember: { name, sessionId: newId },
224
+ sourceMember: { name: found.member?.name ?? null, sessionId: found.sessionId },
225
+ events: moved.events,
226
+ backup: moved.backup,
227
+ sizeMB: Number((moved.size / 1048576).toFixed(2)),
228
+ };
229
+ } catch (error) {
230
+ return { error: `克隆过程失败:${String(error?.message ?? error)}`, newMember: { name, sessionId: newId } };
231
+ }
232
+ }
233
+
234
+ /**
235
+ * 渲染 clone_member_as 的回执。
236
+ *
237
+ * 要点:① 明确「新成员已建、旧成员未动」,避免误以为旧成员被改名/删除;
238
+ * ② 报出搬了多少事件与多大体积(这是唯一能说明「内容真的搬过去了」的量);
239
+ * ③ 报出备份路径(搬错可回滚);④ 失败时连带已建成员一起说明,免得留下孤儿成员。
240
+ *
241
+ * @param {{source?: string, name?: string, withHistory?: boolean}} args 调用参数
242
+ * @param {object} result cloneMemberAs 的返回
243
+ * @returns {string}
244
+ */
245
+ export function renderCloneMemberResult(args, result) {
246
+ const lines = [];
247
+ if (result?.error) {
248
+ lines.push(`克隆失败:${result.error}`);
249
+ if (result.newMember?.sessionId) {
250
+ lines.push(`已创建但未搬内容的新成员:${result.newMember.name}(${result.newMember.sessionId})`);
251
+ lines.push("该成员是空壳,可按需删除(delete 或团队面板移除)。");
252
+ }
253
+ return lines.join("\n");
254
+ }
255
+ const nm = result.newMember ?? {};
256
+ lines.push(`已新建成员:${nm.name}(${nm.sessionId})`);
257
+ if (result.sourceMember?.sessionId) {
258
+ lines.push(`内容来源:${result.sourceMember.name ?? "(未命名)"}(${result.sourceMember.sessionId}) — 该成员本身未改动`);
259
+ }
260
+ if (result.note) {
261
+ lines.push(result.note);
262
+ } else {
263
+ lines.push(`搬运事件:${result.events} 条 | 会话体积:${result.sizeMB}MB`);
264
+ lines.push(`回滚备份:${result.backup}`);
265
+ }
266
+ lines.push("");
267
+ lines.push("生效方式:新成员下次被唤醒时按磁盘日志加载(当前是冷会话,此时替换才生效)。");
268
+ lines.push("如需「改名」效果,旧成员可自行删除;名字唯一性由宿主保证,新名不能与现有成员重复。");
269
+ return lines.join("\n");
270
+ }