dsh-session-conductor 1.3.16 → 1.3.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -27,6 +27,7 @@
27
27
  | 上下文用量自查 | AI 按需查自己会话的上下文占用(百分比、已用/容量 tokens)与构成(系统提示词/工具定义/消息文本),占用达 85% 时附收敛建议。数据与界面圆环同源(官方 `contextPressure` / `contextBreakdown` 投影),只读、无参数。**另:每轮把实时用量注入消息尾部**(AI 每轮都能直接看到数字;注入在尾部而非系统提示词前缀 ⇒ 前缀稳定、prompt cache 仍命中;按 turn 门控,一回合只注一次;配置 `injectContextUsage` 可关) | AI 工具 `context_usage` + 每轮注入 |
28
28
  | 跨会话消息 | AI 直接给另一个会话发消息(不经过人转达):先 `list_sessions` 挑目标,再 `send_to_session` 投递;目标空闲时**由本体自动唤醒**它跑一个回合,运行中则排到其下一回合边界(`mode:"steer"` 改为注入当前回合)。全部走本体官方 `prompt` API(幂等 `requestId`、不自己写会话日志)。**支持 `dryRun` 预览**(报告目标状态与限流余量,不投递、不消耗配额)。`list_sessions` 支持 **`query` 子串过滤 + `limit`/`offset` 分页**(默认只给 20 条,避免整表塞进调用方上下文)。**索引快路径 + 后台化**:`fromIndex=true` 直接读落盘索引(秒回、不重建,不含 live 状态与归档态),默认刷新路径先等 3 秒拿热缓存结果、超时就把**同一次在途构建**交给宿主后台 job 并立刻返回 jobId(冷缓存全量构建实测 226 秒 / 201 会话 / 1.5 GB 日志,热缓存 47 毫秒;job 注册失败会同步降级并带回原因)。硬约束:禁自投、协作链跳数 ≥3 拒绝、同一对会话 60s 内最多 5 条 | AI 工具 `list_sessions` / `send_to_session` |
29
29
  | 成员权限预设切换 | 给队友/子代理查看与切换**权限预设**(`danger-full-access` / `workspace-write` 等)。**为什么必须有它**:子代理会话在界面上切不了权限预设,而 preset 一旦指向当前不可用的值(典型:`auto` 在「自动审查」关闭后悬空),该成员就永久唤不醒 —— 宿主 `pinInitialPermission()` 在会话发布前抛 `permission: cannot restore preset "auto" without its active integration`,再被 subagent 的 `coldResume()` 统一包成 `subagent/not-resumable`,报错文本完全指不到权限层。实现是**追加一条 `permission/preset` 事件**(独立 zstd 帧、带 checksum、长度 CAS、fsync;已有帧一字节不动,可删尾部回滚),靠宿主折叠语义「最后一条生效」。**参数都可空**:`target` 留空/`*`/`all` = 全部成员(排除 Lead、provisioning、failed);`preset` 留空/`auto` = 跟随主代理。预设名先从宿主 `permissionPresets.catalog()` 校验,不在列表里直接拒绝(写错的预设会让成员加载失败)。批量串行执行、逐个隔离失败、回执带完整名单 | AI 工具 `set_member_permission` / `list_permission_presets` + 聊天框指令 `/perm-switch` |
30
+ | 成员换名(新建 + 搬内容) | 给成员**换名字**。宿主把 Team 成员名钉死为不可变身份字段(`team/member` 投影里 `name`/`provider`/`context` 一变就抛 `changed immutable identity fields`,且只允许 provisioning → active 这一次写入),也没有任何 rename 接口 —— 所以换名只能「新建一个成员,再把旧内容搬过去」。这条路成立是因为成员名与会话内容分处两地:名字在 **Lead 日志**的 `team/member` 事件里,内容在**成员自己的日志**里,搬内容不会触碰不可变字段。工具 `clone_member_as` 流程:解析源成员 → spawn 新成员(占位任务)→ 中断并等它**真正空闲**(运行中的成员以内存态优先,改磁盘不生效)→ 搬会话事件:保留目标 header 身份、丢弃源的身份类事件、按目标重建 descriptor、**seq 从 0 严格重排**、用官方编码器写(**带 checksum**)→ 写前自检 → 备份后原子替换。旧成员不会被改动,需要脱手自行删除 | AI 工具 `clone_member_as` |
30
31
  | 创建成员并选模型 | 一条命令完成「**创建团队成员 + 让第一轮真实任务就用指定模型**」。宿主的 `spawn_teammate` 参数里没有 provider/model(服务层 `SpawnTeammateRequest` 也只有 provider,模型来自 agent-team 插件的 freshProvider/forkProvider 配置),直接创建的话首轮一定用默认模型把真实任务跑掉。本工具因此拆成四步:① 用**占位任务**创建(只消耗一条极短提示)② **立刻中断**把这一轮停掉 ③ **等它真正空闲**(用宿主 `waitForChange` 等状态变化,不睡固定时间;否则取消信号会串到下一轮)④ 写 `model/selection`(与 `set_member_model` 同一条链路,默认不碰任何 @Remote)然后**投递真实任务**。参数里 **`provider`/`model` 是模型 provider**(agnes / wb / sa6400 …);创建成员另需**子代理 provider**(`ctx.subagents` 注册表,通常 `spawn`),默认自动取第一个,也可用可选参数 `subagentProvider` 指定 —— 两者是不同命名空间,混用会报 `no subagent provider registered`。设模型失败时**不投递**(否则等于用默认模型白跑),投递失败也如实回执。要彻底「创建前定模型」只能改宿主 agent-team(把 model 加进 request),宿主升级会被覆盖 | AI 工具 `create_teammate_with_model` |
31
32
  | 指定模型新建会话 | AI 新建会话并可**直接指定 provider/model**(建完即切到该模型),可选带一条初始消息让它立刻开始干活。**缺省工作目录 = 调用方会话自己的 cwd**(不落在 DSH 安装目录里)。本体 `create` 本身不带模型,所以是 `create` → `selectModel` 两步;任一步失败会如实回执(如「会话已建,但模型未设置」),不谎报成功。**支持 `dryRun` 预览**(含模型是否存在于目录的核对),不建会话 | AI 工具 `create_session` |
32
33
 
@@ -189,10 +190,89 @@ preview { action: "serve", file: "<插件根>/assets/preview.html", backend: "h
189
190
  要查**实际**在用什么(含历史请求,不只本次进程)或切换:
190
191
  /model-switch <成员名或 sessionId> ← 查实际模型
191
192
  /model-switch <成员名或 sessionId> <provider>/<模型> ← 切换并回显查证
192
- /model-switch * <provider>/<模型> ← 全部成员切到该模型(回执带名单)
193
- /model-switch <成员名|*> auto ← 跟随主代理(Lead)当前模型
193
+
194
+ 和别人对齐 / 批量:
195
+ /model-switch * auto ← 最常用:所有子代理都换成「我自己现在用的」模型
196
+ /model-switch <成员名> auto ← 只让这一个成员换成我自己现在用的模型
197
+ /model-switch * <provider>/<模型> ← 所有子代理换成一个指定的模型
198
+
199
+ * = 全部成员(不含我自己);auto = 「跟我保持一致」,不用去查我用的是哪个模型。
200
+ ```
201
+
202
+ ### 让全部子代理和我用同一个模型
203
+
204
+ 三种写法都能做到,按场景挑:
205
+
206
+ | 场景 | 用户指令 | AI 工具调用 |
207
+ |---|---|---|
208
+ | **所有子代理都跟我一致** | `/model-switch * auto` | `set_member_model({})`(三个参数全留空) |
209
+ | 只让某一个跟我一致 | `/model-switch <成员名> auto` | `set_member_model({ target: "<成员名>" })` |
210
+ | 所有子代理统一到指定模型 | `/model-switch * sa6400/deepseek-v4.1-flash` | `set_member_model({ provider: "sa6400", model: "deepseek-v4.1-flash" })` |
211
+
212
+ **参数留空的语义**(指令与工具一致):
213
+
214
+ | 参数 | 留空 / `*` / `auto` 的含义 |
215
+ |---|---|
216
+ | 成员(`target` 或第一个位置参数) | 留空或 `*` = **全部成员**(自动排除我自己,也排除正在创建 / 已失败的) |
217
+ | 模型(`provider`+`model` 或第二个位置参数) | 留空或 `auto` = **跟主代理(Lead)一致**,即我自己当前实际在用的模型 |
218
+
219
+ 「我自己在用的模型」取自**请求级权威记录**(会话日志里的 `request/header`,即真实发出请求时用的值),
220
+ 读不到就如实报错,不会拿名册里的静态字段冒充。批量执行**串行**、**逐个隔离失败**、**回执带完整名单**。
221
+
222
+ 权限预设同样支持这套写法:`/perm-switch * auto` = 所有子代理的权限预设跟我一致;
223
+ `set_member_permission({})` 等价。
224
+
225
+ 注意:正在跑回合的成员会被跳过(等 60s 仍未结束则报错并提示先中断)—— 这是有意的,
226
+ 运行中切模型不生效,还可能被其内存态覆盖。
227
+
228
+ ### 为什么子代理界面上没有「切换模型」按钮
229
+
230
+ 主会话输入框右上角那个模型选择器,在**子代理会话里是宿主硬性关掉的**,不是本插件漏做,也不是宿主漏做:
231
+
232
+ ```js
233
+ // client/ui-model-selection/src/client/index.ts:134
234
+ available: session => sessions.subagentAddress(session.sessionId) === undefined,
235
+
236
+ // client/ui-model-selection/src/client/directory.ts:153
237
+ throw new Error('model selection is unavailable for addressed subagent sessions')
194
238
  ```
195
239
 
240
+ 宿主源码给出的原因(同文件 `index.ts:9-11`):子代理会话绑定的模型 RPC 会**绕过父级续跑路径去激活已持久化的历史**,
241
+ 所以 UI 层把入口直接关掉。任何走 `session.selectModel` 那条 RPC 的实现都会撞上同一个拦截,装插件也绕不过去。
242
+
243
+ **所以子代理的模型只能由本插件切换** —— 我们写的是 `model/selection` 事件(交给宿主官方投影接管),
244
+ 不经过那条被禁的 RPC,因此不受该限制:
245
+
246
+ | 目的 | 用户指令 | AI 工具 |
247
+ |---|---|---|
248
+ | 单个成员换成指定模型 | `/model-switch <成员名> <provider>/<模型>` | `set_member_model({ target, provider, model })` |
249
+ | 单个成员跟我用同一个 | `/model-switch <成员名> auto` | `set_member_model({ target })` |
250
+ | 全部子代理跟我用同一个 | `/model-switch * auto` | `set_member_model({})` |
251
+
252
+ 权限预设同理(`/perm-switch`、`set_member_permission`)—— 子代理界面上同样没有权限切换入口。
253
+
254
+ ### 模型切换为什么要写 override(以及它和官方界面的关系)
255
+
256
+ 官方模型选择链是「投影 pending → 会话请求头 → 部署默认」:写 `model/selection` 事件只让 pending
257
+ 生效**一轮**,被 `request/header` 消费后,新回合会沿请求头链回落,最终回到部署默认模型。
258
+ 所以本插件在 `agent/request` 瀑布流里按会话强制改写请求配置(每轮都改 → 跨轮必然持久),
259
+ 这份持久状态存在插件域 `memberModelOverrides` 里。
260
+
261
+ **这个机制曾有致命副作用**(1.3.18 修复):override 优先级高于官方链路,一旦写入,
262
+ 用户在主会话界面用官方模型选择器切换就**完全无效** —— 界面显示新模型、请求一直走 override 里的旧模型,
263
+ 不报错、不提示、也没有清除入口。现场表现为「界面切三次、跨三个回合、改日志、重启宿主全都不动」。
264
+
265
+ **现在的行为**:官方切换会往 `modelSelection` 投影写 `pending`,插件每轮请求前把它与 override 比对,
266
+ 不一致就**把 override 同步成官方的新值**(同步而非清除 —— 既让界面选择立刻生效,又保留跨轮持久能力)。
267
+ 同步失败只记一条日志并退回旧 override,绝不打断请求。
268
+
269
+ 使用注意:
270
+
271
+ - 同步只发生在**已有 override 的会话**上;没有 override 的会话完全交给官方链路,插件不干预。
272
+ - `set_member_model` 的批量「跟随主代理」(`auto` / 三参数全空)写入的是**当时**主代理用的模型;
273
+ 之后主代理再换模型,这些成员**不会**自动跟着变(需再执行一次)。
274
+ - 想让某个会话彻底回到「官方说了算」,执行一次官方界面切换即可(override 会被同步成新值)。
275
+
196
276
  「最近请求」取自**进程内真实请求记录**(瞬时、不扫日志);显示「未知」说明本进程还没见它请求过,用 ② 查历史。若同时显示目标与最近请求且不一致,会附 `⚠️ 与目标不一致(下一回合生效前属正常)`。
197
277
 
198
278
  **② 查某个成员实际在用什么**
package/lib/apply.js CHANGED
@@ -21,7 +21,8 @@ import { refreshTitleState } from './features/rename/title.js';
21
21
  // 成员模型:本文件用到 findTargetAgent / switchAgentModel / getMemberModelOverride /
22
22
  // applyModelOverride / validateModelPair(拆出 apply.js 时漏了这一行 import,
23
23
  // 运行到相关代码路径会 ReferenceError: getMemberModelOverride is not defined)
24
- import { getMemberModelOverride, applyModelOverride, noteRequestModel, takeSwitchOutcomes } from './features/member-model/index.js';
24
+ import { getMemberModelOverride, applyModelOverride, noteRequestModel, takeSwitchOutcomes,
25
+ adoptOfficialSelection, readOfficialPendingSelection, setMemberModelOverride } from './features/member-model/index.js';
25
26
  // 会话操作的路由注册已抽到独立模块(handler 实现见该模块;此处只留注册入口)
26
27
  import { registerSessionOpsRoutes } from './features/session-ops-routes.js';
27
28
  import { registerMemberModelTools } from './features/member-model/tools.js';
@@ -317,7 +318,25 @@ export async function apply(ctx, config = {}) {
317
318
  // override 由 set_member_model 写入(domain 持久 + 内存缓存镜像),每轮都改 → 跨轮持久。
318
319
  ctx.on("agent/request", async ({ agent }, next) => {
319
320
  const resolved = await next();
320
- const override = await getMemberModelOverride(ctx, agent?.session?.id);
321
+ const sessionId = agent?.session?.id;
322
+ let override = await getMemberModelOverride(ctx, sessionId);
323
+ // 官方界面切换同步:override 优先级高于官方链路,若用户在主会话界面用官方选择器改了模型,
324
+ // 不把新值同步进 override 就会出现「界面显示新模型、请求一直走旧模型」且无处可查
325
+ // (实测事故:批量写入的 override 把会话钉在已失效 provider 上,界面切换、改日志、重启全无效)。
326
+ // 做法是同步而不是清除:官方切换会写投影 pending,这里把它写回 override,
327
+ // 既让界面选择立刻生效,又保留 override 的跨轮持久能力(不会回落部署默认)。
328
+ try {
329
+ const pending = readOfficialPendingSelection(ctx, agent);
330
+ const adopt = adoptOfficialSelection(override, pending);
331
+ if (adopt) {
332
+ await setMemberModelOverride(ctx, sessionId, adopt.provider, adopt.model);
333
+ override = adopt;
334
+ log(ctx, `官方界面切换已同步到 override:${sessionId} → ${adopt.provider}/${adopt.model}`);
335
+ }
336
+ } catch (error) {
337
+ // 同步失败绝不打断请求:退回用旧 override(行为与修复前一致)
338
+ log(ctx, `官方切换同步失败(不影响本次请求): ${String(error?.message ?? error)}`);
339
+ }
321
340
  const applied = applyModelOverride(resolved, override);
322
341
  // 切换查证(事件驱动):记录本次请求**真实使用**的 provider/model;若该会话有待查证项,
323
342
  // 就在这里按事件判定结果并入队(切换本身不阻塞、不轮询 —— 只等事件到)。
@@ -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
+ }
@@ -55,8 +55,13 @@ async function renderMemberList(ctx, invocation) {
55
55
  lines.push("要查**实际**在用什么(含历史请求,不只本次进程)或切换:");
56
56
  lines.push(` /${COMMAND_NAME} <成员名或 sessionId> ← 查实际模型`);
57
57
  lines.push(` /${COMMAND_NAME} <成员名或 sessionId> <provider>/<模型> ← 切换并回显查证`);
58
- lines.push(` /${COMMAND_NAME} * <provider>/<模型> ← 全部成员切到该模型(回执带名单)`);
59
- lines.push(` /${COMMAND_NAME} <成员名|*> auto ← 跟随主代理(Lead)当前模型`);
58
+ lines.push("");
59
+ lines.push("和别人对齐 / 批量:");
60
+ lines.push(` /${COMMAND_NAME} * auto ← 最常用:所有子代理都换成「我自己现在用的」模型`);
61
+ lines.push(` /${COMMAND_NAME} <成员名> auto ← 只让这一个成员换成我自己现在用的模型`);
62
+ lines.push(` /${COMMAND_NAME} * <provider>/<模型> ← 所有子代理换成一个指定的模型`);
63
+ lines.push("");
64
+ lines.push("* = 全部成员(不含我自己);auto = 「跟我保持一致」,不用去查我用的是哪个模型。");
60
65
  return lines.join("\n");
61
66
  }
62
67
 
@@ -166,7 +171,7 @@ export async function handleModelSwitchCommand(ctx, invocation) {
166
171
  if (isAllTarget(targetRaw)) {
167
172
  return {
168
173
  kind: "error",
169
- text: `批量操作需要指定模型:\n /${COMMAND_NAME} * <provider>/<模型> ← 全部成员切到该模型\n /${COMMAND_NAME} * auto ← 全部成员跟随主代理(Lead)当前模型`,
174
+ text: `批量操作需要指定模型:\n /${COMMAND_NAME} * auto ← 所有子代理换成我自己现在用的模型(最常用)\n /${COMMAND_NAME} * <provider>/<模型> ← 所有子代理换成指定模型`,
170
175
  };
171
176
  }
172
177
  const found = findTargetAgent(ctx, targetRaw, { caller });
@@ -29,7 +29,7 @@ import { switchAgentModel } from "./switch.js";
29
29
  * 占位任务:只为让成员被创建出来,不产生实质动作。
30
30
  * 写得极短是为了把首轮消耗压到最低(这一轮拿不到指定模型,越短越不亏)。
31
31
  */
32
- const PLACEHOLDER_PROMPT = "先待命:不要执行任何操作,不要调用任何工具,不要读写任何文件,等待后续消息。";
32
+ export const PLACEHOLDER_PROMPT = "先待命:不要执行任何操作,不要调用任何工具,不要读写任何文件,等待后续消息。";
33
33
 
34
34
  /**
35
35
  * 等成员真正空闲(中断的取消信号被观测完)再投递。
@@ -39,7 +39,7 @@ const PLACEHOLDER_PROMPT = "先待命:不要执行任何操作,不要调用
39
39
  * 用宿主自己的 `waitForChange` 等状态变化(事件驱动),不睡固定时间。
40
40
  * @returns {Promise<{idle:boolean, status:string}>}
41
41
  */
42
- async function waitUntilIdle(teams, caller, name, signal, budget = 60) {
42
+ export async function waitUntilIdle(teams, caller, name, signal, budget = 60) {
43
43
  if (typeof teams?.listMembers !== 'function' || typeof teams?.waitForChange !== 'function') {
44
44
  // 老宿主没有这两个能力:退化为「不等待」,行为与之前一致
45
45
  return { idle: false, status: 'unknown' };
@@ -13,7 +13,11 @@ export {
13
13
  // 批量目标解析(空 / `*` / `all` = 全部成员,排除 Lead)与 Lead 定位
14
14
  collectTeamMembers, isAllTarget, resolveTargets, resolveLeader,
15
15
  } from "./locate.js";
16
- export { getMemberModelOverride, setMemberModelOverride, applyModelOverride, resetMemberModelCacheForTest } from "./override.js";
16
+ export {
17
+ getMemberModelOverride, setMemberModelOverride, applyModelOverride, resetMemberModelCacheForTest,
18
+ // 官方界面切换的同步:override 优先级高于官方链路,不同步就会出现「界面切了但请求不变」
19
+ adoptOfficialSelection, readOfficialPendingSelection,
20
+ } from "./override.js";
17
21
  export { waitForIdle, appendSelectionEventToLog, switchAgentModel } from "./switch.js";
18
22
  // 切换查证(事件驱动,注册 job 不阻塞):登记待查证项 → 每次请求记录实际模型 → 按事件判定 → 结果回报
19
23
  export {
@@ -33,3 +37,5 @@ export { registerPermSwitchCommand, handlePermSwitchCommand } from "./permission
33
37
  export { readLeaderRoute } from "./lead.js";
34
38
  // 批量应用与回执渲染(串行执行、逐个隔离失败、回执带完整名单)
35
39
  export { applyModelToMany, applyPermissionToMany, renderBatchReport, memberDisplay } from "./batch.js";
40
+ // 换名通道:新建成员 + 搬会话内容(成员名在宿主里不可变,只能走这条路)
41
+ export { cloneMemberAs, transplantSessionLog, renderCloneMemberResult } from "./clone-member.js";
@@ -77,6 +77,56 @@ export function applyModelOverride(resolved, override) {
77
77
  return { ...rest, provider: override.provider, model: override.model };
78
78
  }
79
79
 
80
+ /**
81
+ * 判断是否要把「官方界面刚选的模型」同步进 override。
82
+ *
83
+ * 【为什么需要它】本插件的 override 是**每轮强制改写请求**的持久状态,优先级高于官方链路。
84
+ * 于是有个致命副作用:一旦写入,用户在主会话界面用官方模型选择器切换**完全无效** ——
85
+ * 界面显示新模型、实际请求走 override 里的旧模型,而且不报错、不提示、无清除入口
86
+ * (实测事故:批量「跟随主代理」把一批会话钉在某个已失效 provider 上,界面怎么切都不动,
87
+ * 改会话日志、重启宿主也都没用,因为 override 存在 domain 里且每轮覆盖)。
88
+ *
89
+ * 【做法:同步,而不是清除】官方切换会往 `modelSelection` 投影写 `pending`;
90
+ * 而插件在 hook 里用 override 改写了请求配置 → 与 pending 不匹配 → `consumeSelection` 不会
91
+ * 清空它,于是 pending 会一直保留。每轮把它与 override 比对,不一致就**把 override 更新成
92
+ * pending 的值**:官方界面选择立刻生效,同时仍享有 override 的「跨轮持久」能力(不会回落)。
93
+ *
94
+ * 只在**已存在 override** 的会话上同步:没有 override 的会话不干预,交给官方链路自己跑。
95
+ *
96
+ * @param {{provider: string, model: string} | null} override 当前生效的 override
97
+ * @param {{provider: string, model: string} | null} pending 官方投影里的待生效选择
98
+ * @returns {{provider: string, model: string} | null} 需要写入的新 override;null = 无需同步
99
+ */
100
+ export function adoptOfficialSelection(override, pending) {
101
+ if (!override || !pending) return null;
102
+ if (typeof pending.provider !== "string" || typeof pending.model !== "string") return null;
103
+ if (pending.provider === "" || pending.model === "") return null;
104
+ if (override.provider === pending.provider && override.model === pending.model) return null;
105
+ return { provider: pending.provider, model: pending.model };
106
+ }
107
+
108
+ /**
109
+ * 从会话投影里读官方「待生效」的模型选择(官方界面切换会写这里)。
110
+ *
111
+ * 投影不可用、字段缺失、调用抛错一律返回 null(宁可不同步,也不要在 hook 里抛异常打断请求)。
112
+ *
113
+ * @param {object} ctx 插件 context
114
+ * @param {object} agent 当前请求所属 agent
115
+ * @returns {{provider: string, model: string} | null}
116
+ */
117
+ export function readOfficialPendingSelection(ctx, agent) {
118
+ try {
119
+ const projections = ctx?.sessionProjections ?? ctx?.get?.("sessionProjections");
120
+ const state = projections?.stateOf?.(agent?.session, "modelSelection");
121
+ const pending = state?.pending;
122
+ if (!pending || typeof pending !== "object") return null;
123
+ if (typeof pending.provider !== "string" || typeof pending.model !== "string") return null;
124
+ return { provider: pending.provider, model: pending.model };
125
+ } catch {
126
+ return null;
127
+ }
128
+ }
129
+
80
130
  /** 测试钩子:清空 override 缓存(单测在场景之间调用)。 */
81
131
  export function resetMemberModelCacheForTest() {
82
132
  memberModelCache.clear();
@@ -49,7 +49,7 @@ export async function handlePermSwitchCommand(ctx, invocation) {
49
49
  if (isAllTarget(targetRaw)) {
50
50
  return {
51
51
  kind: "error",
52
- text: `批量操作需要指定预设:\n /${COMMAND_NAME} * <预设名> ← 全部成员切到该预设\n /${COMMAND_NAME} * auto ← 全部成员跟随主代理(Lead)当前预设`,
52
+ text: `批量操作需要指定预设:\n /${COMMAND_NAME} * auto ← 所有子代理换成我自己现在的权限预设(最常用)\n /${COMMAND_NAME} * <预设名> ← 所有子代理换成指定预设`,
53
53
  };
54
54
  }
55
55
  const found = findTargetAgent(ctx, targetRaw, { caller });
@@ -21,6 +21,7 @@ import { registerContextUsageTool } from "../context-usage/index.js";
21
21
  import { registerMemoryRecallTool } from "../memory-recall/index.js";
22
22
  import { registerSessionMessagingTools } from "../session-messaging/index.js";
23
23
  import { registerPermSwitchCommand } from "./permission-command.js";
24
+ import { cloneMemberAs, renderCloneMemberResult } from "./clone-member.js";
24
25
 
25
26
  /**
26
27
  * 注册成员模型/权限相关的全部 AI 工具与聊天指令。
@@ -312,6 +313,40 @@ async function describeSwitchSnapshot(ctx, sessionId, provider, model) {
312
313
  log(ctx, `记忆取回工具注册失败(不影响其它工具): ${String(error?.message ?? error)}`);
313
314
  }
314
315
  log(ctx, "成员模型工具已注册 (list_models / set_member_model)");
316
+ // 把成员「换名」成新成员(新建 + 搬会话内容)。
317
+ // 宿主把成员名钉死为不可变身份字段:`team/member` 投影里 name/provider/context 一变就抛
318
+ // `changed immutable identity fields`,且只允许 provisioning → active 这一次写入,
319
+ // 宿主也没有任何 rename 接口。所以换名只能「新建成员 + 搬旧会话内容」。
320
+ // 这条路成立是因为成员名与会话内容分处两地:名字在 Lead 日志的 team/member 事件里,
321
+ // 内容在成员自己的日志里——搬内容不会触碰不可变字段。
322
+ try {
323
+ tools.register(defineTool({
324
+ name: "clone_member_as",
325
+ description: "把一个成员的会话内容克隆到一个新成员(用于换名:宿主不允许改成员名,只能新建+搬内容)。内部流程:解析源成员 → spawn 新成员(占位任务)→ 中断并等它空闲 → 把源会话事件搬进新成员日志(重排 seq、带 checksum、写前自检、自动备份)。只由 Team Lead 调用。旧成员不会被改动,需要脱手请自行删除。",
326
+ parameters: {
327
+ source: { type: "string", required: true, description: "源成员:成员名 / sessionId / 标题关键字" },
328
+ name: { type: "string", required: true, description: "新成员名(lower-kebab-case,不能与现有成员重复)" },
329
+ description: { type: "string", description: "新成员职责简述(默认「克隆自 <源成员>」)" },
330
+ withHistory: { type: "boolean", description: "是否搬历史内容(默认 true)。传 false = 只建空成员" },
331
+ provider: { type: "string", description: "写进新成员 descriptor 的模型 provider(省略则沿用宿主默认)" },
332
+ model: { type: "string", description: "写进新成员 descriptor 的模型 id(省略则沿用宿主默认)" },
333
+ subagentProvider: { type: "string", description: "子代理 provider(默认取 ctx.subagents.list() 第一个,通常就是 spawn)。一般不用填" },
334
+ },
335
+ output: {
336
+ schema: { type: "string" },
337
+ render(args, value) {
338
+ return [{ type: "text", text: String(value) }];
339
+ },
340
+ },
341
+ async execute(args, exec) {
342
+ const result = await cloneMemberAs(ctx, exec, args);
343
+ return renderCloneMemberResult(args, result);
344
+ },
345
+ }));
346
+ log(ctx, "成员克隆工具已注册 (clone_member_as)");
347
+ } catch (error) {
348
+ log(ctx, `成员克隆工具注册失败(不影响其它工具): ${String(error?.message ?? error)}`);
349
+ }
315
350
  // 聊天框指令 `/model-switch`:用户自己就能看/切成员模型的入口(与 AI 工具同一套切换 + 查证路径)。
316
351
  // 异步注册 + 独立兜底:宿主 commands 服务或 brand 包不可用时只留痕,绝不影响其它功能(同既有约定)。
317
352
  registerModelSwitchCommand(ctx)
@@ -7,12 +7,23 @@
7
7
  // /compaction-model · /auto-rename-model(两条模型设置)、/auto-continue-gate(自动继续闸门)
8
8
  import { readJson, send } from '../http.js';
9
9
  import { log } from '../shared/log.js';
10
- import { readSwitch, patchSwitch } from '../core/config.js';
10
+ // cfg 是核心配置的 live binding(`export let cfg`):模板落盘目录与浏览根都由它算出来。
11
+ // 拆分时漏掉这一个 import 会让 registerSettingsRoutes 在**调用期**抛 ReferenceError,
12
+ // 进而使整个 webServer inject 回调中断 —— 表现为「settings 及其之后的所有路由全部 404」。
13
+ import { cfg, readSwitch, patchSwitch, resolveBrowseRoot, resolveDshHome } from '../core/config.js';
11
14
  import { invalidateSessionListCache } from '../sessions/list-cache.js';
12
15
  import { pluginDomain } from '../core/domain.js';
13
- import { resolveBrowseRoot, resolveDshHome } from '../core/config.js';
14
16
  import { setTemplateStateCache } from '../shared/test-hooks.js';
15
- import { saveTemplate, saveTemplateFromUrl, saveTemplateFromPath, listTemplateDir, removeTemplate, readTemplateSync } from '../template-inject.js';
17
+ // 405 响应文案与内置全文检索入口同样是拆分遗漏项:前者在非 GET/POST 分支里被直接引用
18
+ // (漏了会在方法不对时抛 ReferenceError),后者用 typeof 探测(漏了不会抛,但会永远判成不可用)。
19
+ import { MSG_METHOD_ONLY_GET_POST } from '../shared/constants.js';
20
+ import { searchSessions } from './search.js';
21
+ // 模板读写实现 + 三个模板常量:常量只在表达式里被引用(不是函数调用),
22
+ // 按「找未定义函数调用」的扫描抓不到,拆分时漏过一轮。
23
+ import {
24
+ saveTemplate, saveTemplateFromUrl, saveTemplateFromPath, listTemplateDir, removeTemplate, readTemplateSync,
25
+ TEMPLATE_SLOTS, TEMPLATE_DEFAULTS, TEMPLATE_MAX_BYTES,
26
+ } from '../template-inject.js';
16
27
 
17
28
  /**
18
29
  * 注册模板与设置类路由。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-session-conductor",
3
- "version": "1.3.16",
3
+ "version": "1.3.18",
4
4
  "description": "DSH session management enhancement: archive/restore sessions, batch & conditional delete, AI auto-rename, interrupted-session auto-resume, release idle sessions, workspace grouping, undo last message, repair damaged session logs, value analysis, full-text search, per-member model switching (list_models / set_member_model), and plan/closing template injection (system-prompt + context).",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",