dsh-session-conductor 0.0.0-stage → 1.0.9

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 (68) hide show
  1. package/README.md +165 -2
  2. package/cordis.patch.yml +48 -0
  3. package/lib/apply.js +1361 -0
  4. package/lib/client-parts/apply.js +43 -0
  5. package/lib/client-parts/components/compaction.js +124 -0
  6. package/lib/client-parts/components/conductor-settings.js +62 -0
  7. package/lib/client-parts/components/group.js +115 -0
  8. package/lib/client-parts/components/panel.js +1265 -0
  9. package/lib/client-parts/components/processing.js +98 -0
  10. package/lib/client-parts/components/session-icon.js +20 -0
  11. package/lib/client-parts/components/settings.js +415 -0
  12. package/lib/client-parts/foundation/bootstrap.js +48 -0
  13. package/lib/client-parts/foundation/i18n.js +74 -0
  14. package/lib/client-parts/foundation/list-cache.js +28 -0
  15. package/lib/client-parts/foundation/styles.js +236 -0
  16. package/lib/client.js +2534 -0
  17. package/lib/core/config.js +159 -0
  18. package/lib/core/domain.js +157 -0
  19. package/lib/core/lazy.js +20 -0
  20. package/lib/core/state.js +106 -0
  21. package/lib/core.js +82 -0
  22. package/lib/eio-repair.js +204 -0
  23. package/lib/features/context-usage/index.js +219 -0
  24. package/lib/features/continue/eligibility.js +49 -0
  25. package/lib/features/continue/scan.js +134 -0
  26. package/lib/features/continue/session.js +235 -0
  27. package/lib/features/inject/preset.js +20 -0
  28. package/lib/features/member-model/index.js +14 -0
  29. package/lib/features/member-model/locate.js +157 -0
  30. package/lib/features/member-model/override.js +76 -0
  31. package/lib/features/member-model/setup.js +55 -0
  32. package/lib/features/member-model/switch.js +186 -0
  33. package/lib/features/rename/analysis.js +303 -0
  34. package/lib/features/rename/title.js +158 -0
  35. package/lib/features/rename/value.js +77 -0
  36. package/lib/features/search.js +119 -0
  37. package/lib/features/session-ops-routes.js +218 -0
  38. package/lib/features/session-ops.js +414 -0
  39. package/lib/group.js +303 -0
  40. package/lib/guards.js +28 -0
  41. package/lib/http.js +24 -0
  42. package/lib/i18n/en.json +204 -0
  43. package/lib/i18n/zh.json +204 -0
  44. package/lib/index.js +78 -0
  45. package/lib/repair.js +398 -0
  46. package/lib/seq-gap-repair.js +224 -0
  47. package/lib/session-codec.js +37 -0
  48. package/lib/session-log.js +46 -0
  49. package/lib/sessions/detach.js +113 -0
  50. package/lib/sessions/events.js +73 -0
  51. package/lib/sessions/interruption.js +73 -0
  52. package/lib/sessions/list-cache.js +175 -0
  53. package/lib/sessions/list.js +187 -0
  54. package/lib/sessions/route.js +60 -0
  55. package/lib/sessions/turn.js +21 -0
  56. package/lib/shared/constants.js +20 -0
  57. package/lib/shared/log.js +17 -0
  58. package/lib/shared/settings.js +14 -0
  59. package/lib/shared/test-hooks.js +93 -0
  60. package/lib/shared/util.js +6 -0
  61. package/lib/template-inject.js +289 -0
  62. package/lib/value.js +430 -0
  63. package/lib/zstd-frames.js +177 -0
  64. package/package.json +65 -3
  65. package/skills/dsh-session-conductor-functions.md +197 -0
  66. package/skills/dsh-session-conductor.md +151 -0
  67. package/skills/dsh-session-official-doc.md +245 -0
  68. package/skills/task-completion-report.md +56 -0
package/lib/apply.js ADDED
@@ -0,0 +1,1361 @@
1
+ /** 插件入口 apply:注册路由、工具、设置页与各类钩子(本轮拆分后仍含各 handler 实现,下一步继续抽出)。 */
2
+
3
+ import { readFileSync } from "node:fs";
4
+ import path from "node:path";
5
+ import { findSessionLog } from "./session-log.js";
6
+ // 渲染/校验函数:供 /api/task-completion/render|check 端点使用(LLM 侧工具注册已移除,
7
+ // VALID_STATUSES 不再需要导入——它只在 core.js 的 renderCompletionBlock 内部使用)。
8
+ import { renderCompletionBlock, checkCompletionText } from "./core.js";
9
+ import { log } from "./shared/log.js";
10
+ import {
11
+ MSG_METHOD_ONLY_GET_POST, MSG_NEED_SESSION_ID,
12
+ MAX_ATTACHED_LIMIT, MAX_CONTINUES_PER_SESSION_LIMIT, MAX_STALE_DAYS,
13
+ } from "./shared/constants.js";
14
+ import { lazyRepair, lazySeqGap, lazyEio, lazyValue, lazyFs } from "./core/lazy.js";
15
+ import { pluginDomain, isDomainLive, resetDomainForTest } from "./core/domain.js";
16
+ import {
17
+ continueTimers, continueLocks, continueJobs, deleteLocks,
18
+ withDeleteLock, withSessionLock, withConcurrencyGate, cancelSessionTimers,
19
+ getScanTimer, setScanTimer, resetRuntimeStateForTest,
20
+ } from "./core/state.js";
21
+ import {
22
+ cfg, DEFAULTS, setConfig, resolveDshHome, resolveBrowseRoot,
23
+ readSwitch, patchSwitch, loadPluginConfig, savePluginConfig, resetAutoContinueOnStart,
24
+ autoRenameEnabled, effectiveAutoContinue, resetSwitchGroupsForTest, deleteSwitch, getSwitchGroupsForTest,
25
+ } from "./core/config.js";
26
+ import { continueSession, sendMessageToSession } from "./features/continue/session.js";
27
+ import {
28
+ maybeScheduleContinue, runAutoContinueSession, runAutoScan, scheduleScan, AUTO_CONTINUE_SCAN_DELAY_MS,
29
+ } from "./features/continue/scan.js";
30
+ import { num } from "./shared/util.js";
31
+ import { registerContextUsageTool } from "./features/context-usage/index.js";
32
+ import { registerGroupRoutes } from "./group.js";
33
+ 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
+ import { name } from "./shared/constants.js";
35
+ import { sessionEventsOf } from "./sessions/events.js";
36
+ import { detachSessionAgent, detachAllIdleSessions } from "./sessions/detach.js";
37
+ import {
38
+ invalidateSessionListCache, scheduleSaveListDiskCache, saveListDiskCacheNow, resetListCacheForTest,
39
+ } from "./sessions/list-cache.js";
40
+ import { buildSessionListCached } from "./sessions/list.js";
41
+ import {
42
+ scheduleAnalysis, runAnalysis, resolveModelOverride, analyzeSession, resolveRoute,
43
+ driftAnalysisLlm, extractTitleOnly, parseDriftJson, pendingTimers, resetAnalysisForTest,
44
+ } from "./features/rename/analysis.js";
45
+ import { analyzeValueWithLlm } from "./features/rename/value.js";
46
+ import {
47
+ collectSessionTitleMessages, resolveSessionTitle, stateSuffixOf, stripTitleStateSuffix, refreshTitleState,
48
+ } from "./features/rename/title.js";
49
+ import { SEARCH_MIN_QUERY, searchSessions } from './features/search.js';
50
+ // 成员模型:本文件用到 findTargetAgent / switchAgentModel / getMemberModelOverride /
51
+ // applyModelOverride / validateModelPair(拆出 apply.js 时漏了这一行 import,
52
+ // 运行到相关代码路径会 ReferenceError: getMemberModelOverride is not defined)
53
+ import { findTargetAgent, switchAgentModel, getMemberModelOverride, applyModelOverride, validateModelPair } from './features/member-model/index.js';
54
+ // 会话操作的路由注册已抽到独立模块(handler 实现见该模块;此处只留注册入口)
55
+ import { registerSessionOpsRoutes } from './features/session-ops-routes.js';
56
+ import { installProcessGuards } from './guards.js';
57
+ import { readJson, send } from './http.js';
58
+ import { CONDUCTOR_SETTINGS_NS, PresenceSchema } from './shared/settings.js';
59
+ import { templateStateCache, setTemplateStateCache } from './shared/test-hooks.js';
60
+
61
+ export async function apply(ctx, config = {}) {
62
+ // 修复(热重载兼容):插件重载时旧 apply 的 disposer 会 close 持久化域,
63
+ // 但模块级 domainPromise 缓存不失效 → 新 apply 复用已 closed 的域 → 所有读写报
64
+ // "domain 'dsh_session_conductor' is closed"(list/value-analysis/search 全挂)。
65
+ // 每次 apply 重置缓存,确保重新 open 域。
66
+ // 改为条件重置。原代码无条件清缓存,遇到「旧的域还活着就重载」时
67
+ // (多条目/热重载时序)会在活域上再 open 一次 → storage 报 already open → list 500。
68
+ // 只有确认域已关闭(或从未打开)才清缓存;域还活着就复用同一份,不再重复 open。
69
+ // 【原代码】此处为两行无条件重置:domainPromise = null 与 pushDomainPromise = null(push 域已随自动推送功能移除)
70
+ if (!isDomainLive()) resetDomainForTest();
71
+ setConfig({
72
+ enabled: config?.enabled !== false,
73
+ defaultAutoContinue: config?.defaultAutoContinue === true, // 缺省关闭,仅显式 true 开启
74
+ failRetryDelayMs: num(config?.failRetryDelayMs, 0, 10 * 60 * 1000, DEFAULTS.failRetryDelayMs),
75
+ titleStateSuffix: config?.titleStateSuffix === true, // 起默认取消状态后缀(吸收官方纯净标题),显式 true 才开启
76
+ maxConcurrent: num(config?.maxConcurrent, 1, 8, DEFAULTS.maxConcurrent),
77
+ maxAttached: num(config?.maxAttached, 1, MAX_ATTACHED_LIMIT, DEFAULTS.maxAttached),
78
+ // 会话列表冷会话 inspect 并发上限(默认 2)——防一次性并发全量会话导致堆 OOM
79
+ listInspectBatch: num(config?.listInspectBatch, 1, 16, DEFAULTS.listInspectBatch),
80
+ // list 结果缓存时长(0=不缓存,即时最新;默认 5s 合并面板多组件同时刷新)
81
+ listCacheMs: num(config?.listCacheMs, 0, 5 * 60 * 1000, DEFAULTS.listCacheMs),
82
+ cooldownMs: num(config?.cooldownMs, 60 * 1000, 24 * 3600 * 1000, DEFAULTS.cooldownMs),
83
+ maxContinuesPerSession: num(config?.maxContinuesPerSession, 1, MAX_CONTINUES_PER_SESSION_LIMIT, DEFAULTS.maxContinuesPerSession),
84
+ turnTimeoutMs: num(config?.turnTimeoutMs, 60 * 1000, 6 * 3600 * 1000, DEFAULTS.turnTimeoutMs),
85
+ scanIntervalMs: num(config?.scanIntervalMs, 30 * 1000, 24 * 3600 * 1000, DEFAULTS.scanIntervalMs),
86
+ // 自动重命名模型路由(成对配置才生效;缺省 = 继承会话 request/header 的对话模型)
87
+ autoRenameProvider: typeof config?.autoRenameProvider === "string" && config.autoRenameProvider !== "" ? config.autoRenameProvider : undefined,
88
+ autoRenameModel: typeof config?.autoRenameModel === "string" && config.autoRenameModel !== "" ? config.autoRenameModel : undefined,
89
+ });
90
+
91
+ // 会话分组(原 dsh-session-group 已合并):仅保留只读展示(status/list)
92
+ // + 分组下新建会话(new-session,workspaceId 缺省 = 上次会话工作区);分组管理能力回归
93
+ // DSH 官方 workspace 机制。
94
+ // 进程级兜底——宿主 bin.ts 未注册 unhandledRejection/uncaughtException 处理,
95
+ // Node 15+ 默认 --unhandled-rejections=throw,任何插件的异步 rejection 逃逸都会**直接杀死
96
+ // 整个 DSH 进程**(表现为「自动续跑跑一会进程就没了」)。这里注册兜底:仅记录日志、不退出,
97
+ // 避免单个 promise 异常拖垮宿主;插件自身异步链已全部包 try/catch,这里防的是回归与未知路径。
98
+ installProcessGuards(ctx);
99
+
100
+ await registerGroupRoutes(ctx, {
101
+ blockGroupNewSession: config?.blockGroupNewSession,
102
+ enabled: config?.groupEnabled !== false,
103
+ }, {
104
+ // 承接会话(new-session 新建)默认开启自动重命名(约定)
105
+ setAutoRename: (sessionId, enabled) => patchSwitch(ctx, "autoRename", sessionId, { enabled }, { enabled: false }).catch((error) => {
106
+ log(ctx, `承接会话自动重命名设置失败 ${sessionId}: ${String(error?.message ?? error)}`);
107
+ }),
108
+ }).catch((error) => {
109
+ log(ctx, `会话分组模块初始化失败: ${String(error?.message ?? error)}`);
110
+ });
111
+
112
+ // 回合开始/结束 → 刷新标题状态后缀;回合结束还触发自动重命名 + 自动续跑判定。
113
+ // 【 吸收官方 all-prompts 节奏】每条真人 user/message 后也触发自动重命名精炼
114
+ // (analyzeSession 内部有「新增≥3条 + 5min 间隔」门槛兜底,不会每条都调 LLM)。
115
+ ctx.on("session/event", (session, event) => {
116
+ const type = event?.type;
117
+ if (type === "turn/start" || type === "turn/end") {
118
+ refreshTitleState(ctx, session).catch((error) => {
119
+ log(ctx, `标题状态刷新失败 ${session?.id}: ${String(error?.message ?? error)}`);
120
+ });
121
+ }
122
+ if (type === "user/message" && event?.data?.source?.kind === "user") {
123
+ const sessionId = session?.id;
124
+ if (typeof sessionId === "string") scheduleAnalysis(ctx, sessionId);
125
+ }
126
+ // 自动续跑全局闸门:检测到「用户第一次手动对话」(turn/start 由 user 发起)
127
+ // → 自动置 gate=open(放行自动续跑)。guardian 在 DSH 恢复健康时置 closed,
128
+ // 这里保证「用户真的开始对话了」才恢复续跑,杜绝崩溃恢复后批量建空壳。
129
+ if (type === "turn/start") {
130
+ const eventPayload = event?.data ?? event;
131
+ const byUser =
132
+ eventPayload?.role === "user" ||
133
+ eventPayload?.user === true ||
134
+ eventPayload?.kind === "user" ||
135
+ eventPayload?.source === "user";
136
+ if (byUser) {
137
+ pluginDomain(ctx)
138
+ .then((domain) => {
139
+ const st = domain.global.get();
140
+ if ((st.autoContinueGate ?? "open") === "closed") {
141
+ return domain.global.set({ ...st, autoContinueGate: "open" });
142
+ }
143
+ return null;
144
+ })
145
+ .then((changed) => {
146
+ if (changed) log(ctx, "🔓 检测到用户手动对话,自动续跑闸门已开放(autoContinueGate=open)");
147
+ })
148
+ .catch((error) => {
149
+ log(ctx, `自动续跑闸门开放失败(不影响会话): ${String(error?.message ?? error)}`);
150
+ });
151
+ }
152
+ }
153
+ if (type !== "turn/end") return;
154
+ const sessionId = session?.id;
155
+ if (typeof sessionId !== "string") return;
156
+ scheduleAnalysis(ctx, sessionId);
157
+ maybeScheduleContinue(ctx, sessionId);
158
+ });
159
+
160
+ // 启动后延迟首扫(等持久化/其他服务就绪),随后周期扫描。
161
+ const firstScan = setTimeout(() => {
162
+ runAutoScan(ctx).catch((error) => {
163
+ log(ctx, `自动续跑首扫失败: ${String(error?.message ?? error)}`);
164
+ });
165
+ scheduleScan(ctx);
166
+ }, AUTO_CONTINUE_SCAN_DELAY_MS);
167
+ firstScan.unref?.();
168
+ ctx.effect(() => {
169
+ return () => {
170
+ clearTimeout(firstScan);
171
+ const running = getScanTimer();
172
+ if (running !== null) clearTimeout(running);
173
+ for (const timer of continueTimers.values()) clearTimeout(timer);
174
+ continueTimers.clear();
175
+ };
176
+ }, "session-conductor: timers");
177
+
178
+ // 在 apply 的活跃 fiber 上预热持久化域并挂 close effect,避免 HTTP handler 首次打开踩 inactive context。
179
+ pluginDomain(ctx).catch((error) => {
180
+ log(ctx, `持久化域预热失败: ${String(error?.message ?? error)}`);
181
+ });
182
+
183
+ // 启动即载入开关配置,并自动把所有自动续跑开关复位为关闭
184
+ // (需求:每次 DSH 启动默认关闭,之后面板逐会话决定;autoRename 不复位)。
185
+ resetAutoContinueOnStart(ctx).catch((error) => {
186
+ log(ctx, `自动续跑开关启动复位失败: ${String(error?.message ?? error)}`);
187
+ });
188
+
189
+ // 设置 → 插件 → 插件配置:注册命名空间,浏览器卡片才能被 ConfigurablePluginsTab 配对渲染。
190
+ // 卡片本身仍走 HTTP API,不把业务字段写进 settings.yaml。
191
+ ctx.inject(["settings"], (sctx) => {
192
+ try {
193
+ const settings = sctx.get("settings");
194
+ if (!settings) return;
195
+ const base = { present: true };
196
+ for (const ns of Object.values(CONDUCTOR_SETTINGS_NS)) {
197
+ settings.register(ns, PresenceSchema, { base });
198
+ }
199
+ log(ctx, "已注册设置命名空间(插件配置页卡片)");
200
+ } catch (e) {
201
+ log(ctx, `设置命名空间注册失败: ${e?.message || e}`);
202
+ }
203
+ });
204
+
205
+ // apply 运行时刻 webServer 的 fiber 可能尚未创建,必须走 inject。
206
+ // 其余服务(workspaceRegistry / sessions / sessionPersistence / sessionTitle / llm / storageDomain)
207
+ // 在请求到达时必然可用,用 ctx.get() 惰性解析并做空值兜底。
208
+ ctx.inject(["webServer"], (wctx) => {
209
+ const webServer = wctx.get("webServer");
210
+
211
+ // ---------- GET /api/session-conductor/i18n ----------
212
+ // 语言包外置 lib/i18n/{zh,en}.json(单份权威,改文案无需重打包 client bundle):
213
+ // 浏览器端 ModuleLoader 的 require 不支持相对路径 JSON(require("./i18n/*.json") 抛 missed-the-module-table),
214
+ // 故由宿主侧读外置 JSON 经 HTTP 暴露、客户端 fetch 拉取合并(语言包外置做法)。
215
+ webServer.register({
216
+ kind: "exact",
217
+ path: "/api/session-conductor/i18n",
218
+ handler: async (req, res) => {
219
+ try {
220
+ if ((req.method ?? "GET") !== "GET") {
221
+ return send(res, 405, { ok: false, error: { code: "METHOD", message: "Method Not Allowed" } });
222
+ }
223
+ const zh = JSON.parse(readFileSync(new URL("./i18n/zh.json", import.meta.url), "utf8"));
224
+ const en = JSON.parse(readFileSync(new URL("./i18n/en.json", import.meta.url), "utf8"));
225
+ return send(res, 200, { ok: true, zh, en });
226
+ } catch (error) {
227
+ return send(res, 500, { ok: false, error: { code: "INTERNAL", message: String(error?.message ?? error) } });
228
+ }
229
+ },
230
+ });
231
+
232
+ // ---------- GET /api/session-conductor/preview/<settings|panel> ----------
233
+ // 真实后端预览页:单文件 html(assets/preview-*.html)经本路由同源服务,浏览器从 DSH 反代地址
234
+ // 打开即同源 fetch /api/*(file:// 双击会被 CORS 拦——DSH API 不带 Access-Control-Allow-Origin)。
235
+ // 用 kind:"exact" 枚举两个预览(DSH 的 kind:"prefix" 实测 401 不生效)
236
+ const PREVIEW_FILES = {
237
+ settings: new URL("../../assets/preview-settings.html", import.meta.url),
238
+ panel: new URL("../../assets/preview-panel.html", import.meta.url),
239
+ };
240
+ const servePreview = async (req, res, name) => {
241
+ try {
242
+ if ((req.method ?? "GET") !== "GET") {
243
+ return send(res, 405, { ok: false, error: { code: "METHOD", message: "Method Not Allowed" } });
244
+ }
245
+ const file = PREVIEW_FILES[name];
246
+ if (!file) return send(res, 404, { ok: false, error: { code: "NOT_FOUND", message: "未知预览: " + name } });
247
+ const html = readFileSync(file, "utf8");
248
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
249
+ return res.end(html);
250
+ } catch (error) {
251
+ return send(res, 500, { ok: false, error: { code: "INTERNAL", message: String(error?.message ?? error) } });
252
+ }
253
+ };
254
+ for (const name of ["settings", "panel"]) {
255
+ webServer.register({
256
+ kind: "exact",
257
+ path: "/api/session-conductor/preview/" + name,
258
+ handler: (req, res) => servePreview(req, res, name),
259
+ });
260
+ }
261
+
262
+ // ---------- GET /api/session-conductor/list ----------
263
+ webServer.register({
264
+ kind: "exact",
265
+ path: "/api/session-conductor/list",
266
+ handler: async (req, res) => {
267
+ try {
268
+ // 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
269
+ if (req.method !== "GET") invalidateSessionListCache();
270
+ // list 流式(仅当请求声明 Accept: application/x-ndjson)——live 会话先快速
271
+ // 写出,冷会话串行逐个解析边写边出,前端 fetch stream 逐行追加(每获取一个立即显示,
272
+ // 不等全部);排序由前端按 updatedAt 倒序插入保持(流式下后端不再整体排序)。
273
+ // 默认(无 Accept 头/旧前端/预览页垫片)仍返回普通 JSON——兼容不匹配的调用方。
274
+ const wantStream =
275
+ /application\/x-ndjson/i.test(req.headers?.accept ?? "") || String(req.url ?? "").includes("stream=1");
276
+ if (!wantStream) {
277
+ const sessions = await buildSessionListCached(ctx);
278
+ send(res, 200, { ok: true, sessions });
279
+ return;
280
+ }
281
+ res.setHeader("Content-Type", "application/x-ndjson; charset=utf-8");
282
+ res.setHeader("Cache-Control", "no-store");
283
+ if (res.socket?.setNoDelay) res.socket.setNoDelay(true);
284
+ res.write(JSON.stringify({ ok: true, stream: true }) + "\n"); // 头行(前端跳过非会话行)
285
+ const onItem = (item) => { try { res.write(JSON.stringify(item) + "\n"); } catch { /* 连接已断 */ } };
286
+ await buildSessionListCached(ctx, { onItem, serial: true });
287
+ try { res.end(); } catch { /* 连接已断 */ }
288
+ } catch (error) {
289
+ try {
290
+ res.end(JSON.stringify({ ok: false, error: { code: "internal", message: String(error?.message ?? error) } }) + "\n");
291
+ } catch { /* 连接已断 */ }
292
+ }
293
+ },
294
+ });
295
+
296
+ // ---------- GET/POST /api/session-conductor/templates ----------
297
+ // 会话模板注入():plan(方案模板)/ closing(收尾模板)两个固定槽位,
298
+ // 支持上传本地 md 文件或在线 md 网址(host 下载转存),内容落盘
299
+ // <DSH_HOME>/template-inject-md/<slot>.md → systemPrompt section「session-templates」注入。
300
+ // GET → {ok, slots: {plan:{enabled,name,url,bytes,updatedAt}, closing:{...}}, maxBytes}
301
+ // POST {slot, enabled?} → 只改开关;
302
+ // POST {slot, name, content} → 上传本地 md;
303
+ // POST {slot, url} → 在线 md 网址下载转存;
304
+ // POST {slot, action:"remove"} → 清空槽位。
305
+ {
306
+ const tplHome = resolveDshHome(ctx, cfg);
307
+ // 浏览根与选用校验根唯一来源 = resolveBrowseRoot(工作区目录)。
308
+ // 同一 browseRoot 变量贯穿本块内两个路由:/templates 的 pickPath 校验、/templates/dir 浏览。
309
+ // 落盘仍 tplHome(DSH_HOME/template-inject-md/),浏览/校验/落盘三者职责清晰。
310
+ const browseRoot = resolveBrowseRoot(ctx, cfg);
311
+ if (tplHome) {
312
+ webServer.register({
313
+ kind: "exact",
314
+ path: "/api/session-conductor/templates",
315
+ handler: async (req, res) => {
316
+ try {
317
+ // 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
318
+ if (req.method !== "GET") invalidateSessionListCache();
319
+ const domain = await pluginDomain(ctx);
320
+ const st = domain.global.get();
321
+ const meta = st.sessionTemplates ?? structuredClone(TEMPLATE_DEFAULTS);
322
+ // 缓存写入口必须是归属模块的 setter(import 绑定对导入方只读,直接赋值会 TypeError)
323
+ setTemplateStateCache(meta);
324
+ // 构造带 content 的 slots(GET/POST 统一:导入/编辑后前端编辑框直接读到内容)
325
+ const buildSlotsWithContent = (meta2) => {
326
+ const out = {};
327
+ for (const slot of TEMPLATE_SLOTS) {
328
+ const slotMeta = meta2[slot] ?? { enabled: false, enforce: false, name: "", url: "", bytes: 0, updatedAt: 0 };
329
+ let content = "";
330
+ if (slotMeta.bytes > 0 || slotMeta.name) {
331
+ // readTemplateSync 返回 sanitized 字符串(无文件/不可读返回空串),不是 {ok,text} 对象
332
+ try { content = String(readTemplateSync(tplHome, slot) ?? "").slice(0, TEMPLATE_MAX_BYTES); } catch { content = ""; }
333
+ }
334
+ out[slot] = { ...slotMeta, content };
335
+ }
336
+ return out;
337
+ };
338
+ if (req.method === "GET") {
339
+ return send(res, 200, { ok: true, slots: buildSlotsWithContent(meta), maxBytes: TEMPLATE_MAX_BYTES });
340
+ }
341
+ if (req.method === "POST") {
342
+ const body = await readJson(req);
343
+ // 目录内选用 md 文件(action="pickPath")——校验用工作区浏览根,落盘用 DSH_HOME
344
+ if (body?.action === "pickPath") {
345
+ const slot = String(body?.slot ?? "");
346
+ if (!TEMPLATE_SLOTS.includes(slot)) {
347
+ return send(res, 400, { ok: false, error: { code: "bad-slot", message: `模板槽位须为 ${TEMPLATE_SLOTS.join("|")}` } });
348
+ }
349
+ const saved = await saveTemplateFromPath(browseRoot, slot, body.path, tplHome);
350
+ if (!saved.ok) return send(res, 400, saved);
351
+ const cur = meta[slot] ?? { enabled: false, enforce: false, name: "", url: "", bytes: 0, updatedAt: 0 };
352
+ const next = {
353
+ ...meta,
354
+ [slot]: {
355
+ enabled: cur.enabled,
356
+ enforce: cur.enforce === true,
357
+ name: saved.name || cur.name,
358
+ url: "",
359
+ bytes: saved.bytes,
360
+ updatedAt: Date.now(),
361
+ },
362
+ };
363
+ setTemplateStateCache(next);
364
+ await domain.global.set({ ...st, sessionTemplates: next });
365
+ return send(res, 200, { ok: true, slots: buildSlotsWithContent(next), message: `模板「${slot}」已从目录选用(${saved.name}),记得点「开启注入」` });
366
+ }
367
+ const slot = String(body?.slot ?? "");
368
+ if (!TEMPLATE_SLOTS.includes(slot)) {
369
+ return send(res, 400, { ok: false, error: { code: "bad-slot", message: `模板槽位须为 ${TEMPLATE_SLOTS.join("|")}` } });
370
+ }
371
+ const cur = meta[slot] ?? { enabled: false, enforce: false, name: "", url: "", bytes: 0, updatedAt: 0 };
372
+ if (body?.action === "remove") {
373
+ const r = await removeTemplate(tplHome, slot);
374
+ if (!r.ok) return send(res, 400, r);
375
+ const next = { ...meta, [slot]: { ...cur, enabled: false, enforce: false, name: "", url: "", bytes: 0, updatedAt: 0 } };
376
+ setTemplateStateCache(next);
377
+ await domain.global.set({ ...st, sessionTemplates: next });
378
+ return send(res, 200, { ok: true, slots: buildSlotsWithContent(next), message: `模板「${slot}」已清空` });
379
+ }
380
+ let saved = null;
381
+ if (typeof body?.url === "string" && body.url.trim()) {
382
+ saved = await saveTemplateFromUrl(tplHome, slot, body.url);
383
+ } else if (typeof body?.content === "string") {
384
+ // 编辑保存:可只带 content(name 保留现值);也可带 name(本地导入)
385
+ saved = await saveTemplate(tplHome, slot, { name: (typeof body?.name === "string" && body.name.trim()) ? body.name : cur.name, content: body.content });
386
+ }
387
+ if (saved && saved.ok) {
388
+ const next = {
389
+ ...meta,
390
+ [slot]: {
391
+ enabled: cur.enabled, // 上传内容不自动开,用户再点开关
392
+ enforce: cur.enforce === true,
393
+ name: saved.name || cur.name,
394
+ url: typeof body?.url === "string" && body.url.trim() ? body.url.trim() : "",
395
+ bytes: saved.bytes,
396
+ updatedAt: Date.now(),
397
+ },
398
+ };
399
+ setTemplateStateCache(next);
400
+ await domain.global.set({ ...st, sessionTemplates: next });
401
+ return send(res, 200, { ok: true, slots: buildSlotsWithContent(next), message: `模板「${slot}」已保存,记得点「开启注入」` });
402
+ }
403
+ if (saved && !saved.ok) return send(res, 400, saved);
404
+ if (typeof body?.enabled === "boolean") {
405
+ const next = { ...meta, [slot]: { ...cur, enabled: body.enabled } };
406
+ setTemplateStateCache(next);
407
+ await domain.global.set({ ...st, sessionTemplates: next });
408
+ return send(res, 200, { ok: true, slots: buildSlotsWithContent(next), message: body.enabled ? `模板「${slot}」已开启注入` : `模板「${slot}」已关闭` });
409
+ }
410
+ // 方案模板强制门禁开关({slot:"plan", enforce:bool})——即时生效,无需重启
411
+ if (slot === "plan" && typeof body?.enforce === "boolean") {
412
+ const next = { ...meta, plan: { ...cur, enforce: body.enforce } };
413
+ setTemplateStateCache(next);
414
+ await domain.global.set({ ...st, sessionTemplates: next });
415
+ return send(res, 200, { ok: true, slots: buildSlotsWithContent(next), message: body.enforce
416
+ ? `方案模板强制门禁已开启(未出提案并获确认前,代码修改类工具调用被拒绝)`
417
+ : `方案模板强制门禁已关闭` });
418
+ }
419
+ return send(res, 400, { ok: false, error: { code: "bad-request", message: "需要 {slot, name, content}(本地 md)、{slot, url}(在线 md)、{slot, enabled} 或 {slot:'plan', enforce}" } });
420
+ }
421
+ return send(res, 405, { ok: false, error: { code: "method", message: MSG_METHOD_ONLY_GET_POST } });
422
+ } catch (error) {
423
+ send(res, 500, { ok: false, error: { code: "internal", message: String(error?.message ?? error) } });
424
+ }
425
+ },
426
+ });
427
+
428
+ // ---------- GET /api/session-conductor/templates/dir ----------
429
+ // 模板目录浏览(「在工作区目录内找」):列 DSH 工作区目录树内一层目录。
430
+ // 浏览根与选用校验根同源(上方 browseRoot);落盘仍在 DSH_HOME/template-inject-md/。
431
+ // GET ?path=... → {ok, path, parent, entries:[{name,path,isDir,isMd}]}(缺省从工作区根开始)
432
+ if (browseRoot) {
433
+ webServer.register({
434
+ kind: "exact",
435
+ path: "/api/session-conductor/templates/dir",
436
+ handler: async (req, res) => {
437
+ try {
438
+ // 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
439
+ if (req.method !== "GET") invalidateSessionListCache();
440
+ if (req.method !== "GET") return send(res, 405, { ok: false, error: { code: "method", message: "仅 GET" } });
441
+ const url = new URL(req.url, "http://localhost");
442
+ const path = url.searchParams.get("path") ?? "";
443
+ const r = await listTemplateDir(browseRoot, path);
444
+ if (!r.ok) return send(res, 400, r);
445
+ return send(res, 200, r);
446
+ } catch (error) {
447
+ send(res, 500, { ok: false, error: { code: "internal", message: String(error?.message ?? error) } });
448
+ }
449
+ },
450
+ });
451
+ }
452
+ }
453
+ }
454
+
455
+ // ---------- GET /api/session-conductor/fts-status ----------
456
+ // 全文搜索诊断( 并入,原独立插件 dsh-session-search 已合并):
457
+ // 报告官方 FTS5(sessionQuery)是否挂载 + 内置 zstd 扫描兜底是否可用。
458
+ webServer.register({
459
+ kind: "exact",
460
+ path: "/api/session-conductor/fts-status",
461
+ handler: async (req, res) => {
462
+ try {
463
+ // 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
464
+ if (req.method !== "GET") invalidateSessionListCache();
465
+ const sessionQuery = ctx.get("sessionQuery");
466
+ const sessionEvents = ctx.get("sessionEvents");
467
+ send(res, 200, {
468
+ ok: true,
469
+ plugin: "dsh-session-conductor",
470
+ sessionQueryMounted: sessionQuery !== void 0,
471
+ ftsMode: sessionQuery === void 0 ? "unavailable" : "first-search",
472
+ builtinScanAvailable: typeof searchSessions === "function",
473
+ note:
474
+ sessionQuery === void 0
475
+ ? "官方 FTS5 服务未挂载(检查 session-query-sqlite openAt 配置),使用内置 zstd 扫描兜底"
476
+ : "官方 FTS5 内容搜索已解锁(openAt: first-search),内置 zstd 扫描为兜底",
477
+ });
478
+ } catch (error) {
479
+ send(res, 500, { ok: false, error: { code: "internal", message: String(error?.message ?? error) } });
480
+ }
481
+ },
482
+ });
483
+
484
+ // ---------- GET/POST /api/session-conductor/compaction-model ----------
485
+ // 压缩模型选择:会话模型旁单独选压缩用模型。
486
+ // GET → {ok, compactionModel: {provider,model}|null, follow: true(跟随会话模型)}
487
+ // POST {provider, model} → 设置;POST {follow:true} → 重置为跟随会话模型
488
+ webServer.register({
489
+ kind: "exact",
490
+ path: "/api/session-conductor/compaction-model",
491
+ handler: async (req, res) => {
492
+ try {
493
+ // 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
494
+ if (req.method !== "GET") invalidateSessionListCache();
495
+ const domain = await pluginDomain(ctx);
496
+ const state = domain.global.get();
497
+ const cur = state.compactionModel ?? null;
498
+ if (req.method === "GET") {
499
+ return send(res, 200, { ok: true, compactionModel: cur, follow: cur === null });
500
+ }
501
+ if (req.method === "POST") {
502
+ const body = await readJson(req);
503
+ if (body?.follow === true) {
504
+ await domain.global.set({ ...state, compactionModel: null });
505
+ return send(res, 200, { ok: true, compactionModel: null, follow: true, message: "压缩模型已重置为跟随会话模型" });
506
+ }
507
+ const provider = String(body?.provider ?? "").trim();
508
+ const model = String(body?.model ?? "").trim();
509
+ if (!provider || !model) return send(res, 400, { ok: false, error: { code: "bad-request", message: "需要 provider 和 model" } });
510
+ await domain.global.set({ ...state, compactionModel: { provider, model } });
511
+ return send(res, 200, { ok: true, compactionModel: { provider, model }, follow: false, message: `压缩模型已设为 ${provider}/${model}(重启后生效)` });
512
+ }
513
+ return send(res, 405, { ok: false, error: { code: "method", message: MSG_METHOD_ONLY_GET_POST } });
514
+ } catch (error) {
515
+ send(res, 500, { ok: false, error: { code: "internal", message: String(error?.message ?? error) } });
516
+ }
517
+ },
518
+ });
519
+
520
+ // ---------- GET/POST /api/session-conductor/auto-rename-model ----------
521
+ // 自动重命名模型选择:设置页「DSH 同款解析选择器」选定。
522
+ // GET → {ok, selection: {provider,model}|null, follow: true(跟随会话模型), catalog}
523
+ // catalog = 官方 modelCatalog(provider 分组 + 模型 + 默认),供前端渲染选择器。
524
+ // POST {provider, model} → 设置;POST {follow:true} → 重置为跟随会话模型。
525
+ webServer.register({
526
+ kind: "exact",
527
+ path: "/api/session-conductor/auto-rename-model",
528
+ handler: async (req, res) => {
529
+ try {
530
+ const domain = await pluginDomain(ctx);
531
+ const state = domain.global.get();
532
+ const cur = state.autoRenameModel ?? null;
533
+ if (req.method === "GET") {
534
+ // 官方解析选择器的数据源:provider 分组 + 模型列表 + 部署默认
535
+ let catalog = null;
536
+ try {
537
+ const sc = ctx.get("sessionController");
538
+ catalog = sc?.modelCatalog ? await sc.modelCatalog() : null;
539
+ } catch (error) {
540
+ log(ctx, `auto-rename-model: 官方 modelCatalog 获取失败(前端仍可手动输入): ${String(error?.message ?? error)}`);
541
+ catalog = null;
542
+ }
543
+ return send(res, 200, { ok: true, selection: cur, follow: cur === null, catalog });
544
+ }
545
+ if (req.method === "POST") {
546
+ const body = await readJson(req);
547
+ if (body?.follow === true) {
548
+ await domain.global.set({ ...state, autoRenameModel: null });
549
+ return send(res, 200, { ok: true, selection: null, follow: true, message: "自动重命名模型已重置为跟随会话模型" });
550
+ }
551
+ const provider = String(body?.provider ?? "").trim();
552
+ const model = String(body?.model ?? "").trim();
553
+ if (!provider || !model) return send(res, 400, { ok: false, error: { code: "bad-request", message: "需要 provider 和 model" } });
554
+ await domain.global.set({ ...state, autoRenameModel: { provider, model } });
555
+ return send(res, 200, { ok: true, selection: { provider, model }, follow: false, message: `自动重命名模型已设为 ${provider}/${model}` });
556
+ }
557
+ return send(res, 405, { ok: false, error: { code: "method", message: MSG_METHOD_ONLY_GET_POST } });
558
+ } catch (error) {
559
+ send(res, 500, { ok: false, error: { code: "internal", message: String(error?.message ?? error) } });
560
+ }
561
+ },
562
+ });
563
+
564
+ // ---------- GET/POST /api/session-conductor/auto-continue-gate ----------
565
+ // 自动续跑全局闸门(与 guardian 联动):
566
+ // GET → {ok, gate: "open"|"closed", note}
567
+ // POST {gate:"open"|"closed"} → 设置;closed 时一切自动续跑跳过(周期扫描/scan 自动续跑部分)
568
+ // guardian 在 DSH 恢复健康时置 closed;用户手动开启或首次手动对话(turn/start user)后自动置 open
569
+ webServer.register({
570
+ kind: "exact",
571
+ path: "/api/session-conductor/auto-continue-gate",
572
+ handler: async (req, res) => {
573
+ try {
574
+ // 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
575
+ if (req.method !== "GET") invalidateSessionListCache();
576
+ const domain = await pluginDomain(ctx);
577
+ const state = domain.global.get();
578
+ const cur = state.autoContinueGate ?? "open";
579
+ if (req.method === "GET") {
580
+ return send(res, 200, {
581
+ ok: true,
582
+ gate: cur,
583
+ note: cur === "closed"
584
+ ? "自动续跑全局闸门关闭:一切自动续跑跳过(DSH 刚启动/崩溃恢复后),用户手动开启或第一次手动对话后自动放行"
585
+ : "自动续跑全局闸门开放:按原有判定(单会话开关 → 全局默认)",
586
+ });
587
+ }
588
+ if (req.method === "POST") {
589
+ const body = await readJson(req);
590
+ const gate = String(body?.gate ?? "");
591
+ if (gate !== "open" && gate !== "closed") {
592
+ return send(res, 400, { ok: false, error: { code: "bad-request", message: 'gate 必须为 "open" 或 "closed"' } });
593
+ }
594
+ await domain.global.set({ ...state, autoContinueGate: gate });
595
+ return send(res, 200, {
596
+ ok: true,
597
+ gate,
598
+ message: gate === "closed" ? "自动续跑闸门已关闭(所有自动续跑暂停)" : "自动续跑闸门已开放",
599
+ });
600
+ }
601
+ return send(res, 405, { ok: false, error: { code: "method", message: MSG_METHOD_ONLY_GET_POST } });
602
+ } catch (error) {
603
+ send(res, 500, { ok: false, error: { code: "internal", message: String(error?.message ?? error) } });
604
+ }
605
+ },
606
+ });
607
+
608
+ // ---------- 会话操作路由(归档/取消归档/删除/撤回)----------
609
+ // 从本文件抽出为 lib/features/session-ops-routes.js:apply 只保留「注册入口」,
610
+ // 具体 handler 实现见该模块(行为逐字不变,依赖以 deps 传入)。
611
+ registerSessionOpsRoutes({ webServer, ctx, send, readJson, invalidateSessionListCache });
612
+
613
+ // ---------- POST /api/session-conductor/search ----------
614
+ // 全文搜索:跨会话搜消息内容,返回命中会话 + 上下文片段
615
+ webServer.register({
616
+ kind: "exact",
617
+ path: "/api/session-conductor/search",
618
+ handler: async (req, res) => {
619
+ try {
620
+ // 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
621
+ if (req.method !== "GET") invalidateSessionListCache();
622
+ const body = await readJson(req);
623
+ const query = typeof body?.query === "string" ? body.query : "";
624
+ const scope = ["all", "active", "archived"].includes(body?.scope) ? body.scope : "all";
625
+ if (query.trim().length < SEARCH_MIN_QUERY) {
626
+ return send(res, 400, { ok: false, error: { code: "bad-request", message: `关键词至少 ${SEARCH_MIN_QUERY} 个字符` } });
627
+ }
628
+ const searchResult = await searchSessions(ctx, query, { scope });
629
+ send(res, 200, { ok: true, ...searchResult });
630
+ } catch (error) {
631
+ send(res, 400, { ok: false, error: { code: "search-failed", message: String(error?.message ?? error) } });
632
+ }
633
+ },
634
+ });
635
+
636
+ // ---------- POST /api/session-conductor/auto-rename ----------
637
+ webServer.register({
638
+ kind: "exact",
639
+ path: "/api/session-conductor/auto-rename",
640
+ handler: async (req, res) => {
641
+ try {
642
+ // 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
643
+ if (req.method !== "GET") invalidateSessionListCache();
644
+ const body = await readJson(req);
645
+ if (typeof body?.sessionId !== "string" || body.sessionId === "") {
646
+ return send(res, 400, { ok: false, error: { code: "bad-request", message: MSG_NEED_SESSION_ID } });
647
+ }
648
+ const enabled = body.enabled === true;
649
+ const setting = await patchSwitch(ctx, "autoRename", body.sessionId, { enabled }, { enabled: false });
650
+ let analyzed = null;
651
+ if (enabled) analyzed = await runAnalysis(ctx, body.sessionId); // 开启后立即分析一次
652
+ send(res, 200, { ok: true, enabled: setting.enabled, analyzed });
653
+ } catch (error) {
654
+ send(res, 400, { ok: false, error: { code: "auto-rename-failed", message: String(error?.message ?? error) } });
655
+ }
656
+ },
657
+ });
658
+
659
+ // ---------- POST /api/session-conductor/analyze ----------
660
+ webServer.register({
661
+ kind: "exact",
662
+ path: "/api/session-conductor/analyze",
663
+ handler: async (req, res) => {
664
+ try {
665
+ // 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
666
+ if (req.method !== "GET") invalidateSessionListCache();
667
+ const body = await readJson(req);
668
+ if (typeof body?.sessionId !== "string" || body.sessionId === "") {
669
+ return send(res, 400, { ok: false, error: { code: "bad-request", message: MSG_NEED_SESSION_ID } });
670
+ }
671
+ const analysisResult = await runAnalysis(ctx, body.sessionId, {
672
+ manual: true, // 手动 API:跳过限频/新消息数门槛(显式意图即执行)
673
+ model: typeof body?.model === "string" && body.model !== "" ? body.model : undefined,
674
+ });
675
+ if (!analysisResult.ok) return send(res, 409, analysisResult);
676
+ send(res, 200, analysisResult);
677
+ } catch (error) {
678
+ send(res, 400, { ok: false, error: { code: "analyze-failed", message: String(error?.message ?? error) } });
679
+ }
680
+ },
681
+ });
682
+
683
+ // ---------- POST /api/session-conductor/continue ----------
684
+ // 手动续跑:异步执行(回合可能耗时数分钟),立即返回 accepted,列表用 continueRunning 反映进行中。
685
+ webServer.register({
686
+ kind: "exact",
687
+ path: "/api/session-conductor/continue",
688
+ handler: async (req, res) => {
689
+ try {
690
+ // 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
691
+ if (req.method !== "GET") invalidateSessionListCache();
692
+ const body = await readJson(req);
693
+ if (typeof body?.sessionId !== "string" || body.sessionId === "") {
694
+ return send(res, 400, { ok: false, error: { code: "bad-request", message: MSG_NEED_SESSION_ID } });
695
+ }
696
+ const sessionId = body.sessionId;
697
+ const running = continueJobs.has(sessionId) || (() => {
698
+ try {
699
+ const agent = ctx.get("agents")?.get(sessionId);
700
+ return agent?.status === "running";
701
+ } catch {
702
+ return false;
703
+ }
704
+ })();
705
+ if (running) {
706
+ return send(res, 409, { ok: false, error: { code: "busy", message: "该会话已有续跑/回合在进行中" } });
707
+ }
708
+ continueSession(ctx, sessionId, { auto: false }).then((result) => {
709
+ if (!result?.ok) log(ctx, `手动续跑 ${sessionId} 未成功: ${result?.error?.message ?? "?"}`);
710
+ }).catch((error) => {
711
+ // 手动续跑异步链兜底——continueSession 永不 reject(prepare 段已包 try),
712
+ // 此处再兜一层防未来回归把 rejection 逃逸成 unhandledRejection 杀进程。
713
+ log(ctx, `手动续跑 ${sessionId} 异常: ${String(error?.message ?? error)}`);
714
+ });
715
+ send(res, 200, { ok: true, accepted: true, sessionId });
716
+ } catch (error) {
717
+ send(res, 400, { ok: false, error: { code: "continue-failed", message: String(error?.message ?? error) } });
718
+ }
719
+ },
720
+ });
721
+
722
+ // ---------- POST /api/session-conductor/message ----------
723
+ // 跨会话消息投递:{targetSessionId, message, fromSessionId?}
724
+ // 向目标会话投递用户消息并唤起(live followup / cold resume+followup),不受"中断"限制。
725
+ webServer.register({
726
+ kind: "exact",
727
+ path: "/api/session-conductor/message",
728
+ handler: async (req, res) => {
729
+ try {
730
+ // 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
731
+ if (req.method !== "GET") invalidateSessionListCache();
732
+ const body = await readJson(req);
733
+ const sessionId = body?.targetSessionId || body?.sessionId;
734
+ const text = body?.message;
735
+ if (typeof sessionId !== "string" || sessionId === "" || typeof text !== "string" || text === "") {
736
+ return send(res, 400, { ok: false, error: { code: "bad-request", message: "缺少 targetSessionId 或 message" } });
737
+ }
738
+ // 异步执行(回合可能耗时),立即返回 accepted
739
+ const r = await sendMessageToSession(ctx, sessionId, text, { fromSessionId: body?.fromSessionId || "" });
740
+ return send(res, r.ok ? 200 : (r.error?.code === "not-found" ? 404 : 400), r);
741
+ } catch (error) {
742
+ return send(res, 400, { ok: false, error: { code: "message-failed", message: String(error?.message ?? error) } });
743
+ }
744
+ },
745
+ });
746
+
747
+ // ---------- POST /api/session-conductor/auto-continue ----------
748
+ // 开启/关闭某会话的自动续跑(显式 enabled 覆盖全局默认)。
749
+ webServer.register({
750
+ kind: "exact",
751
+ path: "/api/session-conductor/auto-continue",
752
+ handler: async (req, res) => {
753
+ try {
754
+ // 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
755
+ if (req.method !== "GET") invalidateSessionListCache();
756
+ const body = await readJson(req);
757
+ if (typeof body?.sessionId !== "string" || body.sessionId === "") {
758
+ return send(res, 400, { ok: false, error: { code: "bad-request", message: MSG_NEED_SESSION_ID } });
759
+ }
760
+ const enabled = body.enabled === true;
761
+ // 开关落盘 config.json;先取旧 entry 保留记账字段,enabled 以本次为准。
762
+ const entry = readSwitch("autoContinue", body.sessionId) ?? {};
763
+ await patchSwitch(ctx, "autoContinue", body.sessionId, { ...entry, enabled });
764
+ // 开启开关即触发一次续跑(force 跳过失败重试延迟)——原先要等最长
765
+ // scanIntervalMs(默认 5 分钟)周期扫描才续,用户体感「打开后处理中很久」。
766
+ // 关闭开关不触发;开启是幂等触发点,重复点不会叠加(内部有会话串行锁 + 门槛)。
767
+ if (enabled) {
768
+ runAutoContinueSession(ctx, body.sessionId, { force: true }).catch((error) => {
769
+ log(ctx, `开启自动续跑后立即续跑失败 ${body.sessionId}: ${String(error?.message ?? error)}`);
770
+ });
771
+ }
772
+ send(res, 200, { ok: true, enabled, sessionId: body.sessionId, triggered: enabled });
773
+ } catch (error) {
774
+ send(res, 400, { ok: false, error: { code: "auto-continue-failed", message: String(error?.message ?? error) } });
775
+ }
776
+ },
777
+ });
778
+
779
+ // ---------- POST /api/session-conductor/scan ----------
780
+ // 立即扫描一次全部会话并自动续跑(测试/手动触发用)。
781
+ webServer.register({
782
+ kind: "exact",
783
+ path: "/api/session-conductor/scan",
784
+ handler: async (req, res) => {
785
+ try {
786
+ // 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
787
+ if (req.method !== "GET") invalidateSessionListCache();
788
+ runAutoScan(ctx).then((count) => {
789
+ log(ctx, `手动扫描完成(排队 ${count ?? 0})`);
790
+ }).catch((error) => {
791
+ log(ctx, `手动扫描失败: ${String(error?.message ?? error)}`);
792
+ });
793
+ send(res, 200, { ok: true, accepted: true });
794
+ } catch (error) {
795
+ send(res, 400, { ok: false, error: { code: "scan-failed", message: String(error?.message ?? error) } });
796
+ }
797
+ },
798
+ });
799
+
800
+ // ---------- POST /api/session-conductor/detach ----------
801
+ // 手动释放(置为不活跃):把 live 空闲会话拆回冷状态(日志保留,可重新挂载)。
802
+ webServer.register({
803
+ kind: "exact",
804
+ path: "/api/session-conductor/detach",
805
+ handler: async (req, res) => {
806
+ try {
807
+ // 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
808
+ if (req.method !== "GET") invalidateSessionListCache();
809
+ const body = await readJson(req);
810
+ if (typeof body?.sessionId !== "string" || body.sessionId === "") {
811
+ return send(res, 400, { ok: false, error: { code: "bad-request", message: MSG_NEED_SESSION_ID } });
812
+ }
813
+ const detachResult = await detachSessionAgent(ctx, body.sessionId);
814
+ if (!detachResult.ok) return send(res, 409, detachResult);
815
+ send(res, 200, detachResult);
816
+ } catch (error) {
817
+ send(res, 400, { ok: false, error: { code: "detach-failed", message: String(error?.message ?? error) } });
818
+ }
819
+ },
820
+ });
821
+
822
+ // ---------- POST /api/session-conductor/detach-all ----------
823
+ // 释放全部 live 空闲会话(跳过运行中 / subagent / 续跑中)。
824
+ webServer.register({
825
+ kind: "exact",
826
+ path: "/api/session-conductor/detach-all",
827
+ handler: async (req, res) => {
828
+ try {
829
+ // 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
830
+ if (req.method !== "GET") invalidateSessionListCache();
831
+ const detachAllResult = await detachAllIdleSessions(ctx);
832
+ send(res, 200, detachAllResult);
833
+ } catch (error) {
834
+ send(res, 400, { ok: false, error: { code: "detach-all-failed", message: String(error?.message ?? error) } });
835
+ }
836
+ },
837
+ });
838
+
839
+ // ---------- 会话日志修复(扫描/修复损坏会话) ----------
840
+ webServer.register({
841
+ kind: "exact",
842
+ path: "/api/session-conductor/repair-sessions",
843
+ handler: async (req, res) => {
844
+ try {
845
+ // 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
846
+ if (req.method !== "GET") invalidateSessionListCache();
847
+ const body = await readJson(req);
848
+ const { supportsRepair } = await lazyRepair();
849
+ if (!supportsRepair(ctx)) {
850
+ return send(res, 501, { ok: false, error: { code: "unsupported", message: "当前持久化后端不支持修复(需要 jsonl 后端)" } });
851
+ }
852
+ const dryRun = body?.dryRun === true;
853
+ const { scanCorruptSessions, repairCorruptSessions } = await lazyRepair();
854
+ const report = dryRun ? await scanCorruptSessions(ctx) : await repairCorruptSessions(ctx, { dryRun: false });
855
+ if (report.error) return send(res, 500, { ok: false, error: { code: "repair-failed", message: report.error } });
856
+ send(res, 200, { ok: true, dryRun, report });
857
+ } catch (error) {
858
+ send(res, 400, { ok: false, error: { code: "repair-failed", message: String(error?.message ?? error) } });
859
+ }
860
+ },
861
+ });
862
+
863
+ // ---------- POST /api/session-conductor/repair-frames ----------
864
+ // 会话日志 zstd 帧修复(救援恢复,):会话列表消失/corrupt Zstandard 时,
865
+ // 扫描并修复帧损坏(逐帧解码→多帧正确写回,自动备份)。与 repair-sessions 互补:
866
+ // repair-sessions 修 tool-result content 结构;repair-frames 修 zstd 帧格式。
867
+ webServer.register({
868
+ kind: "exact",
869
+ path: "/api/session-conductor/repair-frames",
870
+ handler: async (req, res) => {
871
+ try {
872
+ // 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
873
+ if (req.method !== "GET") invalidateSessionListCache();
874
+ const body = await readJson(req);
875
+ const dryRun = body?.dryRun === true;
876
+ const { scanCorruptFrames, repairCorruptFrames } = await lazyRepair();
877
+ const report = dryRun ? await scanCorruptFrames() : await repairCorruptFrames({ dryRun: false });
878
+ send(res, 200, { ok: true, dryRun, report });
879
+ } catch (error) {
880
+ send(res, 400, { ok: false, error: { code: "repair-frames-failed", message: String(error?.message ?? error) } });
881
+ }
882
+ },
883
+ });
884
+
885
+ // ---------- POST /api/session-conductor/repair-seq-gap ----------
886
+ // 会话日志 seq-gap + token-surface 修复(实测):
887
+ // seq gap 错位(spliced 拼接未重编号)+ replace 引用缺失 + compaction shadowedRange 未映射
888
+ // 三层叠加导致 history unavailable(token surface: no adjacent shadow price)。
889
+ // dryRun=true 扫描全部会话列出疑似 seq-gap;sessionId 指定则修复单个(自动备份 .seq-gap-backup/)。
890
+ webServer.register({
891
+ kind: "exact",
892
+ path: "/api/session-conductor/repair-seq-gap",
893
+ handler: async (req, res) => {
894
+ try {
895
+ // 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
896
+ if (req.method !== "GET") invalidateSessionListCache();
897
+ const body = await readJson(req);
898
+ const sessionsRoot = path.join(process.env.DSH_HOME || path.join(process.env.HOME ?? "", ".dsh"), "sessions");
899
+ if (body?.dryRun === true) {
900
+ const { scanSeqGapSessions } = await lazySeqGap();
901
+ const list = await scanSeqGapSessions(sessionsRoot);
902
+ send(res, 200, { ok: true, dryRun: true, corrupt: list, total: list.length });
903
+ return;
904
+ }
905
+ const sessionId = body?.sessionId;
906
+ if (!sessionId) { send(res, 400, { ok: false, error: { code: "session-id-required", message: "need sessionId" } }); return; }
907
+ // 定位会话日志路径(sessionId 需含 session- 前缀,cwd 目录未知 → 全仓查找)
908
+ const { readdirSync, existsSync } = await lazyFs();
909
+ let targetPath = null;
910
+ for (const proj of readdirSync(sessionsRoot, { withFileTypes: true })) {
911
+ if (!proj.isDirectory()) continue;
912
+ const p = findSessionLog(path.join(sessionsRoot, proj.name, sessionId));
913
+ if (p) { targetPath = p; break; }
914
+ }
915
+ if (!targetPath) { send(res, 404, { ok: false, error: { code: "session-not-found", message: sessionId } }); return; }
916
+ const { repairSeqGap } = await lazySeqGap();
917
+ const report = await repairSeqGap(targetPath, { dryRun: false });
918
+ send(res, report.ok ? 200 : 400, { ok: report.ok, sessionId, report });
919
+ } catch (error) {
920
+ send(res, 400, { ok: false, error: { code: "repair-seq-gap-failed", message: String(error?.message ?? error) } });
921
+ }
922
+ },
923
+ });
924
+
925
+ // ---------- POST /api/session-conductor/repair-eio ----------
926
+ // 会话日志 EIO 坏块修复(实测固化,来源 sa6400-nested-vm-io-panic skill):
927
+ // 底层存储(BTRFS csum 损坏 / iSCSI LUN I/O 错误)导致 session.jsonl.zstd 某些 4KB 块
928
+ // 读取抛 EIO(Errno 5)→ 网关 observe 会话报 "EIO: i/o error, read"。
929
+ // 修复 = 块级探测找出第一个 EIO 边界 → 读 [0, 边界) 完好前缀 → 原子写回
930
+ // (BTRFS COW 换新块)→ DSH 加载时 readZstdPrefix/commitRepair 自动收尾 torn tail。
931
+ // dryRun=true 全仓扫描;sessionId 指定则修复单个(全仓定位)。
932
+ webServer.register({
933
+ kind: "exact",
934
+ path: "/api/session-conductor/repair-eio",
935
+ handler: async (req, res) => {
936
+ try {
937
+ // 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
938
+ if (req.method !== "GET") invalidateSessionListCache();
939
+ const body = await readJson(req);
940
+ const { sessionsRootOf: eioSessionsRootOf } = await lazyEio();
941
+ const sessionsRoot = eioSessionsRootOf();
942
+ if (body?.dryRun === true) {
943
+ const { scanEioSessions } = await lazyEio();
944
+ const list = await scanEioSessions(sessionsRoot);
945
+ send(res, 200, { ok: true, dryRun: true, eio: list.eio, total: list.total, healthy: list.healthy, error: list.error });
946
+ return;
947
+ }
948
+ const sessionId = body?.sessionId;
949
+ if (sessionId) {
950
+ // 定位会话日志路径(sessionId 需含 session- 前缀,cwd 目录未知 → 全仓查找)
951
+ const { readdirSync, existsSync } = await lazyFs();
952
+ let targetPath = null;
953
+ for (const proj of readdirSync(sessionsRoot, { withFileTypes: true })) {
954
+ if (!proj.isDirectory()) continue;
955
+ const p = findSessionLog(path.join(sessionsRoot, proj.name, sessionId));
956
+ if (p) { targetPath = p; break; }
957
+ }
958
+ if (!targetPath) { send(res, 404, { ok: false, error: { code: "session-not-found", message: sessionId } }); return; }
959
+ const { repairEioFile } = await lazyEio();
960
+ const report = await repairEioFile(targetPath);
961
+ send(res, report.ok ? 200 : 400, { ok: report.ok, sessionId, report });
962
+ return;
963
+ }
964
+ const { repairEioSessions } = await lazyEio();
965
+ const report = await repairEioSessions({ dryRun: false });
966
+ send(res, 200, { ok: true, dryRun: false, report });
967
+ } catch (error) {
968
+ send(res, 400, { ok: false, error: { code: "repair-eio-failed", message: String(error?.message ?? error) } });
969
+ }
970
+ },
971
+ });
972
+
973
+ // ---------- POST /api/session-conductor/repair-dual-format ----------
974
+ // 双格式会话修复:会话目录同时存在 session.jsonl(明文)+ session.jsonl.zstd
975
+ // (压缩)→ 官方 listArtifacts() 抛 encodingMismatch → 会话列表全失败(侧边栏会话消失)。
976
+ // 纯磁盘级扫描(不走 sessionPersistence,后者自身会被 encodingMismatch 阻断),
977
+ // 把多余明文移入 .dual-format-backup/ 保留 zstd 官方格式;dryRun=true 只扫不写。
978
+ webServer.register({
979
+ kind: "exact",
980
+ path: "/api/session-conductor/repair-dual-format",
981
+ handler: async (req, res) => {
982
+ try {
983
+ // 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
984
+ if (req.method !== "GET") invalidateSessionListCache();
985
+ const body = await readJson(req);
986
+ const dryRun = body?.dryRun === true;
987
+ // live 会话跳过:正在写入的会话不应被移动(按 id 查 sessions 服务)
988
+ let skipIds = [];
989
+ try {
990
+ const sessionsSvc = ctx.get("sessions");
991
+ skipIds = (sessionsSvc?.list?.() ?? []).map((s) => s.id);
992
+ } catch {
993
+ // sessions 服务不可用时不做 live 跳过(保守:仍只移非 live 目录)
994
+ }
995
+ const report = dryRun
996
+ ? await (await lazyRepair()).scanDualFormatSessions()
997
+ // 修复:原为裸调 `repairDualFormatSessions(...)`,但该名字从未导入 ⇒ 抛
998
+ // ReferenceError 被下面的 catch 变成 400「repair-dual-format-failed」,非 dryRun 的
999
+ // 双格式修复实际从未执行过。改为与上一行同源的懒加载命名空间调用。
1000
+ : await (await lazyRepair()).repairDualFormatSessions({ dryRun: false, skipIds });
1001
+ if (report.error) {
1002
+ send(res, 500, { ok: false, error: { code: "repair-dual-format-failed", message: report.error } });
1003
+ return;
1004
+ }
1005
+ send(res, 200, { ok: true, dryRun, skipped: report.skipped ?? 0, report });
1006
+ } catch (error) {
1007
+ send(res, 400, { ok: false, error: { code: "repair-dual-format-failed", message: String(error?.message ?? error) } });
1008
+ }
1009
+ },
1010
+ });
1011
+
1012
+ // ---------- POST /api/session-conductor/value-analysis ----------
1013
+ // 会话价值分析( 规则判定 + 特征/LLM/关键词价值):
1014
+ // 规则分类 completed/unfinished/stale/active + 最后回复摘要;
1015
+ // 高/低价值 = assessValue 特征评分(活跃/长度/完成/未完成/细节补充);
1016
+ // 可选 body.keywords=[...] 记录指定关键词 → 标题/最后用户消息命中任一关键词的会话无条件最高价值;
1017
+ // 可选 body.llm=true 额外用 LLM 打分(fail-soft)。
1018
+ webServer.register({
1019
+ kind: "exact",
1020
+ path: "/api/session-conductor/value-analysis",
1021
+ handler: async (req, res) => {
1022
+ try {
1023
+ // 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
1024
+ if (req.method !== "GET") invalidateSessionListCache();
1025
+ const body = await readJson(req);
1026
+ const staleDays = typeof body?.staleDays === "number" && Number.isFinite(body.staleDays)
1027
+ ? Math.min(MAX_STALE_DAYS, Math.max(1, Math.round(body.staleDays)))
1028
+ : 3;
1029
+ const useLlm = body?.llm === true;
1030
+ const sessions = await buildSessionListCached(ctx); // 价值分析走缓存(容忍 5s 陈旧,避免与面板刷新叠加扫描)
1031
+ // 一次性读取各会话事件(复用,避免大会话重复解压导致超时/失败)
1032
+ const eventsById = {};
1033
+ // 逐个会话读取最后 assistant 文本 + 最后用户消息(损坏/不可读会话跳过,用空串)
1034
+ const texts = {};
1035
+ const userTexts = {};
1036
+ for (const s of sessions) {
1037
+ try {
1038
+ const found = await sessionEventsOf(ctx, s.id);
1039
+ if (found) {
1040
+ eventsById[s.id] = found.events || [];
1041
+ const { lastAssistantText, lastUserText, filterSessionsByKeywords } = await lazyValue();
1042
+ texts[s.id] = lastAssistantText(found.events);
1043
+ userTexts[s.id] = lastUserText(found.events);
1044
+ }
1045
+ } catch {
1046
+ /* 单会话失败跳过 */
1047
+ }
1048
+ }
1049
+ // 可选 LLM 高/低价值判断
1050
+ let llmById = {};
1051
+ if (useLlm) {
1052
+ llmById = await analyzeValueWithLlm(ctx, sessions, texts, (m) => log(ctx, m));
1053
+ }
1054
+ // 特征评分(活跃/长度/完成/未完成/细节补充)+ 关键词命中→无条件最高
1055
+ const featuresById = {};
1056
+ for (const s of sessions) {
1057
+ try {
1058
+ const { buildValueFeatures } = await lazyValue();
1059
+ featuresById[s.id] = buildValueFeatures(eventsById[s.id] || [], s, new Date());
1060
+ } catch {
1061
+ const { buildValueFeatures: buildValueFeatures2 } = await lazyValue();
1062
+ featuresById[s.id] = buildValueFeatures2([], s, new Date());
1063
+ }
1064
+ }
1065
+ const keywords = Array.isArray(body?.keywords) ? body.keywords : [];
1066
+ const { analyzeValuesWithKeywords } = await lazyValue();
1067
+ const analysisSummary = analyzeValuesWithKeywords(sessions, texts, userTexts, featuresById, keywords, llmById, new Date(), staleDays);
1068
+ const counts = {};
1069
+ for (const key of Object.keys(analysisSummary)) counts[key] = analysisSummary[key].length;
1070
+ send(res, 200, {
1071
+ ok: true,
1072
+ staleDays,
1073
+ llm: !!useLlm,
1074
+ keywords: keywords.length ? keywords : void 0,
1075
+ keywordHits: keywords.length ? filterSessionsByKeywords(sessions, texts, userTexts, keywords, new Date()) : [],
1076
+ generatedAt: Date.now(),
1077
+ counts,
1078
+ completed: analysisSummary.completed,
1079
+ unfinished: analysisSummary.unfinished,
1080
+ stale: analysisSummary.stale,
1081
+ active: analysisSummary.active,
1082
+ high: analysisSummary.high,
1083
+ low: analysisSummary.low,
1084
+ });
1085
+ } catch (error) {
1086
+ send(res, 400, { ok: false, error: { code: "value-analysis-failed", message: String(error?.message ?? error) } });
1087
+ }
1088
+ },
1089
+ });
1090
+
1091
+ // ---------- 任务完成汇报(task-completion-report 管道) ----------
1092
+ // 收尾约定不再由代码写死注入 systemPrompt——改由模板注入「收尾模板」槽位承担
1093
+ // (设置 → 会话管理 → 模板注入 → closing,可自定义内容)。
1094
+ // LLM 侧的 render/check 工具已移除,只剩下面的 HTTP 端点(面板/自检用),故 tools 如实回报空列表。
1095
+ webServer.register({
1096
+ kind: "exact",
1097
+ path: "/api/task-completion/status",
1098
+ handler: async (req, res) => {
1099
+ send(res, 200, {
1100
+ ok: true,
1101
+ name: "dsh-session-conductor",
1102
+ skill: "task-completion-report",
1103
+ conventionSource: "template-inject:closing(不再代码写死,由收尾模板槽位注入)",
1104
+ tools: []
1105
+ });
1106
+ },
1107
+ });
1108
+
1109
+ webServer.register({
1110
+ kind: "exact",
1111
+ path: "/api/task-completion/render",
1112
+ handler: async (req, res) => {
1113
+ try {
1114
+ // 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
1115
+ if (req.method !== "GET") invalidateSessionListCache();
1116
+ const body = await readJson(req);
1117
+ const block = renderCompletionBlock(body);
1118
+ send(res, 200, { ok: true, block });
1119
+ } catch (error) {
1120
+ send(res, 400, { ok: false, error: { code: "render-failed", message: String(error?.message ?? error) } });
1121
+ }
1122
+ },
1123
+ });
1124
+
1125
+ webServer.register({
1126
+ kind: "exact",
1127
+ path: "/api/task-completion/check",
1128
+ handler: async (req, res) => {
1129
+ try {
1130
+ // 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
1131
+ if (req.method !== "GET") invalidateSessionListCache();
1132
+ const body = await readJson(req);
1133
+ if (typeof body?.text !== "string") {
1134
+ return send(res, 400, { ok: false, error: { code: "bad-request", message: "缺少 text" } });
1135
+ }
1136
+ send(res, 200, { ok: true, ...checkCompletionText(body.text) });
1137
+ } catch (error) {
1138
+ send(res, 400, { ok: false, error: { code: "check-failed", message: String(error?.message ?? error) } });
1139
+ }
1140
+ },
1141
+ });
1142
+
1143
+ log(ctx, `API 路由已注册 (/api/session-conductor/* + /api/task-completion/*);自动续跑: enabled=${cfg.enabled}, 默认开启=${cfg.defaultAutoContinue}, maxAttached=${cfg.maxAttached}, maxConcurrent=${cfg.maxConcurrent}`);
1144
+ });
1145
+
1146
+ // 启动水合:把存储里的会话模板状态读进 templateStateCache。
1147
+ //
1148
+ // 为什么必须有:templateStateCache 的初值是 structuredClone(TEMPLATE_DEFAULTS)(plan/closing 均
1149
+ // enabled=false、enforce=false),而**只有 /templates 路由被访问过**才会用存储值覆写它
1150
+ // ⇒ 宿主重启后若没有打开过设置页(没有任何 /templates 请求),模板注入与「方案模板强制门禁」
1151
+ // 就全部按“默认关闭”执行 —— 设置里明明是开启的,却既不注入也不拦截(同一根因两个症状)。
1152
+ // 做法:首次需要时从插件存储读一次并写入缓存,之后复用同一个 Promise(只水合一次)。
1153
+ let templateHydration = null;
1154
+ const hydrateTemplateState = () => {
1155
+ if (!templateHydration) {
1156
+ templateHydration = (async () => {
1157
+ try {
1158
+ const domain = await pluginDomain(ctx);
1159
+ const st = domain.global.get();
1160
+ setTemplateStateCache(st.sessionTemplates ?? structuredClone(TEMPLATE_DEFAULTS));
1161
+ } catch (error) {
1162
+ log(ctx, `会话模板状态水合失败(按默认值继续): ${String(error?.message ?? error)}`);
1163
+ }
1164
+ })();
1165
+ }
1166
+ return templateHydration;
1167
+ };
1168
+
1169
+ // 任务完成汇报:约定注入每个 agent 系统提示 + 渲染/校验工具。
1170
+ // ── 方案模板强制门禁(tools/pre-execute waterfall)────────────────
1171
+ // 无状态判定:每次拦截从会话事件流现场推导门状态——「提案」= 确认消息之前那一段 AI 输出里
1172
+ // 同时含提案标题与确认段标记;「确认」= 最后一条真实用户消息命中确认词。重启零恢复问题,
1173
+ // 门状态永远由真实事件流现场推导。delegationDepth>0 的子 agent 会话豁免(子 agent 无法交互
1174
+ // 确认;主会话是强制执行点,注入文本约定主 AI 不得借委派绕过门禁)。
1175
+ // ⚠️ waterfall 契约:不拦时必须 next(),否则短路整条链(hooks 桥同款写法,见 PreToolUse 映射)。
1176
+ ctx.on("tools/pre-execute", async (exec, next) => {
1177
+ try {
1178
+ // 启动水合:不水合则重启后 enforce 恒为默认 false ⇒ 方案强制门禁静默失效
1179
+ await hydrateTemplateState();
1180
+ const meta = templateStateCache || TEMPLATE_DEFAULTS;
1181
+ if (meta?.plan?.enforce !== true) return next();
1182
+ const session = exec?.agent?.session;
1183
+ if (!session) return next();
1184
+ if ((session?.header?.delegationDepth ?? 0) > 0) return next();
1185
+ const name = String(exec?.name ?? "");
1186
+ let gated = PLAN_ENFORCE_EDIT_TOOLS.includes(name);
1187
+ if (!gated && name === "bash") {
1188
+ const cmd = exec?.arguments?.command ?? exec?.arguments?.cmd ?? "";
1189
+ gated = typeof cmd === "string" && PLAN_ENFORCE_BASH_WRITE_RE.test(cmd);
1190
+ }
1191
+ if (!gated) return next();
1192
+ if (planGateAllows(session.events)) return next();
1193
+ return { kind: "deny", reason: planEnforceDenyMessage(name) };
1194
+ } catch (error) {
1195
+ // 门禁自身故障 fail-open(不阻断工具链),只留痕
1196
+ log(ctx, `方案门禁判定异常(放行): ${String(error?.message ?? error)}`);
1197
+ return next();
1198
+ }
1199
+ });
1200
+
1201
+ // systemPrompt / tools 可能不在所有组合中,缺失时静默跳过,不影响插件其余功能。
1202
+ ctx.inject(["systemPrompt"], (sctx) => {
1203
+ try {
1204
+ // 修复自 1.0.0 起一直存在的静默失败:原写死 `text: TASK_COMPLETION_CONVENTION`,
1205
+ // 但该标识符在全仓 / 全历史既没有 const 定义,也没有赋值或 import ⇒ 构造 section 参数时
1206
+ // 直接抛 `ReferenceError: TASK_COMPLETION_CONVENTION is not defined`,被下面的 catch
1207
+ // 吞成一行日志 ⇒ 该 section 从未注册、收尾约定从未注入(表现即「指挥家从来没注入过」)。
1208
+ // 现改为同步读取插件自带的权威文本 skills/task-completion-report.md(去 frontmatter;
1209
+ // 读取失败或为空则跳过注册并留痕,不再静默炸)。
1210
+ // 原写法(保留供追溯):text: TASK_COMPLETION_CONVENTION
1211
+ const conventionText = readCompletionConventionSync();
1212
+ if (conventionText) {
1213
+ sctx.get("systemPrompt").section({
1214
+ name: "task-completion-report",
1215
+ order: 1000,
1216
+ text: () => conventionText
1217
+ });
1218
+ } else {
1219
+ log(ctx, "systemPrompt 注入跳过:收尾约定文本为空(skills/task-completion-report.md 缺失或不可读)");
1220
+ }
1221
+ } catch (error) {
1222
+ log(ctx, `systemPrompt 注入失败: ${String(error?.message ?? error)}`);
1223
+ }
1224
+ });
1225
+
1226
+ // 方案/收尾模板改走**上下文注入**(agent/pre-step,每 agent 首次 step 注入一次,
1227
+ // 参考同款形态)——不再塞 systemPrompt section(上下文与系统提示词是两回事,
1228
+ // 方案模板一条、收尾模板一条,各带自己的说明,一开始就注入)。
1229
+ // 模板内容同步读盘 + templateStateCache(templates API 写入后刷新),enabled 才注入对应条。
1230
+ const templateInjectedAgents = new WeakSet();
1231
+ if (typeof ctx?.on === "function") {
1232
+ const tplDshHome = resolveDshHome(ctx, cfg);
1233
+ // 成员模型切换(官方模式二:agent/request 瀑布流拦截):按会话强制改写请求模型。
1234
+ // 不依赖官方「投影 pending + 请求头链」(那条链在新回合会回落部署默认模型),
1235
+ // override 由 set_member_model 写入(domain 持久 + 内存缓存镜像),每轮都改 → 跨轮持久。
1236
+ ctx.on("agent/request", async ({ agent }, next) => {
1237
+ const resolved = await next();
1238
+ const override = await getMemberModelOverride(ctx, agent?.session?.id);
1239
+ return applyModelOverride(resolved, override);
1240
+ });
1241
+ // createUserMessage 来自宿主包:动态导入 + 守卫。
1242
+ // 为什么必须判类型:工作区自测环境没有 @deepseek-ai/* 依赖,直接引用标识符会抛
1243
+ // ReferenceError,被 pre-step 的 try/catch 吞成一行日志 ⇒ 模板注入静默失效
1244
+ // (与历史上 defineTool 的坑同类;实测抓法见 test/api/helpers/fake-services.mjs)。
1245
+ let createUserMessage = null;
1246
+ try {
1247
+ const llmMod = await import("@deepseek-ai/dsh-llm");
1248
+ if (typeof llmMod?.createUserMessage === "function") createUserMessage = llmMod.createUserMessage;
1249
+ } catch { /* 无宿主依赖(工作区自测)→ 注入分支前会跳过 */ }
1250
+
1251
+ ctx.on("agent/pre-step", async ({ agent, signal }, next) => {
1252
+ const decision = await next();
1253
+ if (decision?.kind === "reject" || signal?.aborted) return decision;
1254
+ if (!agent || templateInjectedAgents.has(agent)) return decision;
1255
+ templateInjectedAgents.add(agent);
1256
+ try {
1257
+ if (!tplDshHome) return decision;
1258
+ // 启动水合:不水合则重启后模板状态恒为默认(关)⇒ plan/closing 永不注入
1259
+ await hydrateTemplateState();
1260
+ const metas = templateStateCache ?? TEMPLATE_DEFAULTS;
1261
+ if (typeof createUserMessage !== "function") {
1262
+ log(ctx, "会话模板上下文注入跳过:createUserMessage 不可用(缺 @deepseek-ai/dsh-llm)");
1263
+ return decision;
1264
+ }
1265
+ const injected = [];
1266
+ for (const slot of TEMPLATE_SLOTS) {
1267
+ const text = collectTemplateSlotText(tplDshHome, slot, metas);
1268
+ if (!text) continue;
1269
+ injected.push(createUserMessage({
1270
+ content: [{ type: "text", text: `【dsh-session-conductor 模板注入(设置 → 会话管理 → 模板注入)】\n\n${text}` }],
1271
+ source: { kind: "plugin:dsh-session-conductor", plugin: "dsh-session-conductor", form: "instructions" },
1272
+ }));
1273
+ }
1274
+ if (injected.length === 0) return decision;
1275
+ return { ...decision, messages: [...(decision.messages || []), ...injected] };
1276
+ } catch (error) {
1277
+ log(ctx, `会话模板上下文注入失败: ${String(error?.message ?? error)}`);
1278
+ return decision;
1279
+ }
1280
+ });
1281
+ }
1282
+
1283
+ // defineTool 动态加载:工作区自测环境没有 DSH 依赖,装在 profile 内必可解析(与 dsh-git-push 同法)。
1284
+ // 此前本文件直接使用 defineTool 却**从未导入** ⇒ inject('tools') 回调抛
1285
+ // ReferenceError: defineTool is not defined ⇒ 四个工具全部注册不上。
1286
+ // 实测抓到方式:mini-host + 假服务(test/api/helpers/fake-services.mjs)跑真 handler 时,
1287
+ // warnings 报 `inject(tools) 回调异常: defineTool is not defined`。
1288
+ let defineTool = null;
1289
+ try {
1290
+ const mod = await import('@deepseek-ai/dsh-tools');
1291
+ if (typeof mod?.defineTool === 'function') defineTool = mod.defineTool;
1292
+ } catch { /* 无 DSH 依赖(工作区自测)→ 下面跳过注册,不影响其它接线 */ }
1293
+
1294
+ ctx.inject(["tools"], (tctx) => {
1295
+ const tools = tctx.get("tools");
1296
+ if (!defineTool) return; // 无 DSH 依赖时不注册(避免 ReferenceError 炸掉整个 inject 回调)
1297
+ tools.register(defineTool({
1298
+ name: "list_models",
1299
+ description: "列出当前可用模型(provider → models[])。切模型前先用它拿到确切 model id,不要凭记忆猜模型名。",
1300
+ parameters: {},
1301
+ output: {
1302
+ schema: { type: "string" },
1303
+ render(args, value) {
1304
+ return [{ type: "text", text: String(value) }];
1305
+ }
1306
+ },
1307
+ async execute() {
1308
+ try {
1309
+ const sc = ctx.get("sessionController");
1310
+ if (!sc?.modelCatalog) return "modelCatalog 服务不可用(无法列出模型)";
1311
+ const catalog = await sc.modelCatalog();
1312
+ const groups = catalog?.groups ?? [];
1313
+ if (groups.length === 0) return "模型目录为空(检查 provider 配置)";
1314
+ return groups.map((g) => {
1315
+ const models = (g.models ?? [])
1316
+ .map((m) => ` - ${m.id}${m.name && m.name !== m.id ? `(${m.name})` : ""}`)
1317
+ .join("\n");
1318
+ return `${g.id}${g.name && g.name !== g.id ? `(${g.name})` : ""}:\n${models}`;
1319
+ }).join("\n\n");
1320
+ } catch (error) {
1321
+ return `列出模型失败:${String(error?.message ?? error)}`;
1322
+ }
1323
+ }
1324
+ }));
1325
+ tools.register(defineTool({
1326
+ name: "set_member_model",
1327
+ description: "给指定成员/子代理切换模型(只影响该成员的会话,不动其他成员)。target 传成员名/sessionId/标题关键字;provider 是 list_models 里的分组名(如 agnes / wb),model 是该分组下的模型 id(如 global:deepseek-v4.1-flash)。切换前会校验组合是否存在,避免切到无效组合导致回合失败。",
1328
+ parameters: {
1329
+ target: { type: "string", required: true, description: "目标成员:成员名(如 model-test)、sessionId 或标题关键字" },
1330
+ provider: { type: "string", required: true, description: "provider 分组名(list_models 输出的顶层分组,如 agnes / wb / ai-gateway)" },
1331
+ model: { type: "string", required: true, description: "该 provider 下的模型 id(含前缀,如 global:deepseek-v4.1-flash / agnes-3.0-flash)" }
1332
+ },
1333
+ output: {
1334
+ schema: { type: "string" },
1335
+ render(args, value) {
1336
+ return [{ type: "text", text: String(value) }];
1337
+ }
1338
+ },
1339
+ async execute(args) {
1340
+ const provider = String(args?.provider ?? "").trim();
1341
+ const model = String(args?.model ?? "").trim();
1342
+ if (!provider || !model) return "切换失败:provider 和 model 都必填(先用 list_models 查确切值)";
1343
+ // 先校验组合有效性——无效组合会让后续回合的模型请求被拒(表现为「切换生效但回合失败」)
1344
+ const pair = await validateModelPair(ctx, provider, model);
1345
+ if (pair.error) return `切换失败:${pair.error}`;
1346
+ const found = findTargetAgent(ctx, args?.target);
1347
+ if (found.error) return `切换失败:${found.error}`;
1348
+ const switchResult = await switchAgentModel(ctx, found.sessionId, provider, model, found.agent);
1349
+ if (switchResult.error) return `切换失败:${switchResult.error}`;
1350
+ const memberLabel = found.member?.name ? `${found.member.name}(${found.sessionId})` : found.sessionId;
1351
+ return `已把成员 ${memberLabel}(${found.matched} 匹配)切到 ${provider}/${model};生效方式:${switchResult.via}`;
1352
+ }
1353
+ }));
1354
+ // 上下文用量自查工具:让 AI 自己按需查占用与构成,而不是每轮把数值注入系统提示词
1355
+ // (每轮注入会让系统提示词前缀每轮变化 → prompt cache 失效 → 成本上升)。
1356
+ if (registerContextUsageTool(ctx, defineTool, tools)) {
1357
+ log(ctx, "上下文用量工具已注册 (context_usage)");
1358
+ }
1359
+ log(ctx, "成员模型工具已注册 (list_models / set_member_model)");
1360
+ });
1361
+ }