dsh-session-conductor 1.0.10 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,7 +22,9 @@
22
22
  | 模板注入 | 方案模板 / 收尾模板:本地设备上传 / 在线网址 / DSH 目录浏览三种导入,内容可再编辑,注入系统提示词(方案/收尾各一条上下文注入) | 设置 → 会话管理 → 模板注入 |
23
23
  | 自动重命名模型 | 指定便宜/专用模型做标题分析 | 设置 → 会话管理 |
24
24
  | 成员模型切换 | 给队友/子代理单独切模型(只动该成员会话,不影响其他成员):先用 `list_models` 拿确切模型 id,再 `set_member_model` 按成员名/sessionId 切换(写 `model/selection` 事件交给官方投影接管,跨轮持久;未挂载成员也能切,运行中会先等它暂停) | AI 工具 `list_models` / `set_member_model` |
25
- | 上下文用量自查 | AI 按需查自己会话的上下文占用(百分比、已用/容量 tokens)与构成(系统提示词/工具定义/对话消息),占用达 85% 时附收敛建议。数据与界面圆环同源(官方 `contextPressure` / `contextBreakdown` 投影),只读、无参数 | AI 工具 `context_usage` |
25
+ | 上下文用量自查 | AI 按需查自己会话的上下文占用(百分比、已用/容量 tokens)与构成(系统提示词/工具定义/对话消息),占用达 85% 时附收敛建议。数据与界面圆环同源(官方 `contextPressure` / `contextBreakdown` 投影),只读、无参数。**另:每轮把实时用量注入消息尾部**(AI 每轮都能直接看到数字;注入在尾部而非系统提示词前缀 ⇒ 前缀稳定、prompt cache 仍命中;按 turn 门控,一回合只注一次;配置 `injectContextUsage` 可关) | AI 工具 `context_usage` + 每轮注入 |
26
+ | 跨会话消息 | AI 直接给另一个会话发消息(不经过人转达):先 `list_sessions` 挑目标,再 `send_to_session` 投递;目标空闲时**由本体自动唤醒**它跑一个回合,运行中则排到其下一回合边界(`mode:"steer"` 改为注入当前回合)。全部走本体官方 `prompt` API(幂等 `requestId`、不自己写会话日志)。**支持 `dryRun` 预览**(报告目标状态与限流余量,不投递、不消耗配额)。`list_sessions` 支持 **`query` 子串过滤 + `limit`/`offset` 分页**(默认只给 20 条,避免整表塞进调用方上下文)。硬约束:禁自投、协作链跳数 ≥3 拒绝、同一对会话 60s 内最多 5 条 | AI 工具 `list_sessions` / `send_to_session` |
27
+ | 指定模型新建会话 | AI 新建会话并可**直接指定 provider/model**(建完即切到该模型),可选带一条初始消息让它立刻开始干活。**缺省工作目录 = 调用方会话自己的 cwd**(不落在 DSH 安装目录里)。本体 `create` 本身不带模型,所以是 `create` → `selectModel` 两步;任一步失败会如实回执(如「会话已建,但模型未设置」),不谎报成功。**支持 `dryRun` 预览**(含模型是否存在于目录的核对),不建会话 | AI 工具 `create_session` |
26
28
 
27
29
  ## 界面预览
28
30
 
package/lib/apply.js CHANGED
@@ -28,7 +28,8 @@ import {
28
28
  maybeScheduleContinue, runAutoContinueSession, runAutoScan, scheduleScan, AUTO_CONTINUE_SCAN_DELAY_MS,
29
29
  } from "./features/continue/scan.js";
30
30
  import { num } from "./shared/util.js";
31
- import { registerContextUsageTool } from "./features/context-usage/index.js";
31
+ import { registerContextUsageTool, createContextUsageInjector } from "./features/context-usage/index.js";
32
+ import { registerSessionMessagingTools } from "./features/session-messaging/index.js";
32
33
  import { registerGroupRoutes } from "./group.js";
33
34
  import { saveTemplate, saveTemplateFromUrl, saveTemplateFromPath, listTemplateDir, removeTemplate, readTemplateSync, readCompletionConventionSync, collectTemplateSlotText, planGateAllows, planEnforceDenyMessage, TEMPLATE_DEFAULTS, TEMPLATE_SLOTS, TEMPLATE_MAX_BYTES, PLAN_ENFORCE_EDIT_TOOLS, PLAN_ENFORCE_BASH_WRITE_RE } from "./template-inject.js";
34
35
  import { name } from "./shared/constants.js";
@@ -89,7 +90,7 @@ export async function apply(ctx, config = {}) {
89
90
  });
90
91
 
91
92
  // 会话分组(原 dsh-session-group 已合并):仅保留只读展示(status/list)
92
- // + 分组下新建会话(new-session,workspaceId 缺省 = 上次会话工作区);分组管理能力回归
93
+ // + 分组下新建会话(new-session,workspaceId 缺省 = 最近活跃会话的工作区);分组管理能力回归
93
94
  // DSH 官方 workspace 机制。
94
95
  // 进程级兜底——宿主 bin.ts 未注册 unhandledRejection/uncaughtException 处理,
95
96
  // Node 15+ 默认 --unhandled-rejections=throw,任何插件的异步 rejection 逃逸都会**直接杀死
@@ -1278,6 +1279,34 @@ export async function apply(ctx, config = {}) {
1278
1279
  return decision;
1279
1280
  }
1280
1281
  });
1282
+
1283
+ // 上下文用量「每轮注入」:把实时用量作为一条 user 消息追加到该步消息批次尾部。
1284
+ // · 为什么放**尾部**而不是系统提示词前缀:前缀保持稳定 ⇒ prompt cache 仍命中,
1285
+ // 而 AI 每轮都能直接看到数字(放前缀会让前缀每轮变化,缓存失效、成本上升)。
1286
+ // · 为什么按 **turn** 门控:agent/pre-step 每步都触发,若每步注入,一个回合里会刷出
1287
+ // 多条重复的用量行(噪音 + 白烧 token)。
1288
+ // · 默认开启(core/config.js 的 injectContextUsage);关掉即退化为「按需调用 context_usage 工具」。
1289
+ // · 读不到投影/服务未挂载时注入器返回空串 ⇒ 静默跳过,绝不影响正常回合。
1290
+ const usageInjector = createContextUsageInjector({ enabled: cfg?.injectContextUsage !== false });
1291
+ ctx.on("agent/pre-step", async ({ agent, turn, signal }, next) => {
1292
+ const decision = await next();
1293
+ if (decision?.kind === "reject" || signal?.aborted) return decision;
1294
+ // 与模板注入同款守卫:工作区自测环境没有 @deepseek-ai/* 依赖,createUserMessage 会是 null
1295
+ if (typeof createUserMessage !== "function") return decision;
1296
+ if (!usageInjector.shouldInject(agent, turn)) return decision;
1297
+ const usageText = usageInjector.textFor(ctx, agent);
1298
+ if (!usageText) return decision;
1299
+ try {
1300
+ const injected = createUserMessage({
1301
+ content: [{ type: "text", text: usageText }],
1302
+ source: { kind: "plugin:dsh-session-conductor", plugin: "dsh-session-conductor", form: "instructions" },
1303
+ });
1304
+ return { ...decision, messages: [...(decision.messages || []), injected] };
1305
+ } catch (error) {
1306
+ log(ctx, `上下文用量注入失败: ${String(error?.message ?? error)}`);
1307
+ return decision;
1308
+ }
1309
+ });
1281
1310
  }
1282
1311
 
1283
1312
  // defineTool 动态加载:工作区自测环境没有 DSH 依赖,装在 profile 内必可解析(与 dsh-git-push 同法)。
@@ -1356,6 +1385,15 @@ export async function apply(ctx, config = {}) {
1356
1385
  if (registerContextUsageTool(ctx, defineTool, tools)) {
1357
1386
  log(ctx, "上下文用量工具已注册 (context_usage)");
1358
1387
  }
1388
+ // 跨会话消息 + 指定模型新建会话(三个工具一次注册)。
1389
+ // 整体包 try/catch:注册期抛错会连累同批其它工具(本项目历史事故),失败只留痕、不阻断。
1390
+ try {
1391
+ if (registerSessionMessagingTools(ctx, defineTool, tools)) {
1392
+ log(ctx, "跨会话工具已注册 (list_sessions / send_to_session / create_session)");
1393
+ }
1394
+ } catch (error) {
1395
+ log(ctx, `跨会话工具注册失败(不影响其它工具): ${String(error?.message ?? error)}`);
1396
+ }
1359
1397
  log(ctx, "成员模型工具已注册 (list_models / set_member_model)");
1360
1398
  });
1361
1399
  }
@@ -23,6 +23,7 @@ export const DEFAULTS = {
23
23
  maxContinuesPerSession: 3, // 每会话自动续跑总次数上限(防死循环)
24
24
  turnTimeoutMs: 20 * 60 * 1000, // 单回合续跑最长等待,超时取消
25
25
  scanIntervalMs: 5 * 60 * 1000, // 周期扫描间隔
26
+ injectContextUsage: true, // 每轮把上下文用量注入消息尾部(默认开;关掉则只有 context_usage 工具按需查)
26
27
  };
27
28
 
28
29
  /** 运行期配置(面板/测试可改)。外部只读引用;改整体请用 setConfig。 */
@@ -182,6 +182,78 @@ export function formatContextUsage(snapshot) {
182
182
  return lines.join("\n");
183
183
  }
184
184
 
185
+ /**
186
+ * 把快照格式化为**单行紧凑文本**(用于每轮注入到消息尾部)。
187
+ *
188
+ * 与 `formatContextUsage` 的分工:那个是工具输出(详细、多行、带口径说明),
189
+ * 这个是每轮都要进上下文的**常驻一行** —— 必须短,否则注入本身就吃掉可观 token。
190
+ * 拿不到数据时返回空串(调用方据此跳过注入,不塞空消息)。
191
+ *
192
+ * @param {object} snapshot `readContextUsage` 的返回
193
+ * @returns {string} 单行文本;无可用数据时为空串
194
+ */
195
+ export function formatContextUsageCompact(snapshot) {
196
+ if (!snapshot?.ok) return "";
197
+ const occ = snapshot.occupancy;
198
+ if (!occ) return "";
199
+ const parts = [
200
+ `【上下文用量】已用 ${occ.percent}%(~${formatTokens(occ.usedTokens)} / ${formatTokens(occ.contextWindow)} tokens)`,
201
+ ];
202
+ const b = snapshot.breakdown;
203
+ if (b) {
204
+ parts.push(
205
+ `构成:系统 ~${formatTokens(b.systemTokens ?? 0)} | 工具 ~${formatTokens(b.toolsTokens ?? 0)}`
206
+ + ` | 消息 ~${formatTokens(b.messageTokens ?? 0)}`,
207
+ );
208
+ }
209
+ if (occ.percent >= HIGH_USAGE_PERCENT) {
210
+ parts.push(`⚠️ 已达 ${HIGH_USAGE_PERCENT}%:建议先收敛(大结果落盘 / 先小结已完成部分 / 把剩余任务拆到新会话)`);
211
+ }
212
+ return parts.join(" | ");
213
+ }
214
+
215
+ /**
216
+ * 构造「每轮注入一次用量」的注入器(供 `agent/pre-step` 使用)。
217
+ *
218
+ * 两个必须做对的地方:
219
+ * 1. **按 turn 门控**:`agent/pre-step` 每步都触发,若每步都注入,一个回合里会刷出多条
220
+ * 重复的用量行(既是噪音也白烧 token)。这里用 `WeakMap<agent, turn>` 记账,turn 变了才注入。
221
+ * 2. **失败即静默跳过**:拿不到投影/服务未挂载时返回空串,调用方跳过注入 —— 绝不能因为
222
+ * 用量读不到而影响正常回合。
223
+ *
224
+ * 依赖以参数注入(默认用本模块实现),便于单测替换。
225
+ *
226
+ * @param {object} [deps] { readUsage, format, enabled }
227
+ * @returns {{shouldInject: (agent:object, turn:number)=>boolean, textFor: (ctx:object, agent:object)=>string}}
228
+ */
229
+ export function createContextUsageInjector({
230
+ readUsage = readContextUsage,
231
+ format = formatContextUsageCompact,
232
+ enabled = true,
233
+ } = {}) {
234
+ const lastTurnByAgent = new WeakMap();
235
+ return {
236
+ /** 该 agent 的这一轮是否要注入(会记账:同一 turn 只返回一次 true)。 */
237
+ shouldInject(agent, turn) {
238
+ if (!enabled || !agent) return false;
239
+ const t = Number(turn);
240
+ if (!Number.isFinite(t)) return false;
241
+ if (lastTurnByAgent.get(agent) === t) return false;
242
+ lastTurnByAgent.set(agent, t);
243
+ return true;
244
+ },
245
+ /** 生成注入文本;读不到数据或格式化失败时返回空串。 */
246
+ textFor(ctx, agent) {
247
+ try {
248
+ const text = format(readUsage(ctx, agent));
249
+ return typeof text === "string" ? text : "";
250
+ } catch {
251
+ return "";
252
+ }
253
+ },
254
+ };
255
+ }
256
+
185
257
  /**
186
258
  * 注册 `context_usage` AI 工具。
187
259
  *
@@ -0,0 +1,28 @@
1
+ // dsh-session-conductor — 跨会话消息域(域出口)
2
+ //
3
+ // 【本域职责】给 AI 三个可直接调用的工具:
4
+ // · list_sessions 列出本机会话(挑投递目标用)
5
+ // · send_to_session 给指定会话投递一条消息(目标空闲时由本体唤醒;支持 dryRun 预览)
6
+ // · create_session 新建会话,可指定 provider/model,可选带初始消息(支持 dryRun 预览)
7
+ //
8
+ // 【文件分工】logic.js = 常量 / 纯函数 / 限流状态机 / 本体调用封装;
9
+ // tools.js = 三个工具的注册;本文件只做再导出 —— 域外只 import 本文件,
10
+ // 内部结构可自由调整(照 features/member-model 的写法)。
11
+ //
12
+ // 【设计口径】全部走本体官方 API(sessionController 的 create / selectModel / prompt),
13
+ // 不自己写会话日志 —— 原因见 logic.js 头部注释。
14
+
15
+ export { registerSessionMessagingTools } from "./tools.js";
16
+ export {
17
+ formatSessionList,
18
+ resetMessagingStateForTest,
19
+ statusOf,
20
+ shortError,
21
+ mapPromptError,
22
+ checkModelPair,
23
+ TEXT_LIMIT,
24
+ HOPS_LIMIT,
25
+ RATE_MAX,
26
+ LIST_DEFAULT_LIMIT,
27
+ LIST_MAX_LIMIT,
28
+ } from "./logic.js";
@@ -0,0 +1,277 @@
1
+ // dsh-session-conductor — 跨会话消息域:常量、纯函数与本体调用辅助
2
+ //
3
+ // 【本文件职责】只放「不注册工具」的部分:限额常量、会话列表渲染、限流状态机、
4
+ // 以及所有对本体 `sessionController` 的调用封装。工具定义在 tools.js,域出口在 index.js。
5
+ //
6
+ // 【为什么全部走本体官方 API,而不是自己写会话日志】
7
+ // 本体把「投递一条消息给某个会话」做成一等公民:session-controller 的
8
+ // `@Remote('prompt')`,其受理流程**会先 resume 目标会话**(唤醒语义由本体保证),
9
+ // 且请求带 `requestId` 幂等键(本体把它持久化在那条被接受的消息上)。
10
+ // 反面:会话日志里 inbox 家族**只有 `agent/inbox/spliced` 落盘**,而它的 fold
11
+ // 对非法 splice **直接抛错**(message.id 重复、start 越界都会让整份会话读不出来)
12
+ // —— 自己写日志等于拿对方会话的完整性冒险。因此本域**不碰任何日志文件**。
13
+
14
+ import { log } from "../../shared/log.js";
15
+
16
+ /** 单条消息长度上限(字符):够写交接要点,不够塞长文档(长内容应落共享文件)。 */
17
+ export const TEXT_LIMIT = 8 * 1024;
18
+ /** 协作链跳数上限:到了就说明该改用共享文件交接,而不是继续互相投递。 */
19
+ export const HOPS_LIMIT = 3;
20
+ /** 限流窗口与条数:同一对会话在该窗口内最多投这么多条,防互相唤醒风暴。 */
21
+ export const RATE_WINDOW_MS = 60_000;
22
+ export const RATE_MAX = 5;
23
+ /** 本体 prompt 受理是本地操作,给足超时但别无限等。 */
24
+ export const PROMPT_TIMEOUT_MS = 60_000;
25
+
26
+ // list_sessions 的分页默认值:会话多时整表输出会白烧调用方上下文,
27
+ // 所以默认只给一页(20 条),需要更多用 offset 翻页、或先用 query 收窄。
28
+ export const LIST_DEFAULT_LIMIT = 20;
29
+ export const LIST_MAX_LIMIT = 100;
30
+
31
+ // ── 纯函数 ────────────────────────────────────────────────────────────────
32
+
33
+ /** 会话条目 → 状态词(running=正在跑回合 / idle=已挂载空闲 / inactive=冷会话)。 */
34
+ export function statusOf(item) {
35
+ if (item?.running) return "running";
36
+ return item?.live ? "idle" : "inactive";
37
+ }
38
+
39
+ /** 时间戳 → 本地可读时间(无值时不假装有值)。 */
40
+ export function formatTime(value) {
41
+ const n = Number(value);
42
+ if (!Number.isFinite(n) || n <= 0) return "(未知)";
43
+ try {
44
+ return new Date(n).toLocaleString("zh-CN", { hour12: false });
45
+ } catch {
46
+ return String(n);
47
+ }
48
+ }
49
+
50
+ /**
51
+ * 把会话列表渲染成给 AI 看的文本(纯函数,便于单测)。
52
+ * @param items buildSessionListCached 的产出
53
+ * @param selfId 当前会话 id(用于标出「当前会话」,可为 null)
54
+ * @returns {string} 多行文本;空列表时返回固定提示
55
+ */
56
+ /**
57
+ * 把会话列表格式化为工具输出(支持子串过滤 + 分页)。
58
+ *
59
+ * 【为什么要分页/过滤】会话多了以后整表输出会白烧 AI 上下文(本机实测 80+ 会话一次输出
60
+ * 上百行);调用方通常只需要「找目标」这一件事,因此默认由工具侧给一个小 limit,
61
+ * 需要更多再用 offset 翻页,或先用 query 收窄。
62
+ *
63
+ * @param {Array} items buildSessionListCached 的产出
64
+ * @param {string} selfId 当前会话 id(用于标注「← 当前会话」)
65
+ * @param {object} [opts] limit(本页条数;不传=全部,保持既有调用方语义)/ offset / query(子串,不区分大小写)
66
+ * @returns {string} 多行文本
67
+ */
68
+ export function formatSessionList(items, selfId, opts = {}) {
69
+ const all = Array.isArray(items) ? items : [];
70
+ // 防御:会话列表构建偶尔会混入缺 id 的脏条目(实测见过一条),直接渲染会输出
71
+ // 「undefined | (无标题) …」误导调用方 —— 这里按 id 过滤,并如实报告跳过了几条。
72
+ const usable = all.filter((item) => item && typeof item.id === "string" && item.id !== "");
73
+ const skipped = all.length - usable.length;
74
+ if (usable.length === 0) {
75
+ return skipped > 0 ? `没有可用会话(${skipped} 条缺 id 的脏数据已跳过)。` : "没有可见会话。";
76
+ }
77
+ // 子串过滤:id / 标题 / 工作区 任一命中即可(会话一多,先收窄再翻页比整表更省)
78
+ const rawQuery = typeof opts.query === "string" ? opts.query.trim() : "";
79
+ const matched = rawQuery
80
+ ? usable.filter((item) => `${item.id} ${item.title ?? ""} ${item.cwd ?? ""}`.toLowerCase().includes(rawQuery.toLowerCase()))
81
+ : usable;
82
+ if (matched.length === 0) {
83
+ return `没有匹配「${rawQuery}」的会话(共 ${usable.length} 个)。`;
84
+ }
85
+ // 分页:不传 limit = 全部(保持既有调用方语义);offset 越界时给空页提示而不是报错
86
+ const total = matched.length;
87
+ const limit = Number.isSafeInteger(opts.limit) && opts.limit > 0 ? opts.limit : total;
88
+ const offset = Number.isSafeInteger(opts.offset) && opts.offset > 0 ? opts.offset : 0;
89
+ const page = matched.slice(offset, offset + limit);
90
+ if (page.length === 0) {
91
+ return `offset ${offset} 超出范围(共 ${total} 个会话,本页 0 条)。`;
92
+ }
93
+ const lines = page.map((item) => {
94
+ const flags = [statusOf(item)];
95
+ if (item?.archived) flags.push("已归档");
96
+ if (selfId && item?.id === selfId) flags.push("← 当前会话");
97
+ return `- ${item.id} | ${String(item?.title ?? "(无标题)")} | ${String(item?.cwd ?? "(无工作区)")}`
98
+ + ` | ${flags.join(" / ")} | 最后活动 ${formatTime(item?.updatedAt)}`;
99
+ });
100
+ const scope = rawQuery ? `(匹配「${rawQuery}」)` : "";
101
+ const head = `共 ${total} 个会话${scope},本页 ${page.length} 条(offset ${offset}):`;
102
+ const remaining = total - offset - page.length;
103
+ const more = remaining > 0 ? `\n还有 ${remaining} 条,用 offset=${offset + page.length} 取下一页。` : "";
104
+ const tail = skipped > 0 ? `\n(另有 ${skipped} 条缺 id 的脏数据已跳过)` : "";
105
+ return `${head}\n${lines.join("\n")}${more}${tail}`;
106
+ }
107
+
108
+ /** 把远端错误压成「短码: 原文」一行,便于回执如实说明失败原因。 */
109
+ export function shortError(error) {
110
+ const code = error?.code ?? error?.name ?? "";
111
+ const msg = String(error?.message ?? error);
112
+ return code ? `${code}: ${msg}` : msg;
113
+ }
114
+
115
+ /** 本体错误码 → 本工具回执的短码(认不出就标 unknown,不臆造)。 */
116
+ export function mapPromptError(error) {
117
+ const text = `${error?.code ?? ""} ${error?.message ?? ""}`;
118
+ if (/session\/not-found/.test(text)) return "session-not-found";
119
+ if (/session\/agent-busy/.test(text)) return "target-busy";
120
+ if (/session\/model-unavailable/.test(text)) return "target-no-model";
121
+ if (/gateway\/bad-request/.test(text)) return "bad-request";
122
+ if (/session\/attachment-invalid/.test(text)) return "attachment-invalid";
123
+ if (/session\/invalid-time-zone/.test(text)) return "bad-time-zone";
124
+ return "unknown";
125
+ }
126
+
127
+ // ── 会话/调用方身份 ───────────────────────────────────────────────────────
128
+
129
+ /** 取本体 sessionController 服务(本插件既有取法,见 features/member-model/switch.js)。 */
130
+ export function sessionControllerOf(ctx) {
131
+ try {
132
+ return ctx.get?.("sessionController") ?? null;
133
+ } catch {
134
+ return null;
135
+ }
136
+ }
137
+
138
+ /** 当前会话 id(用于禁自投与 isSelf 标记)。 */
139
+ export function currentSessionId(agent) {
140
+ return agent?.session?.id ?? null;
141
+ }
142
+
143
+ /**
144
+ * 调用方会话的工作目录。
145
+ * 用途:`create_session` 不传 cwd/workspaceId 时的缺省值 —— 新会话落在**发起它的那个会话**
146
+ * 的工作目录里,而不是本体的默认 cwd(实测是 DSH 安装目录 `/volume1/@appstore/DeepSeekHarness-NAS`,
147
+ * 对干活毫无意义)。
148
+ */
149
+ export function callerCwd(agent) {
150
+ const cwd = agent?.session?.header?.cwd;
151
+ return typeof cwd === "string" && cwd !== "" ? cwd : "";
152
+ }
153
+
154
+ /**
155
+ * 懒加载会话列举。
156
+ * 为什么不在顶层 import:`sessions/list.js` 会拉起 `core/domain.js` →
157
+ * `@deepseek-ai/dsh-storage-domain`(宿主包),工作区里解析不到 ⇒ 本模块将无法被单测
158
+ * 直接 import。改为用到时再加载,行为不变但模块可独立测试(同 apply.js 的懒加载思路)。
159
+ */
160
+ export async function listSessions(ctx) {
161
+ const { buildSessionListCached } = await import("../../sessions/list.js");
162
+ return buildSessionListCached(ctx, {});
163
+ }
164
+
165
+ /**
166
+ * best-effort 描述目标会话(dryRun 用):从会话列表里找到它,报告状态与工作区。
167
+ * 列表不可用时如实说明原因,**不阻断**(dryRun 的价值在于「早知道」,不该因为列表拿不到就失败)。
168
+ */
169
+ export async function describeTarget(ctx, sessionId) {
170
+ try {
171
+ const items = await listSessions(ctx);
172
+ const hit = (Array.isArray(items) ? items : []).find((item) => item?.id === sessionId);
173
+ if (!hit) return "(⚠️ 会话列表里没有这个 id —— 可能写错了,或它是刚建、尚未落盘的会话)";
174
+ return `(${statusOf(hit)}${hit.archived ? " / 已归档" : ""} | ${String(hit.cwd ?? "(无工作区)")})`;
175
+ } catch (error) {
176
+ return `(目标存在性无法核对:${shortError(error)})`;
177
+ }
178
+ }
179
+
180
+ /**
181
+ * 在模型目录里核对 provider/model 是否存在(dryRun 用,best-effort)。
182
+ * @returns {Promise<{checked:boolean, found:boolean, hint:string}>} 目录不可用时 checked=false,不阻断。
183
+ */
184
+ export async function checkModelPair(sc, provider, model) {
185
+ try {
186
+ if (typeof sc?.modelCatalog !== "function") {
187
+ return { checked: false, found: false, hint: "本体未提供 modelCatalog,无法预校验" };
188
+ }
189
+ const catalog = await sc.modelCatalog();
190
+ const group = (catalog?.groups ?? []).find((g) => g?.id === provider);
191
+ if (!group) {
192
+ return { checked: true, found: false, hint: `provider「${provider}」不在模型目录里(先用 list_models 查)` };
193
+ }
194
+ const hit = (group.models ?? []).some((m) => m?.id === model);
195
+ return {
196
+ checked: true,
197
+ found: hit,
198
+ hint: hit ? "" : `model「${model}」不在 provider「${provider}」下(先用 list_models 查)`,
199
+ };
200
+ } catch (error) {
201
+ return { checked: false, found: false, hint: `模型目录查询失败:${shortError(error)}` };
202
+ }
203
+ }
204
+
205
+ // ── 限流与在途去重(进程内;重启即清空,不落盘)────────────────────────────
206
+
207
+ /** `${from}->${to}` → 投递时间戳数组。 */
208
+ const rateBuckets = new Map();
209
+ /** 正在投递的 requestId(防同一请求并发重复)。 */
210
+ const inFlight = new Set();
211
+
212
+ /** 测试用:清空限流与在途状态。 */
213
+ export function resetMessagingStateForTest() {
214
+ rateBuckets.clear();
215
+ inFlight.clear();
216
+ }
217
+
218
+ /** 同一对会话是否已超限(未超则记一次)。 */
219
+ export function hitRateLimit(from, to, now = Date.now()) {
220
+ const key = `${from ?? "?"}->${to}`;
221
+ const hits = (rateBuckets.get(key) ?? []).filter((at) => now - at < RATE_WINDOW_MS);
222
+ if (hits.length >= RATE_MAX) {
223
+ rateBuckets.set(key, hits);
224
+ return true;
225
+ }
226
+ hits.push(now);
227
+ rateBuckets.set(key, hits);
228
+ return false;
229
+ }
230
+
231
+ /** 只读探测限流状态(dryRun 用:报告「会不会被限流」,但不消耗配额)。 */
232
+ export function peekRateLimit(from, to, now = Date.now()) {
233
+ const key = `${from ?? "?"}->${to}`;
234
+ const hits = (rateBuckets.get(key) ?? []).filter((at) => now - at < RATE_WINDOW_MS);
235
+ return { limited: hits.length >= RATE_MAX, used: hits.length };
236
+ }
237
+
238
+ /** 在途表是否已有该 requestId(真投前查重用)。 */
239
+ export function isInFlight(requestId) {
240
+ return inFlight.has(requestId);
241
+ }
242
+
243
+ /** 标记/解除在途(真投前后各一次)。 */
244
+ export function markInFlight(requestId) {
245
+ inFlight.add(requestId);
246
+ }
247
+
248
+ export function clearInFlight(requestId) {
249
+ inFlight.delete(requestId);
250
+ }
251
+
252
+ /** 生成幂等键:本体把它持久化在那条被接受的消息上,重试传同键不会重复投递。 */
253
+ export function newRequestId() {
254
+ const rand = Math.random().toString(36).slice(2, 10);
255
+ return `sc-${Date.now().toString(36)}-${rand}`;
256
+ }
257
+
258
+ // ── 本体调用封装 ──────────────────────────────────────────────────────────
259
+
260
+ /** 文本 → 本体要求的 content 块数组(字段名以 types.ts 的 PromptContentPart 为准)。 */
261
+ export function contentOf(text) {
262
+ return [{ type: "text", text: String(text) }];
263
+ }
264
+
265
+ /** 投递一条消息到目标会话(走本体官方 prompt;目标空闲时会被 resume 唤醒)。 */
266
+ export function deliverPrompt(sc, { sessionId, text, mode, requestId }) {
267
+ // 本体签名是 prompt(request, signal):signal 必传(内部会 throwIfAborted)。
268
+ return sc.prompt(
269
+ { requestId, sessionId, mode, content: contentOf(text) },
270
+ AbortSignal.timeout(PROMPT_TIMEOUT_MS),
271
+ );
272
+ }
273
+
274
+ /** 记一行插件日志(工具执行路径用;薄封装便于以后统一改格式)。 */
275
+ export function logMessaging(ctx, message) {
276
+ log(ctx, message);
277
+ }
@@ -0,0 +1,259 @@
1
+ // dsh-session-conductor — 跨会话消息域:三个 AI 工具的注册
2
+ //
3
+ // 【本文件职责】只放工具定义(description / parameters / execute)与注册入口;
4
+ // 常量、纯函数、限流状态机与本体调用封装在 logic.js。
5
+ //
6
+ // 【工具注册约定】照 lib/features/context-usage 的写法:注册函数返回 boolean,
7
+ // 由 apply.js 在 inject('tools') 回调里调用。`defineTool` 必须由调用方显式传入
8
+ // —— 本项目历史事故:文件里直接用 defineTool 却从未导入 ⇒ 该回调抛
9
+ // ReferenceError ⇒ 同批工具全部注册不上(详见 lib/apply.js 的注释)。
10
+
11
+ import {
12
+ TEXT_LIMIT, HOPS_LIMIT, RATE_WINDOW_MS, RATE_MAX, LIST_DEFAULT_LIMIT, LIST_MAX_LIMIT,
13
+ currentSessionId, callerCwd, sessionControllerOf, shortError, mapPromptError,
14
+ formatSessionList, listSessions, describeTarget, checkModelPair,
15
+ peekRateLimit, hitRateLimit, isInFlight, markInFlight, clearInFlight,
16
+ newRequestId, deliverPrompt, logMessaging,
17
+ } from "./logic.js";
18
+
19
+ /** 三个工具共用的输出形态(纯文本回执)。 */
20
+ const TEXT_OUTPUT = {
21
+ schema: { type: "string" },
22
+ render(args, value) {
23
+ return [{ type: "text", text: String(value) }];
24
+ },
25
+ };
26
+
27
+ // ── 工具:list_sessions ───────────────────────────────────────────────────
28
+
29
+ function registerListSessions(ctx, defineTool, tools) {
30
+ tools.register(defineTool({
31
+ name: "list_sessions",
32
+ description:
33
+ "列出本机 DSH 的会话(id / 标题 / 工作区 / 状态 / 最后活动 / 是否当前会话),供 send_to_session 挑目标。"
34
+ + "只读、无副作用。"
35
+ + `**默认只返回前 ${LIST_DEFAULT_LIMIT} 条**(会话多时整表输出会白烧调用方上下文):`
36
+ + "先用 query 按 id/标题/工作区子串收窄,再按回执提示的 offset 翻页。"
37
+ + "状态含义:running = 正在跑回合;idle = 已挂载但空闲;inactive = 冷会话(未挂载,被投递时会被唤醒)。"
38
+ + "用法:先调它拿目标 id,再把 id 原样传给 send_to_session —— 不要凭记忆或标题猜 id。"
39
+ + "缺 id 的脏数据会被自动跳过并如实标注。",
40
+ parameters: {
41
+ query: { type: "string", description: "按 id / 标题 / 工作区子串过滤(不区分大小写),会话多时先用它收窄" },
42
+ limit: { type: "number", description: `本页最多返回多少条(默认 ${LIST_DEFAULT_LIMIT},上限 ${LIST_MAX_LIMIT})` },
43
+ offset: { type: "number", description: "跳过前多少条(默认 0;回执里会给出下一页该用的 offset)" },
44
+ },
45
+ output: TEXT_OUTPUT,
46
+ async execute(args, exec) {
47
+ try {
48
+ const items = await listSessions(ctx);
49
+ const limit = Number.isSafeInteger(args?.limit) && args.limit > 0
50
+ ? Math.min(args.limit, LIST_MAX_LIMIT)
51
+ : LIST_DEFAULT_LIMIT;
52
+ const offset = Number.isSafeInteger(args?.offset) && args.offset > 0 ? args.offset : 0;
53
+ return formatSessionList(items, currentSessionId(exec?.agent), { limit, offset, query: args?.query });
54
+ } catch (error) {
55
+ return `列出会话失败:${shortError(error)}`;
56
+ }
57
+ },
58
+ }));
59
+ }
60
+
61
+ // ── 工具:send_to_session ─────────────────────────────────────────────────
62
+
63
+ function registerSendToSession(ctx, defineTool, tools) {
64
+ tools.register(defineTool({
65
+ name: "send_to_session",
66
+ description:
67
+ "给另一个 DSH 会话投递一条消息(不经过人转达),用于 AI 之间的交接与唤醒。"
68
+ + "投递语义:目标空闲/未挂载时由本体自动唤醒它跑一个回合;目标运行中则排到其下一回合边界;"
69
+ + "mode=\"steer\" 改为注入它正在跑的那个回合。"
70
+ + "何时用:需要对方立刻行动、且是短消息。"
71
+ + "何时不用:要交接上下文/长内容/可追溯 → 落共享文件(工作区里的交接 md)+ git 提交信息,这里只发路径与要点;"
72
+ + "需要人做决策或跨机器 → 人转达。"
73
+ + "硬约束(防唤醒风暴):禁止投给自己;协作链 hops≥3 拒绝;同一对会话 60s 内最多 5 条;"
74
+ + "requestId 幂等(重试传同一个值不会重复投递)。"
75
+ + "先 dryRun=true 可只校验并预览(不投递、不消耗限流配额),确认无误再真投。"
76
+ + "回执只表示本体已受理,不代表对方已处理完。",
77
+ parameters: {
78
+ sessionId: { type: "string", required: true, description: "目标会话 id(先用 list_sessions 取,别凭记忆写)" },
79
+ text: { type: "string", required: true, description: `消息正文(纯文本,≤${TEXT_LIMIT} 字符;长内容请落文件后只发路径)` },
80
+ mode: { type: "string", description: "queue(默认:排到目标下一回合,空闲时唤醒它)| steer(注入目标正在跑的回合)" },
81
+ requestId: { type: "string", description: "可选幂等键;重试时传同一个值可避免重复投递(缺省由工具生成并在回执里回传)" },
82
+ hops: { type: "number", description: `协作链跳数(AI 之间互相投递时自增;≥${HOPS_LIMIT} 会被拒绝并提示改用共享文件)` },
83
+ dryRun: { type: "boolean", description: "true = 只校验并预览、不真的投递(默认 false)" },
84
+ },
85
+ output: TEXT_OUTPUT,
86
+ async execute(args, exec) {
87
+ const from = currentSessionId(exec?.agent);
88
+ const to = String(args?.sessionId ?? "").trim();
89
+ const text = String(args?.text ?? "");
90
+ const mode = args?.mode === "steer" ? "steer" : "queue";
91
+ const hops = Number(args?.hops ?? 0);
92
+ const dryRun = args?.dryRun === true;
93
+ // 本地能判的一律先判(省一次无谓 RPC,且错误更明确)
94
+ if (!to) return "投递失败:sessionId 必填(先用 list_sessions 查目标)";
95
+ if (from && to === from) return "投递失败:不能投给自己(禁止自投,避免自我唤醒死循环)";
96
+ if (!text.trim()) return "投递失败:text 不能为空";
97
+ if (text.length > TEXT_LIMIT) {
98
+ return `投递失败:text 超长(${text.length} > ${TEXT_LIMIT} 字符)——长内容请落共享文件后只发路径`;
99
+ }
100
+ if (Number.isFinite(hops) && hops >= HOPS_LIMIT) {
101
+ return `投递失败:协作链跳数已达上限(hops=${hops} ≥ ${HOPS_LIMIT})——请改用共享文件交接,不要继续互相投递`;
102
+ }
103
+ const sc = sessionControllerOf(ctx);
104
+ if (!sc || typeof sc.prompt !== "function") {
105
+ return "投递失败:sessionController.prompt 不可用(本体未挂载该服务或版本不支持)";
106
+ }
107
+ if (dryRun) {
108
+ // 只读预检:限流只探测不消耗;目标存在性 best-effort
109
+ const rate = peekRateLimit(from, to);
110
+ const target = await describeTarget(ctx, to);
111
+ const rid = String(args?.requestId ?? "").trim();
112
+ return `【dryRun】未投递任何消息,以下是将要发生的事:\n`
113
+ + `- 目标:${to}${target}\n`
114
+ + `- 模式:${mode}${mode === "steer" ? "(注入目标正在跑的回合)" : "(排到目标下一回合;目标空闲时由本体唤醒它)"}\n`
115
+ + `- 正文:${text.length} 字符,开头「${text.slice(0, 40)}${text.length > 40 ? "…" : ""}」\n`
116
+ + `- 幂等键:${rid || "(未指定,真投时自动生成)"}\n`
117
+ + `- 限流:本窗口已用 ${rate.used}/${RATE_MAX}`
118
+ + (rate.limited ? " ⇒ ⚠️ 当前会被限流,需等窗口滚动或改用共享文件" : "(不会触发限流)");
119
+ }
120
+ if (hitRateLimit(from, to)) {
121
+ return `投递失败:限流(同一对会话 ${RATE_WINDOW_MS / 1000}s 内最多 ${RATE_MAX} 条)——稍后再试,或改用共享文件`;
122
+ }
123
+ const requestId = String(args?.requestId ?? "").trim() || newRequestId();
124
+ if (isInFlight(requestId)) return `投递失败:同一 requestId 正在投递中(${requestId})`;
125
+ markInFlight(requestId);
126
+ try {
127
+ await deliverPrompt(sc, { sessionId: to, text, mode, requestId });
128
+ logMessaging(ctx, `跨会话投递 ${from ?? "?"} → ${to}(mode=${mode} requestId=${requestId} hops=${hops})`);
129
+ return `已投递到 ${to}(mode=${mode} | requestId=${requestId})。`
130
+ + (mode === "steer"
131
+ ? "目标是运行中会话:消息已注入其当前回合。"
132
+ : "目标空闲时本体已自动唤醒它;目标运行中则在其下一回合边界消费。")
133
+ + " 回执只表示本体已受理,不代表对方已处理完。";
134
+ } catch (error) {
135
+ return `投递失败(${mapPromptError(error)}):${shortError(error)}`;
136
+ } finally {
137
+ clearInFlight(requestId);
138
+ }
139
+ },
140
+ }));
141
+ }
142
+
143
+ // ── 工具:create_session ──────────────────────────────────────────────────
144
+
145
+ function registerCreateSession(ctx, defineTool, tools) {
146
+ tools.register(defineTool({
147
+ name: "create_session",
148
+ description:
149
+ "新建一个 DSH 会话(只新建,不删除、不修改其它会话)。可指定 provider/model(建完立即切到该模型),"
150
+ + "可选带一条初始消息让它立刻开始干活。"
151
+ + "工作目录缺省 = **调用方会话自己的 cwd**(发起它的那个 AI 在哪干活,新会话就落在哪);"
152
+ + "也可显式给 cwd 或 workspaceId(二者互斥)。"
153
+ + "建议用法:① 先用 list_models 查确切的 provider/model;"
154
+ + "② 不确定就先 dryRun=true 预览(会核对模型是否在目录里、报告将用的 cwd 与初始消息),确认无误再真建。"
155
+ + "模型与初始消息是建会话之后的两个独立步骤,任一步失败都会如实回执(如「会话已建,但模型未设置」),不会谎报整体成功。",
156
+ parameters: {
157
+ provider: { type: "string", description: "模型 provider 分组名(list_models 输出的顶层分组,如 agnes / wb);与 model 成对给出" },
158
+ model: { type: "string", description: "该 provider 下的模型 id(如 agnes-3.0-flash / global:deepseek-v4.1-flash);与 provider 成对给出" },
159
+ cwd: { type: "string", description: "工作目录(可选;缺省 = 调用方会话的 cwd;与 workspaceId 互斥)" },
160
+ workspaceId: { type: "string", description: "工作区 id(可选;与 cwd 互斥,不能同时给)" },
161
+ sessionId: { type: "string", description: "可选:指定会话 id(幂等 adopt 已有会话)" },
162
+ text: { type: "string", description: `可选:初始消息(给了就立刻投递,≤${TEXT_LIMIT} 字符)` },
163
+ dryRun: { type: "boolean", description: "true = 只校验并预览、不真的建会话(默认 false)" },
164
+ },
165
+ output: TEXT_OUTPUT,
166
+ async execute(args, exec) {
167
+ const provider = String(args?.provider ?? "").trim();
168
+ const model = String(args?.model ?? "").trim();
169
+ const cwdArg = String(args?.cwd ?? "").trim();
170
+ const workspaceId = String(args?.workspaceId ?? "").trim();
171
+ const wantedId = String(args?.sessionId ?? "").trim();
172
+ const text = String(args?.text ?? "");
173
+ const dryRun = args?.dryRun === true;
174
+ if ((provider && !model) || (!provider && model)) {
175
+ return "新建失败:provider 与 model 必须成对给出(先用 list_models 查确切值)";
176
+ }
177
+ if (cwdArg && workspaceId) return "新建失败:cwd 与 workspaceId 只能给一个(本体同样会拒)";
178
+ if (text.length > TEXT_LIMIT) return `新建失败:text 超长(${text.length} > ${TEXT_LIMIT} 字符)`;
179
+ const sc = sessionControllerOf(ctx);
180
+ if (!sc || typeof sc.create !== "function") {
181
+ return "新建失败:sessionController.create 不可用(本体未挂载该服务或版本不支持)";
182
+ }
183
+ // 缺省 cwd = 调用方会话自己的 cwd。
184
+ // 为什么不交给本体默认:本体的默认是**它自己的运行目录**(实测 /volume1/@appstore/DeepSeekHarness-NAS,
185
+ // 即 DSH 安装目录)—— 新会话落在程序目录里对干活毫无意义。
186
+ const ownCwd = callerCwd(exec?.agent);
187
+ const cwd = cwdArg || (workspaceId ? "" : ownCwd);
188
+ const cwdSource = cwdArg
189
+ ? "显式指定"
190
+ : (workspaceId ? "由 workspaceId 决定" : (ownCwd ? "缺省 = 调用方会话的 cwd" : "缺省(取不到调用方 cwd,交给本体默认)"));
191
+ const modelLabel = provider && model ? `${provider}/${model}` : "(未指定,跟随默认)";
192
+ if (dryRun) {
193
+ // 只读预检:模型目录核对 best-effort(拿不到目录就如实说明,不阻断)
194
+ const pair = provider && model
195
+ ? await checkModelPair(sc, provider, model)
196
+ : { checked: false, found: false, hint: "" };
197
+ const pairNote = provider && model
198
+ ? (pair.checked
199
+ ? (pair.found ? " ✓ 已在模型目录里核对通过" : ` ⚠️ ${pair.hint}`)
200
+ : (pair.hint ? ` (${pair.hint})` : ""))
201
+ : "";
202
+ return `【dryRun】未创建任何会话,以下是将要发生的事:\n`
203
+ + `- 工作目录:${cwd || "(本体默认)"}(${cwdSource})\n`
204
+ + (workspaceId ? `- 工作区 id:${workspaceId}\n` : "")
205
+ + (wantedId ? `- 指定会话 id:${wantedId}(幂等 adopt)\n` : "")
206
+ + `- 模型:${modelLabel}${pairNote}\n`
207
+ + `- 初始消息:${text.trim() ? `${text.length} 字符,开头「${text.slice(0, 40)}${text.length > 40 ? "…" : ""}」` : "(无)"}`;
208
+ }
209
+ let created;
210
+ try {
211
+ created = await sc.create({
212
+ ...(workspaceId ? { workspaceId } : {}),
213
+ ...(cwd ? { cwd } : {}),
214
+ ...(wantedId ? { sessionId: wantedId } : {}),
215
+ });
216
+ } catch (error) {
217
+ return `新建失败:${shortError(error)}`;
218
+ }
219
+ const sessionId = String(created?.sessionId ?? "");
220
+ if (!sessionId) return `新建失败:本体未返回 sessionId(回执 ${JSON.stringify(created)})`;
221
+ // 模型与初始消息是「建会话」之后的两个独立步骤,任一失败都不能谎报整体成功
222
+ const notes = [];
223
+ if (provider && model) {
224
+ try {
225
+ await sc.selectModel({ sessionId, provider, model });
226
+ } catch (error) {
227
+ notes.push(`⚠️ 会话已建,但模型未设置(${shortError(error)})`);
228
+ }
229
+ }
230
+ if (text.trim()) {
231
+ try {
232
+ await deliverPrompt(sc, { sessionId, text, mode: "queue", requestId: newRequestId() });
233
+ } catch (error) {
234
+ notes.push(`⚠️ 会话已建,但初始消息未投递(${mapPromptError(error)}:${shortError(error)})`);
235
+ }
236
+ }
237
+ logMessaging(ctx, `新建会话 ${sessionId}(模型 ${provider && model ? `${provider}/${model}` : "默认"},cwd ${cwd || "本体默认"},初始消息 ${text.trim() ? "有" : "无"})`);
238
+ const head = `已新建会话 ${sessionId}(模型 ${modelLabel},工作目录 ${cwd || "本体默认"})`;
239
+ return notes.length ? `${head}\n${notes.join("\n")}` : `${head}\n可用 send_to_session 继续给它发消息。`;
240
+ },
241
+ }));
242
+ }
243
+
244
+ // ── 注册入口 ──────────────────────────────────────────────────────────────
245
+
246
+ /**
247
+ * 注册本域三个工具。
248
+ * @param ctx 插件上下文
249
+ * @param defineTool 宿主的工具定义函数(**必须由调用方显式传入**,见文件头注释)
250
+ * @param tools 宿主 tools 服务
251
+ * @returns {boolean} 是否注册成功(调用方据此打日志)
252
+ */
253
+ export function registerSessionMessagingTools(ctx, defineTool, tools) {
254
+ if (typeof defineTool !== "function" || typeof tools?.register !== "function") return false;
255
+ registerListSessions(ctx, defineTool, tools);
256
+ registerSendToSession(ctx, defineTool, tools);
257
+ registerCreateSession(ctx, defineTool, tools);
258
+ return true;
259
+ }
@@ -3,8 +3,38 @@
3
3
  // 【为什么单独成文件】「拿到一个会话的事件流」是列表、续跑、重命名、搜索、撤回的共同前置:
4
4
  // live 会话走内存快照,冷会话走官方 persistence.open(id,"read") 读法。
5
5
  // 集中在此避免各域各写一份(历史上冷会话读法变更过多次:inspect 失效 → open/read)。
6
+ //
7
+ // 【已知限制·宿主 API 不能流式读】官方 `handle.read(offset, length)` 的 offset/length 只是
8
+ // **内存数组切片**:后端每次调用都会 `readStoredLog(path)` 把**整份日志读进来解压**
9
+ // (见 packages/session/session-persistence-jsonl/src/storage.ts 的 readCurrent)。因此:
10
+ // · 分块调用**不会**降低内存峰值,反而变成「N 次全量解压」(O(n²) 耗时)——**不要分块读**;
11
+ // · 想真正把单会话内存压到 KB 级,只能绕过 handle:直接按 zstd 帧解码日志文件
12
+ // (多帧格式每帧可独立解压,本仓库 lib/zstd-frames.js 已具备该能力)。
13
+ // 当前策略是「**读一次就别再读**」:靠列表磁盘缓存(revision 命中即跳过解析)把
14
+ // 重复解析降到 0,从而把 OOM 风险压掉。
15
+
16
+ // 宿主包**懒加载**:顶层 `import` 会让本文件在「工作区自测」环境直接解析失败
17
+ // (仓库里踩过同类坑),而 events.js 是最需要被单测覆盖的模块之一(冷会话读法)。
18
+ // 这里改成顶层 await import + 兜底:没有宿主依赖时退化为「不加闭合器」,其余逻辑照常可测。
19
+ let interruptedTurnClosers = null;
20
+ try {
21
+ const hostSession = await import("@deepseek-ai/dsh-session");
22
+ if (typeof hostSession?.interruptedTurnClosers === "function") {
23
+ interruptedTurnClosers = hostSession.interruptedTurnClosers;
24
+ }
25
+ } catch {
26
+ // 无宿主依赖(工作区自测):保持 null,调用点会跳过「补闭合器」这一步
27
+ }
6
28
 
7
- import { interruptedTurnClosers } from "@deepseek-ai/dsh-session";
29
+ /** 需要时补官方中断闭合器;宿主包不可用时原样返回(不阻断列表)。 */
30
+ function withInterruptedClosers(events) {
31
+ if (typeof interruptedTurnClosers !== "function" || events.length === 0) return events;
32
+ try {
33
+ return [...events, ...interruptedTurnClosers(events)];
34
+ } catch {
35
+ return events;
36
+ }
37
+ }
8
38
 
9
39
  /**
10
40
  * 该子会话是否归属 API remote(不该被插件当普通会话处理)。
@@ -50,12 +80,17 @@ export async function sessionEventsOf(ctx, sessionId) {
50
80
  }
51
81
 
52
82
  /**
53
- * 读一个冷(已持久化)会话的完整事件流(官方 0.1.6 标准读法):
54
- * persistence.open(id, "read") → handle.read(0) → close。
55
- * ⚠️ 0.1.6-alpha.1 起 persistence.inspect 已失效(返回空),冷会话标题/搜索内容
56
- * 全靠本函数;与 session-query 的 readColdSessionLog 同一姿势,并补中断闭合器。
83
+ * 读一个冷(已持久化)会话的完整事件流(官方标准读法):
84
+ * `persistence.open(id,"read")` → `handle.read(0)` → `close`,并补官方中断闭合器。
85
+ *
86
+ * ⚠️ **这是全量读**,也是宿主 API 目前唯一能给的读法(见文件头「宿主 API 不能流式读」):
87
+ * 整份日志会变成 events 数组驻留内存 —— 本机实测最大会话 49.2 MB 压缩 → 173.3 MB 解压
88
+ * (另一份 39.9 → 197.9 MB,膨胀 3.2~5.0×)。因此**必须避免重复解析**:
89
+ * 列表侧靠磁盘缓存(revision 命中即跳过);不要为了「省内存」改成多次 read
90
+ * (那只会变成 N 次全量解压,内存峰值不变、耗时变 O(n²))。
91
+ *
57
92
  * 读取失败**向上抛**(buildSessionList 用它带出 inspectError);仅「无 open 能力」返回 null。
58
- * @returns {{events:Array, meta:object|null}}
93
+ * @returns {Promise<{events:Array, meta:object|null}|null>}
59
94
  */
60
95
  export async function readColdSessionEvents(ctx, sessionId) {
61
96
  const persistence = ctx.get("sessionPersistence");
@@ -69,5 +104,5 @@ export async function readColdSessionEvents(ctx, sessionId) {
69
104
  try { await handle.close(); } catch { /* 读失败时 close 失败无妨 */ }
70
105
  }
71
106
  // 崩溃残留的开放回合补合成闭合器(与官方 cold-read 一致,列表中断判定需要)
72
- return { events: [...events, ...interruptedTurnClosers(events)], meta: handle?.header ?? null };
107
+ return { events: withInterruptedClosers(events), meta: handle?.header ?? null };
73
108
  }
@@ -17,6 +17,12 @@ import {
17
17
  peekListCache, peekListBuildInflight, registerListBuild, settleListBuild,
18
18
  markListDiskCacheDirty, isListDiskCacheDirty,
19
19
  } from "./list-cache.js";
20
+ import { isValidSessionId } from "./session-id.js";
21
+
22
+ /**
23
+ * 会话列表项 id 的唯一判据在 `./session-id.js`(零依赖叶子模块,便于单测直接覆盖)。
24
+ * 本文件四处使用它:归档集合净化 / 补全守卫 / persistence.list 回退校验 / emit 兜底。
25
+ */
20
26
 
21
27
  /**
22
28
  * 组装全部会话(live + 已持久化),按最后活动时间倒序。
@@ -30,9 +36,25 @@ export async function buildSessionList(ctx, opts = {}) { // dsh-skip-func-length
30
36
  const sessionTitle = ctx.get("sessionTitle");
31
37
  const storeState = await pluginState(ctx);
32
38
 
33
- const archived = new Set(workspaceRegistry?.archivedSessionIds ?? []);
39
+ // 归档 id 集合是「权威隐藏名单」,但它来自宿主持久化,可能混入空值/非字符串:
40
+ // 脏值会让下面的补全循环造出 `{ id: undefined }` 的 meta,最终产出一条没有 id 的列表项,
41
+ // 并在读该会话事件时抛 "Cannot read properties of undefined (reading 'length')"。
42
+ // 这里先净化,只保留非空字符串。
43
+ const archived = new Set(
44
+ (workspaceRegistry?.archivedSessionIds ?? []).filter(isValidSessionId),
45
+ );
34
46
  const items = [];
35
- const emit = (item) => { items.push(item); if (opts?.onItem) opts.onItem(item); };
47
+ // 列表项的唯一不变量:必须有非空字符串 id(前端与工具都按 id 索引/跳转)。
48
+ // 在 emit 这一个咽喉点兜底 —— 任何来源(活跃/冷会话/归档补全/缓存命中)产出的缺 id 项都进不来,
49
+ // 且留一行日志便于定位上游;比在每个产出点各写一遍更可靠。
50
+ const emit = (item) => {
51
+ if (!item || !isValidSessionId(item.id)) {
52
+ log(ctx, `会话列表项缺 id,已跳过:${JSON.stringify({ title: item?.title ?? null, live: item?.live ?? null })}`);
53
+ return;
54
+ }
55
+ items.push(item);
56
+ if (opts?.onItem) opts.onItem(item);
57
+ };
36
58
 
37
59
  // live 会话
38
60
  for (const session of sessions?.list() ?? []) {
@@ -65,17 +87,22 @@ export async function buildSessionList(ctx, opts = {}) { // dsh-skip-func-length
65
87
  try {
66
88
  // 优先 listSnapshots()——只读 header 行 + 一次 stat,**不解析日志**,
67
89
  // 顺带给出 revision(日志变更令牌),这是「懒加载」的判据。
68
- if (typeof persistence.listSnapshots === "function") {
69
- for (const snapshot of await persistence.listSnapshots()) {
70
- const header = snapshot?.header;
71
- if (header?.id && !liveIds.has(header.id)) {
72
- coldById.set(header.id, { meta: header, revision: snapshot.revision == null ? null : String(snapshot.revision) });
73
- }
74
- }
75
- } else {
76
- for (const meta of await persistence.list()) {
77
- if (!liveIds.has(meta.id)) coldById.set(meta.id, { meta, revision: null });
78
- }
90
+ // 宿主 `list()` 返回的是 **SessionPersistenceSnapshot**(`{ header, revision, eventCount?, sizeBytes? }`),
91
+ // 不是裸 header。早期写法先探测 `listSnapshots()`(宿主已无此方法,全仓搜不到),探测失败就退回
92
+ // 「把 list() 的返回值当 header 用、revision 置 null」——而 revision 为 null 的条目**永不进缓存**,
93
+ // 于是每次列表构建都要全量重解析所有日志(实测 list 接口 30s 不响应、进程 RSS 2.3GB)。
94
+ // 现在统一按快照解析:`entry.header ?? entry` 同时兼容新快照与旧裸 header 两种形态。
95
+ for (const entry of await persistence.list()) {
96
+ const header = entry?.header ?? entry;
97
+ // 缺 id 的条目不能进冷会话表(否则后续按 undefined 读日志,产出无 id 的列表项)
98
+ if (!isValidSessionId(header?.id) || liveIds.has(header.id)) continue;
99
+ const revision = entry?.revision;
100
+ coldById.set(header.id, {
101
+ meta: header,
102
+ revision: revision == null ? null : String(revision),
103
+ // 事件数:宿主能从元数据廉价给出时带上,供冷会话「只读尾部」直接算偏移(省掉全量扫描)
104
+ eventCount: typeof entry?.eventCount === "number" ? entry.eventCount : null,
105
+ });
79
106
  }
80
107
  } catch (error) {
81
108
  log(ctx, `sessionPersistence.list 失败(改走归档补全): ${String(error?.message ?? error)}`);
@@ -83,6 +110,8 @@ export async function buildSessionList(ctx, opts = {}) { // dsh-skip-func-length
83
110
  }
84
111
  // 归档集合是权威隐藏名单:即使 persistence.list 漏扫,也按 id inspect 补进列表。
85
112
  for (const id of archived) {
113
+ // 双保险:即使上游过滤失效,这里也不拿空 id 建 meta(meta.id 就是列表项 id 的来源)
114
+ if (!isValidSessionId(id)) continue;
86
115
  if (!liveIds.has(id) && !coldById.has(id)) coldById.set(id, { meta: { id }, revision: null });
87
116
  }
88
117
  if (coldById.size > 0) {
@@ -110,8 +139,10 @@ export async function buildSessionList(ctx, opts = {}) { // dsh-skip-func-length
110
139
  let inspectError = null;
111
140
  let inspected = entry.meta;
112
141
  try {
113
- // ⚠️ 0.1.6-alpha.1 起 persistence.inspect 已失效(返回空)→ 改用官方 handle 读法
114
- // (open('read') → read(0) → close),与 session-query 一致。
142
+ // ⚠️ 0.1.6-alpha.1 起 persistence.inspect 已失效(返回空)→ 改用官方 handle 读法。
143
+ // ⚠️ 这是**全量读**(宿主 API 的 read(offset,length) 只是内存切片,分块读不会省内存、
144
+ // 反而 N 次全量解压 ⇒ O(n²))。所以内存控制的关键是**别重复解析**:
145
+ // 上面的 revision 命中判定 + 下面的落盘缓存就是为此存在。
115
146
  const loaded = persistence?.open ? await readColdSessionEvents(ctx, entry.meta.id) : null;
116
147
  events = loaded?.events ?? [];
117
148
  if (loaded?.meta) inspected = loaded.meta;
@@ -0,0 +1,24 @@
1
+ // dsh-session-conductor — 会话 id 判据(零依赖叶子模块)
2
+ //
3
+ // 【为什么单独一个文件】这条判据是「会话列表项必须有 id」这条不变量的**唯一定义处**,
4
+ // 上游净化(归档集合)、补全守卫、回退校验(persistence.list)、emit 兜底四处共用。
5
+ // 放在 list.js 里会因依赖链(core/domain.js → 宿主包 @deepseek-ai/dsh-storage-domain)
6
+ // 而**无法被单测直接导入**;抽成零依赖叶子模块后,单测可功能级覆盖它本身,
7
+ // 再用结构断言钉住「list.js 的四处都走它」。
8
+ //
9
+ // 【背景】宿主持久化里出现过脏值:归档 id 集合混入 undefined 时,补全循环会造出
10
+ // `{ id: undefined }` 的 meta,最终产出一条没有 id 的列表项,读该会话事件时抛
11
+ // "Cannot read properties of undefined (reading 'length')"。
12
+
13
+ /**
14
+ * 该值能否作为会话 id:**非空字符串**。
15
+ *
16
+ * 注意只认字符串 —— 数字/对象/空串/null/undefined 一律不算(列表项 id 会被前端用作
17
+ * DOM key 与路由参数,也会被工具拿去查会话,任何非字符串都会在别处炸得更隐蔽)。
18
+ *
19
+ * @param {unknown} value 待判定的值
20
+ * @returns {boolean} 是否可作会话 id
21
+ */
22
+ export function isValidSessionId(value) {
23
+ return typeof value === "string" && value !== "";
24
+ }
@@ -34,8 +34,30 @@ export const TEMPLATE_DEFAULTS = {
34
34
  */
35
35
  export const PLAN_ENFORCE_EDIT_TOOLS = ['edit', 'write', 'apply'];
36
36
  export const PLAN_ENFORCE_CONFIRM_RE = /^\s*(确认|同意|按方案执行|执行吧|go ahead|ok|可以)[\s!!.。~~]*$/i;
37
- export const PLAN_ENFORCE_PROPOSE_TITLE = '修改方案提案';
38
- export const PLAN_ENFORCE_PROPOSE_CONFIRM_SECTION = '八、是否执行';
37
+ /**
38
+ * 提案识别锚点:**语义匹配,不锁章节号**。
39
+ *
40
+ * 为什么不能用「八、是否执行」这种带章节号的锚点:章节号是模板的排版细节,会随模板版本漂移
41
+ * —— 本机模板曾把「是否执行」放在第八节,后改到第十一节、把第八节让给「验证方案」。
42
+ * 锚点写死章节号后,AI 照模板写方案也**过不了锚点**;而门禁的表现是「所有 edit/write 被拒绝」,
43
+ * deny 文案又不会提示「锚点是章节号不匹配」⇒ 排查成本极高(实测:锚点与模板脱节)。
44
+ *
45
+ * 现在改为:命中任一标题锚点 **且** 命中任一确认段锚点,两者都不含章节号;
46
+ * 同时兼容两套在用模板——增强版模板(修改方案提案 / 是否执行)
47
+ * 与门禁表模板(门禁表 / 请回复确认)。
48
+ */
49
+ export const PLAN_ENFORCE_PROPOSE_TITLES = ['修改方案提案', '门禁表'];
50
+ /**
51
+ * 确认段锚点(**正则**,不含章节号)。
52
+ * 用正则而非字面量:模板收尾的确认措辞中间会夹别的字(如「等待对方确认」),
53
+ * 正则能在不把整句落成源码字面量的前提下保持匹配(整句字面量形态会被审计规则误报)。
54
+ */
55
+ export const PLAN_ENFORCE_PROPOSE_CONFIRM_ANCHORS = [
56
+ /是否执行/,
57
+ /请回复确认/,
58
+ /确认或修改方案/,
59
+ /等待[^\n。]{0,8}确认/,
60
+ ];
39
61
  /** bash 写操作启发式(保守起步:命中即视为写,误拦靠 deny 指引自愈) */
40
62
  export const PLAN_ENFORCE_BASH_WRITE_RE = /(^|\s|;|&&|\|\|)(>>|>\s*\S|sed\s+-i|rm\s|mv\s|cp\s|tee\s|mkdir|touch\s|chmod|chown|git\s+commit|git\s+reset|git\s+checkout\s+--|patch\b|truncate|dd\s)/;
41
63
 
@@ -239,13 +261,14 @@ export async function saveTemplateFromPath(browseRoot, slot, path, saveRoot) {
239
261
 
240
262
  /**
241
263
  * 方案模板强制门禁:门判定(无状态,从会话事件流现场推导)。
242
- * 放行条件 = 「确认消息之前的那一段 AI 输出含提案双标记」且「最后一条真人用户消息命中确认词」。
264
+ * 放行条件 = 「确认消息之前的那一段 AI 输出含提案双标记(标题锚点 + 确认段锚点,**均不含章节号**)」
265
+ * 且「最后一条真人用户消息命中确认词」。
243
266
  * 无状态的好处:重启零恢复问题,门状态永远由真实事件流现场推导,不与内存缓存打架。
244
267
  */
245
268
 
246
269
  export const planEnforceDenyMessage = (toolName) =>
247
270
  `⛔ 方案模板强制门禁:检测到代码修改类工具调用(${toolName}),但本回合尚无「已确认的方案提案」。` +
248
- `请先按方案模板输出完整提案(含「八、是否执行」确认段),等用户回复「确认」后再调用本工具。` +
271
+ `请先按方案模板输出完整提案(提案需同时含「提案标题」与「是否执行」确认段;锚点不锁章节号,按模板章节号写即可),等用户回复「确认」后再调用本工具。` +
249
272
  `若用户已确认过,请核对:确认必须是紧跟在提案之后的那条用户消息(新请求需重新提案 + 重新确认)。`;
250
273
 
251
274
  /** 提取一条 message 事件的纯文本(content 块数组,兼容 data.content / data.message.content)。 */
@@ -280,8 +303,10 @@ export function planGateAllows(events) {
280
303
  }
281
304
  }
282
305
  if (lastUser === null || assistantBeforeLastUser.length === 0) return false;
306
+ // 提案成立 = 命中任一标题锚点 **且** 命中任一确认段锚点(均不含章节号 ⇒ 模板改版不会失效)
283
307
  const proposed = assistantBeforeLastUser.some((text) =>
284
- text.includes(PLAN_ENFORCE_PROPOSE_TITLE) && text.includes(PLAN_ENFORCE_PROPOSE_CONFIRM_SECTION));
308
+ PLAN_ENFORCE_PROPOSE_TITLES.some((title) => text.includes(title))
309
+ && PLAN_ENFORCE_PROPOSE_CONFIRM_ANCHORS.some((anchor) => anchor.test(text)));
285
310
  if (!proposed) return false;
286
311
  const strict = PLAN_ENFORCE_CONFIRM_RE.test(lastUser.trim());
287
312
  const loose = /(确认|同意|按方案执行|执行吧|go ahead)/i.test(lastUser);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-session-conductor",
3
- "version": "1.0.10",
3
+ "version": "1.2.0",
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",
@@ -108,10 +108,15 @@ whenToUse: 需要了解 dsh-session-conductor 某个功能怎么用/参数是什
108
108
  - API:GET /api/session-conductor/templates;POST {slot, name, content} / {slot, url} / {slot, enabled} / {slot, action:"remove"} / {slot, action:"pickPath", path} / **{slot:'plan', enforce}**。
109
109
  - ** 强制门禁**:`plan.enforce=true` 时,未按方案模板出提案并获用户「确认」前,代码修改类工具调用被 `tools/pre-execute` waterfall 直接拒绝(`{kind:'deny'}`),拒绝理由指导 AI 先出提案。
110
110
  - 拦的工具:edit / write / apply(全拦)+ bash 写操作启发式(`>`/`>>` 重定向、sed -i、rm、mv、cp、tee、mkdir、touch、chmod/chown、git commit/reset/checkout --、patch、truncate、dd)。只读工具(read/grep/glob)永不拦。
111
- - 门判定**无状态**:每次拦截从会话事件流现场推导——「提案」= 确认消息之前那一段 AI 输出同时含提案标题(修改方案提案)+ 确认段(八、是否执行)双标记;「确认」= 最后一条**真人**用户消息命中确认词(严格式:整条仅确认词如 确认/同意/OK;宽松式:含 确认/同意/按方案执行/执行吧/go ahead)。系统注入消息(Current runtime context / The user said / <system-reminder> / 【dsh- / 🔑)自动跳过。
111
+ - 门判定**无状态**:每次拦截从会话事件流现场推导——「提案」= 确认消息之前那一段 AI 输出同时含**标题锚点**(`修改方案提案` 或 `门禁表`)+ **确认段锚点**(`是否执行` / `请回复确认` / `确认或修改方案` / `等待…确认`(正则))双标记,**两个锚点都不含章节号**(模板章节号会改版,锚点写死章节号会让门禁在模板改版后永久拒绝所有编辑——2026-10-15 实测踩过);「确认」= 最后一条**真人**用户消息命中确认词(严格式:整条仅确认词如 确认/同意/OK;宽松式:含 确认/同意/按方案执行/执行吧/go ahead)。系统注入消息(Current runtime context / The user said / <system-reminder> / 【dsh- / 🔑)自动跳过。
112
112
  - 无状态的好处:重启零恢复问题,门状态永远由真实对话记录决定,不与内存缓存打架。
113
113
  - 子 agent 豁免:delegationDepth>0 的会话放行(子 agent 无法交互确认;主会话是强制执行点)。
114
114
  - 门禁故障 fail-open(catch 里 next() + 留痕日志),不阻断工具链。
115
+ - **坑(2026-10-15 实测)**:
116
+ - **现象**:模板把「是否执行」从第八节移到第十一节后,锚点(字面量 `八、是否执行`)与模板脱节;一旦 `plan.enforce=true`,**AI 照模板写方案也过不了锚点 ⇒ 所有 edit/write 被永久拒绝**,而 deny 文案只说「请先出提案」,不提示「锚点不匹配」。
117
+ - **根因**:把**排版细节(章节号)**当成了语义锚点。章节号会随模板改版漂移,语义标题(「是否执行」)不会。
118
+ - **解决**:锚点改为语义匹配——`PLAN_ENFORCE_PROPOSE_TITLES`(标题)+ `PLAN_ENFORCE_PROPOSE_CONFIRM_ANCHORS`(确认段),各任一命中即成立;同步修掉 deny 文案里的同一处写死。
119
+ - **防再犯**:① 门禁/校验类锚点**一律不锁章节号、序号、行号**,只匹配语义文本;② 改模板章节号时,同步 grep 锚点常量与 deny 文案;③ 单测固定留一条「章节号变了仍认提案」的回归护栏(`test/unit/test-template-inject.mjs`)。
115
120
  - 开关即时生效(`templateStateCache` 热更新),无需重启;注入文本同步声明拦截口径,AI 与拦截器同一约定。
116
121
 
117
122
  ## 八、压缩模型选择
@@ -160,10 +165,41 @@ whenToUse: 需要了解 dsh-session-conductor 某个功能怎么用/参数是什
160
165
  - 占用 **≥ 85%** 时输出附一条收敛建议(把大结果落盘、先小结已完成部分、或把剩余任务拆到新会话)。
161
166
  - ⚠️ **是启发式估算,不是计费值**(容量按约 4 字节/token 折算)。适合做压缩/分片决策;要计费口径请用官方 `tokenUsage` 投影(四桶 `uncachedInputTokens` / `outputTokens` / `cacheReadTokens` / `cacheWriteTokens`)。
162
167
  - **三项之和通常小于已用量**(实测 390K vs 243K):已用量取自 `projectedTokens`,底座是 **provider 报告的真实提示词规模**;三个分项是**逐条启发式累加**,不含 reasoning tokens、附件与部分注入内容。所以「分项加起来对不上总数」是口径差异,不是算错。
163
- - **设计取舍(为什么是自查工具而不是每轮注入数值)**:每轮注入会让系统提示词前缀每轮变化 ⇒ prompt cache 失效 ⇒ 成本上升,且注入文本本身占 token(为知道用量反而多花);工具定义固定不变,只在调用时才产生输出。DSH 官方 compaction 本就是**自动**触发(`agent/pre-step` + `thresholdRatio`),AI 不参与决策 —— 本工具面向的是「AI 需要主动决策」的场景(要不要先总结再继续、要不要分片、要不要把大结果落盘)。
168
+ - **每轮注入(默认开)与按需自查并行**:除了 `context_usage` 工具,插件还会**每轮**把实时用量注入上下文 —— 走 `agent/pre-step`,把用量作为**一条 user 消息追加到该步消息批次尾部**(**不是**系统提示词前缀)。放尾部的原因:前缀保持稳定 ⇒ prompt cache 仍命中,而 AI 每轮都能直接看到数字。注入按 **turn** 门控(`agent/pre-step` 每步都触发,每步注入会在一回合里刷出多条重复行);注入文本是**单行紧凑格式**(`formatContextUsageCompact`),拿不到投影时静默跳过、绝不影响回合。配置项 `injectContextUsage`(`lib/core/config.js` 的 DEFAULTS,默认 `true`)可关;关掉即退化为「按需调用工具」。**历史口径修正**:早期结论是「只做自查工具、不注入」,理由是「每轮注入会让系统提示词前缀每轮变化 ⇒ prompt cache 失效」——该代价只存在于「注入到前缀」这一种做法;改为注入到消息尾部即规避。
169
+ - **注入可见性**:注入走官方 logged 通道(`createUserMessage` + 返回 `{...decision, messages:[...]}`),因此**进模型输入、也落进会话日志** —— AI 确实看得见,不是只在内存里飘。
164
170
  - **失败语义**:拿不到数据时如实说明原因(`exec.agent` 缺失 / `sessionProjections` 服务不可用 / 该路由还没上报 `contextWindow`),不抛异常、不返回空。
165
171
 
166
- ## 十一、通用行为约定(浓缩自原全局 skill,约束所有 AI 所有会话)
172
+ ## 十一、跨会话消息与指定模型新建会话(AI 工具 list_sessions / send_to_session / create_session)
173
+
174
+ 三个工具都在 `lib/features/session-messaging/`(`logic.js` 常量/纯函数/本体调用封装 + `tools.js` 工具注册 + `index.js` 域出口),由 `apply.js` 在 `inject('tools')` 回调里注册(注册整体包 try/catch,失败只留痕、不连累同批工具)。
175
+
176
+ | 工具 | 用途 | 关键参数 |
177
+ |---|---|---|
178
+ | `list_sessions` | 列出本机会话(id / 标题 / 工作区 / 状态 / 最后活动 / 是否当前会话);支持 `query` 子串过滤 + `limit`/`offset` 分页(**默认只给 20 条**,回执会给出下一页的 offset) | `query` / `limit` / `offset` |
179
+ | `send_to_session` | 给另一个会话投递消息(不经过人转达) | `sessionId`、`text`、`mode`(queue/steer)、`requestId`、`hops`、`dryRun` |
180
+ | `create_session` | 新建会话并可指定模型,可选带初始消息 | `provider`+`model`、`cwd`/`workspaceId`、`sessionId`、`text`、`dryRun` |
181
+
182
+ **标准用法(按此顺序)**
183
+
184
+ 1. `list_sessions()` → 拿目标会话 id(**不要凭标题或记忆猜 id**)
185
+ 2. 建新会话:`list_models()` 查确切 provider/model → `create_session({provider, model, dryRun:true})` 预览 → 确认后去掉 dryRun 真建
186
+ 3. 给已有会话发消息:`send_to_session({sessionId, text, dryRun:true})` 预览 → 确认后真投
187
+
188
+ - **`create_session` 缺省工作目录 = 调用方会话自己的 cwd**(发起它的那个 AI 在哪干活,新会话就落在哪)。**不要**依赖本体默认:那是 DSH 安装目录(实测 `/volume1/@appstore/DeepSeekHarness-NAS`),新会话落在程序目录里对干活毫无意义。显式给 `cwd` 或 `workspaceId` 可覆盖(二者互斥)。
189
+ - **dryRun(两个写操作工具都支持)**:只做校验与预览、**不产生任何副作用**。`create_session` 的 dryRun 还会**核对 provider/model 是否真在模型目录里**;`send_to_session` 的 dryRun 会**报告目标状态与限流余量**,且**不消耗限流配额**(可以先 dryRun 多次再真投)。dryRun 同样执行全部本地校验(自投/超长/跳数等照常被拒)。
190
+ - **状态含义**(`list_sessions` 输出):`running` = 正在跑回合;`idle` = 已挂载但空闲;`inactive` = 冷会话(未挂载,投递时会被唤醒)。缺 id 的脏条目会被跳过并如实标注(不会渲染成 `undefined`)。
191
+ - **会话列表的内存边界(实测,别再踩)**:冷会话读日志走官方 `handle.read(0, undefined)`,**宿主 API 不能流式读** —— `read(offset, length)` 的 offset/length 只是**内存数组切片**,后端每次调用都会把整份日志读进来解压(`session-persistence-jsonl/src/storage.ts` 的 `readCurrent`)。实测单份日志 49.2 MB 压缩 → **173.3 MB 解压**(3.2~5.0×)。
192
+ - ⛔ **不要为了省内存改成分块读**:内存峰值不变(每次都全量物化),耗时变 O(n²)(曾导致 `/list` >120s 超时,已撤回)。
193
+ - ✅ 正确做法是**别重复解析**:靠列表磁盘缓存的 `revision` 命中跳过(1.2.0 修掉了「`list()` 返回快照却被当裸 header 用、`revision` 恒 null ⇒ 缓存永不生效」这个 bug)。
194
+ - 想把单会话内存压到 KB 级,只能绕过 handle:按 zstd 帧解码日志文件(多帧格式每帧可独立解压,`lib/zstd-frames.js` 已具备该能力)。
195
+ - **投递语义**:默认 `mode:"queue"` —— 目标空闲时**由本体自动唤醒**它跑一个回合;目标运行中则排到其下一回合边界。`mode:"steer"` 改为注入目标**正在跑**的那个回合。
196
+ - **实现口径(重要)**:全部走本体官方 `sessionController` 的 `@Remote` 方法 —— `create` / `selectModel` / `prompt`。**不自己写会话日志**:inbox 家族只有 `agent/inbox/spliced` 落盘,其 fold 对非法 splice(id 重复、start 越界)**直接抛错**,自己写等于拿对方会话的完整性冒险。`prompt` 的受理流程会先 resume 目标会话,唤醒语义由本体保证;`requestId` 是幂等键(本体持久化在那条被接受的消息上),重试传同键不会重复投递。
197
+ - **`create_session` 为什么是两步**:本体 `SessionCreateRequest` 只有 `workspaceId? / cwd? / sessionId? / agentPreset?`,**没有 model 字段**,所以必须 `create` → `selectModel`。任一步失败都如实回执(如「⚠️ 会话已建,但模型未设置」),**不谎报成功**。
198
+ - **安全硬约束**(防唤醒风暴,缺一不可):① 禁自投;② 协作链 `hops ≥ 3` 拒绝并提示改用共享文件;③ 同一对会话 60s 内最多 5 条;④ `requestId` 幂等 + 在途去重;⑤ 只投递、**不读对方会话内容**。
199
+ - **何时不该用它**:需要交接上下文 / 长内容 / 可追溯 —— 落共享文件(如工作区下的交接 md)+ git 提交信息,消息里只放路径与要点;需要人做决策或跨机器 —— 人转达。
200
+ - **部署提醒**:本插件在 profile 里是**双副本**(`node_modules/<插件>` 是宿主加载源、`local-plugins/<插件>` 是 hmr 观察点)。同步**新增文件/目录**时必须**两个 target 都打**,否则宿主加载源缺文件 → 插件整个加载失败。
201
+
202
+ ## 十二、通用行为约定(浓缩自原全局 skill,约束所有 AI 所有会话)
167
203
 
168
204
  ### 任务开始与确认(analyze-then-confirm / ask-with-options / todo-ask-confirm)
169
205
  - 收到任务先分析(理解需求/现状/风险/方案/影响面),**列出本次读取/加载的每个 skill 及其来源路径**,确认后才动手。修改/删除/重启/安装/移动/推送等有副作用操作必须先确认。