@shgroup/dsh-serenity-hooks 1.57.2 → 1.58.2

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/lib/index.js CHANGED
@@ -3,7 +3,7 @@ import { c as hostInjected, d as hostSessions, f as hostSettings, h as hostWebSe
3
3
  import { _ as resolveInside, a as findSerenityRoot$1, c as matchBlacklist, d as readCccName$1, f as readCheapWorkerConfig, h as readTrajectorySkills, i as findGitRoot, l as pathInside, m as readSafeModeHiddenTools, n as SAFE_MODE_MARKER, o as isSafeModeOn, p as readExclusiveTools, r as classifyPath, s as loadSerenityConfig, t as DEFAULT_SERENITY_CONFIG_PATHS, u as readBlacklist } from "./ccc-B0ZGL9lx.js";
4
4
  import { A as localIdStamp, C as setActiveSessionInfo, D as isoLocal, E as useSession, O as localFileStamp, S as sessionsRoot, T as summarize, _ as namingTitleFor, a as pruneMissingBindings, b as resolveSessionByTitle, c as setBindingStore, d as clearActiveSessionInfo, f as createSession, g as listSessions, h as getActiveSessionInfo, i as listBoundSessionIds, k as localHuman, l as DEFAULT_SESSION_SCOPE, m as findSession, n as PLUGIN_SOURCE, o as readLastBound, p as extractSessionMdPathFromText, r as appendBound, s as resolveSessionTrajectoryLabel, t as ACC_MESSAGE_KIND, u as TRAJECTORY_ACTIONS, v as parseSessionContextFromEvents, w as showSession, x as sessionEvents$1, y as readActiveSessionMd } from "./message-source-B4P3Uw6D.js";
5
5
  import { a as readWeixinCredential, c as weixinInboundDir, d as LOCALSTORE_SCOPES, f as checkLocalstoreGitCompliance, g as runLocalStore, h as readStore, i as matchWeixinRoute, l as weixinSessionIdFor, m as readGitTrack, n as extractWeixinText, o as readWeixinSettings, p as localstorePath, r as hasVoiceItem, s as sanitizeFileName$1, t as extractWeixinMedia } from "./weixin-route-B9gU1YXz.js";
6
- import { a as GATEWAY_PORT, c as SKIFF_DEBUG_PORT, d as skiffRoleFor$1, f as skiffSessionInfo$1, i as FACE_PORTS, l as WEIXIN_SEND_PORT, m as ACC_VERSION, n as readSimpleSettings, o as MAIN_WEB_PORT, p as unregisterSkiffSession$1, r as registerSettingsSection, s as MECHANISM_PORTS, u as registerSkiffSession$1 } from "./settings-section-DQIVsTqE.js";
6
+ import { a as FACE_PORTS, c as MECHANISM_PORTS, d as registerSkiffSession$1, f as skiffRoleFor$1, h as ACC_VERSION, i as ACP_PORT, l as SKIFF_DEBUG_PORT, m as unregisterSkiffSession$1, n as readSimpleSettings, o as GATEWAY_PORT, p as skiffSessionInfo$1, r as registerSettingsSection, s as MAIN_WEB_PORT, u as WEIXIN_SEND_PORT } from "./settings-section-DGUQZo5j.js";
7
7
  import { a as readSkiffRoles, c as roleMsmWhitelist, d as systemPromptSource, f as trajectorySubset, i as isSkiffSessionId, l as roleToolWhitelist, n as buildSkiffBasePrompt, o as resolveRoleSystemPrompt, r as createRolePromptReader, s as resolveSkiffKind } from "./skiff-role-B615L9MV.js";
8
8
  import { REGISTERED_TOOLS } from "./invariant.js";
9
9
  import { a as markdownToPlainText, c as sendTyping, i as getUpdates, l as sniffImageExt, n as downloadMedia, o as sendFileMessage, r as getConfig, s as sendTextMessage, t as TypingStatus } from "./weixin-api-BrRcPiQs.js";
@@ -13,7 +13,7 @@ import { defineTool } from "@deepseek-ai/dsh-tools";
13
13
  import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
14
14
  import { appendFileSync, chmodSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, utimesSync, writeFileSync } from "node:fs";
15
15
  import { execFile, execFileSync, spawn, spawnSync } from "node:child_process";
16
- import { homedir, platform } from "node:os";
16
+ import { homedir, hostname, platform } from "node:os";
17
17
  import { promisify } from "node:util";
18
18
  import { platform as platform$1 } from "node:process";
19
19
  import { fileURLToPath } from "node:url";
@@ -9971,10 +9971,20 @@ function listWakes(root, opts = {}) {
9971
9971
  */
9972
9972
  /** 入口文件名(**全写**,所有者令 2026-09-19;不改缩写) */
9973
9973
  const CRO_FILENAME = "continuous-re-occurrence.ts";
9974
+ /**
9975
+ * 机器绑定文件名(**全写**,与 `CRO_FILENAME` 同风格)。
9976
+ *
9977
+ * 🔴 **它随 CCC git 走是设计意图,不是缺陷**:
9978
+ * 绑定声明必须跟着**轨迹**走,才能在第二台机器上被读到、并被判为"不是我的"。
9979
+ * (这正是机器绑定的整个目的 —— 见 `resolveCroHostBinding` 的头注。)
9980
+ */
9981
+ const CRO_HOST_FILENAME = "cro-host-binding";
9974
9982
  /** 硬超时(沿用 `biasProvider` 先例的 60s;设计 §5 情形 2) */
9975
9983
  const CRO_TIMEOUT_MS = 6e4;
9976
9984
  /** stdout 上限(防一个坏程序把日志撑爆;沿用 hook 截断思路) */
9977
9985
  const CRO_STDOUT_MAX = 65536;
9986
+ /** 绑定文件体积上限(它只是个机器名 ⇒ 超过这个数一定是写错了,不读) */
9987
+ const CRO_HOST_FILE_MAX = 1024;
9978
9988
  /** SESSION.md 文件名(与 `trajectory-ops.ts` 同值;此处本地常量避免反向依赖) */
9979
9989
  const SESSION_MD = "SESSION.md";
9980
9990
  /**
@@ -10109,6 +10119,135 @@ function croScriptPath(root, dirName) {
10109
10119
  return resolveInside(root, join("AGENT_SESSIONS", dirName, CRO_FILENAME));
10110
10120
  }
10111
10121
  /**
10122
+ * 解析机器绑定 —— 「**这条轨迹由哪台机器负责自唤醒**」(所有者令 2026-10-15)。
10123
+ *
10124
+ * ## 所有者裁定的语义(逐字,D135)
10125
+ *
10126
+ * 「**CRO 必须指定绑定机器,否则不运行,唤醒器 检查这个**」⇒ 三条同时成立:
10127
+ * ① **只许这一台**(白名单,不是"排除这一台");
10128
+ * ② **fail-closed** —— **没绑 = 不跑**(不是"没绑 = 随便跑");
10129
+ * ③ **检查者在唤醒器**(本函数由 `runCroPhase` 调用,不是程序自己判)。
10130
+ *
10131
+ * ## 绑定键 = 机器名(所有者 2026-10-15 裁「用 hostname 吧,比较合理」)
10132
+ *
10133
+ * ⚠️ **已接受的取舍**:本机机器名是 QEMU 机型派生的默认形
10134
+ * (`yh-Standard-PC-Q35-ICH9-2009`)⇒ 同法新建的同名用户 VM 会取到同一个名字。
10135
+ * 所有者 2026-10-15 明确「**不改名,家里就这几台机器没必要**」⇒ **风险由所有者接受**。
10136
+ *
10137
+ * ## 🔴 为什么必须是三态,「没绑」不能并进「绑给别人」
10138
+ *
10139
+ * 2026-10-15 已发生过**同族事故**:`send-now` 失败时报的是「无绑定会话记录」,
10140
+ * 而真因是"那条轨迹不可寻址"—— **文案相同、原因不同**,害得排查方向错了一次
10141
+ * (S142 §日志 续 129 / D134)。
10142
+ * ⇒ 绑定这一层**不许再造同样的坑**:「你没绑」与「你绑给了别人」是**两件不同的事**,
10143
+ * 日志必须分开写 —— 否则将来"为什么这条轨迹不跑了"会又一次指错方向。
10144
+ *
10145
+ * ## 文件形态(刻意的极简)
10146
+ *
10147
+ * · 位置 = `<轨迹目录>/cro-host-binding`(与 CRO 程序**同级**)
10148
+ * · 内容 = **一行机器名**(首尾空白裁掉;只取第一行有效内容)
10149
+ * · 不用 JSON —— 它只有一个值,JSON 只会多一种写错的方式
10150
+ * · 支持 `#` 注释行与空行(方便人写、方便留痕)
10151
+ *
10152
+ * 🔴 **它必须随 git 入库**(不是"运行态"):绑定声明要**跟着轨迹走到第二台机器**,
10153
+ * 在那里被判成「绑给了别人」而**正确地不跑** —— 那就是本机制在工作。
10154
+ * ⇒ ⛔ **不要把它加进 `.gitignore`**(那会让第二台机器读到"没绑"而同样不跑,
10155
+ * 表面上结果一样,但**判据从"明确绑给别人"退化成"什么都没说"**,失去可诊断性)。
10156
+ *
10157
+ * ## 为什么读文件而不是读快照字段
10158
+ *
10159
+ * 快照(`CroSnapshot`)是喂给**程序**的;而本判定发生在**程序被 spawn 之前**
10160
+ * —— 程序还没跑,没有快照可读。⇒ 只能读文件。(这也是"机制属 ACC"的体现:
10161
+ * ACC 在调用用户程序前先判定自己该不该调用。)
10162
+ *
10163
+ * @param root CCC 根
10164
+ * @param dirName 轨迹目录名
10165
+ * @param selfHost 本机机器名(**注入** ⇒ 可测;生产调用方传 `hostname()`)
10166
+ * @returns 判定结果(**永不抛** —— 旁路容忍铁律)
10167
+ */
10168
+ function resolveCroHostBinding(root, dirName, selfHost) {
10169
+ const self = String(selfHost ?? "").trim();
10170
+ let fileAbs;
10171
+ try {
10172
+ fileAbs = resolveInside(root, join("AGENT_SESSIONS", dirName, CRO_HOST_FILENAME));
10173
+ } catch (err) {
10174
+ return {
10175
+ allowed: false,
10176
+ bound: null,
10177
+ self,
10178
+ code: "read-error",
10179
+ detail: `绑定文件路径非法(已忽略):${String(err?.message ?? err)}`
10180
+ };
10181
+ }
10182
+ let raw;
10183
+ try {
10184
+ if (!existsSync(fileAbs)) return {
10185
+ allowed: false,
10186
+ bound: null,
10187
+ self,
10188
+ code: "not-bound",
10189
+ detail: `未绑定机器(缺 ${CRO_HOST_FILENAME})⇒ 按"必须指定"不运行`
10190
+ };
10191
+ const st = statSync(fileAbs);
10192
+ if (st.size > CRO_HOST_FILE_MAX) return {
10193
+ allowed: false,
10194
+ bound: null,
10195
+ self,
10196
+ code: "read-error",
10197
+ detail: `绑定文件过大(${st.size} B > ${CRO_HOST_FILE_MAX} B)⇒ 判为写错,不运行`
10198
+ };
10199
+ raw = readFileSync(fileAbs, "utf-8");
10200
+ } catch (err) {
10201
+ return {
10202
+ allowed: false,
10203
+ bound: null,
10204
+ self,
10205
+ code: "read-error",
10206
+ detail: `绑定文件读取失败 ⇒ 不运行:${String(err?.message ?? err)}`
10207
+ };
10208
+ }
10209
+ const bound = parseCroHostBinding(raw);
10210
+ if (bound === null) return {
10211
+ allowed: false,
10212
+ bound: null,
10213
+ self,
10214
+ code: "not-bound",
10215
+ detail: `绑定文件为空(只有注释/空行)⇒ 按"必须指定"不运行`
10216
+ };
10217
+ if (bound === self) return {
10218
+ allowed: true,
10219
+ bound,
10220
+ self,
10221
+ code: "bound-to-self",
10222
+ detail: `已绑定本机(${bound})`
10223
+ };
10224
+ return {
10225
+ allowed: false,
10226
+ bound,
10227
+ self,
10228
+ code: "bound-to-other",
10229
+ detail: `绑定给别的机器(绑定=${bound},本机=${self})⇒ 本机不运行`
10230
+ };
10231
+ }
10232
+ /**
10233
+ * 解析绑定文件正文 ⇒ 机器名(取不到 ⇒ `null`)。
10234
+ *
10235
+ * 规则(刻意宽松:**只认一行,其余都当注释**):
10236
+ * · 逐行扫;跳过空行与 `#` 开头的行;
10237
+ * · **第一条有效行** = 机器名(裁首尾空白);
10238
+ * · 无有效行 ⇒ `null`。
10239
+ * @param raw 文件正文
10240
+ * @returns 机器名;无有效内容 ⇒ `null`
10241
+ */
10242
+ function parseCroHostBinding(raw) {
10243
+ for (const line of String(raw ?? "").split(/\r?\n/)) {
10244
+ const t = line.trim();
10245
+ if (t === "" || t.startsWith("#")) continue;
10246
+ return t;
10247
+ }
10248
+ return null;
10249
+ }
10250
+ /**
10112
10251
  * 列出**启用了 CRO 的轨迹目录名**(供调度器每 tick 扫描,设计 §6.1)。
10113
10252
  *
10114
10253
  * ## 判据与形态(R↓)
@@ -11134,7 +11273,10 @@ async function acquireWakeAgent(ctx, root, dirName) {
11134
11273
  return { error: `冷会话载入失败(${id}): ${String(err?.message ?? err)}` };
11135
11274
  }
11136
11275
  }
11137
- return { error: `无绑定会话记录(AGENT_SESSIONS/.bindings.json 中无 ${dirName})——目标轨迹需先被 container_trajectory use 激活过` };
11276
+ return {
11277
+ error: `无绑定会话记录(AGENT_SESSIONS/.bindings.json 中无 ${dirName})——目标轨迹需先被 container_trajectory use 激活过`,
11278
+ noBinding: true
11279
+ };
11138
11280
  }
11139
11281
  /** 唤醒消息(唤起即投递正文:身份锚定 + 唤醒信息 + 任务) */
11140
11282
  function buildWakeText(entry, target) {
@@ -11256,11 +11398,21 @@ async function sendToTrajectory(ctx, root, target, message, sender) {
11256
11398
  ok: false,
11257
11399
  detail: `目标 trajectory 未命中(${target})`
11258
11400
  };
11259
- const acquired = await acquireWakeAgent(ctx, root, t.dirName);
11260
- if ("error" in acquired) return {
11261
- ok: false,
11262
- detail: acquired.error
11263
- };
11401
+ let acquired = await acquireWakeAgent(ctx, root, t.dirName);
11402
+ let createdNote = "";
11403
+ if ("error" in acquired) {
11404
+ if (!acquired.notReady && acquired.noBinding === true) {
11405
+ acquired = await createAndBindTarget(ctx, root, t.dirName);
11406
+ if ("error" in acquired) return {
11407
+ ok: false,
11408
+ detail: acquired.error
11409
+ };
11410
+ createdNote = `;目标原本无活跃 dsh 会话 ⇒ **已自动创建并绑定**一条(${acquired.how})`;
11411
+ } else return {
11412
+ ok: false,
11413
+ detail: acquired.error
11414
+ };
11415
+ }
11264
11416
  const agent = acquired.agent;
11265
11417
  const make = () => createUserMessage({
11266
11418
  content: [{
@@ -11280,14 +11432,14 @@ async function sendToTrajectory(ctx, root, target, message, sender) {
11280
11432
  }
11281
11433
  return {
11282
11434
  ok: true,
11283
- detail: `已即时投递 ${t.dirName}(${acquired.how};目标空闲 ⇒ followup 立即起轮)`
11435
+ detail: `已即时投递 ${t.dirName}(${acquired.how};目标空闲 ⇒ followup 立即起轮)${createdNote}`
11284
11436
  };
11285
11437
  }
11286
11438
  try {
11287
11439
  agent.steer(make());
11288
11440
  return {
11289
11441
  ok: true,
11290
- detail: `已即时注入 ${t.dirName}(${acquired.how};目标在跑 ⇒ steer 当场注入当前轮)`
11442
+ detail: `已即时注入 ${t.dirName}(${acquired.how};目标在跑 ⇒ steer 当场注入当前轮)${createdNote}`
11291
11443
  };
11292
11444
  } catch (err) {
11293
11445
  try {
@@ -11300,11 +11452,72 @@ async function sendToTrajectory(ctx, root, target, message, sender) {
11300
11452
  }
11301
11453
  return {
11302
11454
  ok: true,
11303
- detail: `已投递 ${t.dirName}(${acquired.how};steer 不可用 ⇒ 退回 followup): ${String(err?.message ?? err)}`
11455
+ detail: `已投递 ${t.dirName}(${acquired.how};steer 不可用 ⇒ 退回 followup): ${String(err?.message ?? err)}${createdNote}`
11304
11456
  };
11305
11457
  }
11306
11458
  }
11307
11459
  /**
11460
+ * 🆕 **给一条「从未被激活过」的轨迹就地建一条 dsh 会话并绑定它**(2026-10-16 owner 令,S142)。
11461
+ *
11462
+ * 何时用:**只有** `send-now` 在 `acquireWakeAgent` 报「无绑定会话记录」时(见该处的判据说明)。
11463
+ *
11464
+ * 三步(顺序不可换):
11465
+ * ① `sessionController.create({ cwd: root })` —— 宿主原语,**idempotently adopt**
11466
+ * ⇒ 重复调用不会建出第二条(不必自造"先查再建"的竞态窗口)。
11467
+ * ② `resolveAgent(sessionId)` —— 拿到**真 agent**。`create` 只返回 id,不给 agent。
11468
+ * ③ `appendBound(...)` —— 落绑定。**必须由本函数写**:`listBoundSessionIds` 是
11469
+ * `acquireWakeAgent` 的唯一选人依据,不写绑定则下一条消息**还是**找不到它
11470
+ * (那就成了"建了但永远用不上",比不建更坏)。
11471
+ *
11472
+ * 🔴 **为什么用 `cwd: root` 而不是目标的目录**:会话的 cwd 决定它落在哪个 CCC
11473
+ * (`cccRootForCwd` 上溯 `.serenity`)。目标轨迹本就住在该 CCC 的 `AGENT_SESSIONS/` 下
11474
+ * ⇒ 传 CCC 根才让它**进同一个 CCC**;传轨迹目录会让它变成一个**不同 CCC**的会话。
11475
+ *
11476
+ * @returns 命中返回 agent 与取得方式;任一步失败返回**带原因**的 error(不静默、不误报成功)
11477
+ */
11478
+ async function createAndBindTarget(ctx, root, dirName) {
11479
+ const sc = hostService(ctx, "sessionController");
11480
+ const create = sc?.create;
11481
+ if (typeof create !== "function") return {
11482
+ error: "目标无活跃 dsh 会话,且 sessionController.create 不可用(绑定 id: 无)——无法就地创建载体;请先对该轨迹执行一次 container_trajectory use",
11483
+ notReady: true
11484
+ };
11485
+ let sessionId = "";
11486
+ try {
11487
+ const out = await create.call(sc, { cwd: root });
11488
+ sessionId = String(out?.sessionId ?? "");
11489
+ } catch (err) {
11490
+ return { error: `就地创建会话失败(${dirName}): ${String(err?.message ?? err)}` };
11491
+ }
11492
+ if (!sessionId) return { error: `就地创建会话未返回 sessionId(${dirName})` };
11493
+ const resolved = sc?.resolveAgent;
11494
+ if (typeof resolved !== "function") return {
11495
+ error: `会话已建(${sessionId})但 sessionController.resolveAgent 不可用`,
11496
+ notReady: true
11497
+ };
11498
+ let agent;
11499
+ try {
11500
+ const out = await resolved.call(sc, sessionId);
11501
+ agent = out?.agent;
11502
+ if (!agent) return { error: `会话已建(${sessionId})但 resolveAgent 未返回 agent: ${String(out?.error ?? "未知原因")}` };
11503
+ } catch (err) {
11504
+ return { error: `会话已建(${sessionId})但载入失败: ${String(err?.message ?? err)}` };
11505
+ }
11506
+ const bound = appendBound({ header: {
11507
+ id: sessionId,
11508
+ cwd: root
11509
+ } }, "activate", {
11510
+ dirName,
11511
+ mdPath: join(root, "AGENT_SESSIONS", dirName, "SESSION.md"),
11512
+ note: "auto-created by send-now"
11513
+ });
11514
+ const how = `auto-create(${sessionId}${bound ? ", bound" : ", ⚠️ 绑定写入失败"})`;
11515
+ return {
11516
+ agent,
11517
+ how
11518
+ };
11519
+ }
11520
+ /**
11308
11521
  * 本 tick 要扫的 CCC 根集合(**并集**)。
11309
11522
  *
11310
11523
  * C2(S142 2026-09-15 Q5):原实现是 `collectLiveCccs`——**只认 live 会话** ⇒
@@ -11441,10 +11654,18 @@ async function runCroPhase(ctx, root, registry, log) {
11441
11654
  lastSkipReason: s.lastSkipReason
11442
11655
  };
11443
11656
  const agents = hostAgents(ctx);
11657
+ const selfHost = safeHostname();
11658
+ let hostSkips = 0;
11444
11659
  let wakes = 0;
11445
11660
  let quiet = 0;
11446
11661
  let skips = 0;
11447
11662
  for (const dirName of dirs) try {
11663
+ const verdict = resolveCroHostBinding(root, dirName, selfHost);
11664
+ if (!verdict.allowed) {
11665
+ hostSkips += 1;
11666
+ log.push(`· ${root}: ${dirName}: CRO 机器绑定未通过(${verdict.code}):${verdict.detail}`);
11667
+ continue;
11668
+ }
11448
11669
  const bound = listBoundSessionIds(root, dirName);
11449
11670
  const running = runningCarriersOf(bound, nowMs);
11450
11671
  const outcome = await evaluateCro(root, dirName, readCroSnapshotInput(root, dirName, nowMs, {
@@ -11481,7 +11702,22 @@ async function runCroPhase(ctx, root, registry, log) {
11481
11702
  skips += 1;
11482
11703
  log.push(`· ${root}: ${dirName}: CRO 内部异常(已忽略,不影响既有链路): ${String(err?.message ?? err)}`);
11483
11704
  }
11484
- log.push(`· ${root}: CRO 评估 ${dirs.length} 条(唤起 ${wakes},不唤起 ${quiet},跳过 ${skips})`);
11705
+ log.push(`· ${root}: CRO 评估 ${dirs.length} 条(唤起 ${wakes},不唤起 ${quiet},跳过 ${skips}${hostSkips > 0 ? `,非本机绑定 ${hostSkips}` : ""})`);
11706
+ }
11707
+ /**
11708
+ * 取本机机器名(**永不抛**)。
11709
+ *
11710
+ * `os.hostname()` 理论上可能抛(极少数环境)⇒ 这里兜底成空串。
11711
+ * ⚠️ **空串的后果是"全部不通过"**(`bound === self` 永不成立)⇒ 即 **fail-closed**,
11712
+ * 与所有者裁定同向(宁可都不跑,也不会"名字取不到就随便跑")。
11713
+ * @returns 本机机器名;取不到 ⇒ `''`
11714
+ */
11715
+ function safeHostname() {
11716
+ try {
11717
+ return hostname();
11718
+ } catch {
11719
+ return "";
11720
+ }
11485
11721
  }
11486
11722
  /** 一次调度 tick:遍历已知 CCC → 到期项串行投递 / 超窗项置 missed;返回人读摘要 */
11487
11723
  async function runWakeTick(ctx) {
@@ -11641,6 +11877,36 @@ const CRO_GUIDE = `CRO(Continuous Re-Occurrence · 持续再发生)编写指
11641
11877
  (tick 日志里会记一行跳过理由 —— 这是为了让你事后能分辨
11642
11878
  「**总闸关着没跑**」和「**跑了、程序自己判了不叫**」这两件完全不同的事)。
11643
11879
 
11880
+ ## 2b. 🔴 机器绑定(**必须指定,否则不运行** —— 所有者令 2026-10-15)
11881
+
11882
+ 同目录再放一个文件:
11883
+
11884
+ <CCC 根>/AGENT_SESSIONS/<轨迹目录>/cro-host-binding
11885
+
11886
+ 内容 = **一行机器名**(就是本机 \`hostname\`)。也支持 \`#\` 注释行,方便留痕:
11887
+
11888
+ # 这条轨迹的自唤醒只在 CCC 宿主机上跑
11889
+ yh-Standard-PC-Q35-ICH9-2009
11890
+
11891
+ **语义(三条,缺一不可)**:
11892
+ · **只许这一台** —— 文件名里写的那台;别处**不跑**;
11893
+ · 🔴 **fail-closed** —— **没写这个文件 ⇒ 不跑**(不是"没写就随便跑");
11894
+ · **检查者是唤醒器**(ACC),**不是**你的程序 —— 你的程序**根本不会被调用**。
11895
+
11896
+ **为什么要有它**:CRO 程序本身**随 git 走**(它在你轨迹目录里,会跟着 CCC 到第二台机器)。
11897
+ 没有这道门,**同一段程序会在两台机器各跑一次**(同一轨迹被叫两次)。
11898
+ 有了它,第二台机器读到"绑给了别人"就**正确地不跑**。
11899
+
11900
+ ⚠️ **两条与它有关的纪律**:
11901
+ 1. 🔴 **不要把这个文件加进 \`.gitignore\`** —— 它**必须**跟着仓走。
11902
+ 否则第二台机器读到的是"没绑",结果**碰巧一样**(也不跑),
11903
+ 但你的判据从「**明确绑给了别人**」退化成「**什么都没说**」⇒ 排查时看不出真因。
11904
+ 2. 🔴 **tick 日志会区分两种跳过**:
11905
+ 「\`not-bound\` 你没绑」与「\`bound-to-other\` 你绑给了别人」是**两件事**,文案不同。
11906
+ (2026-10-15 有过一次教训:两种不同的原因报同一句话,害排查指错方向。)
11907
+
11908
+ **改绑 / 解除**:直接改这个文件(或删掉 ⇒ 变回"没绑 ⇒ 不跑")。**没有额外命令**。
11909
+
11644
11910
  ## 3. 我给你什么(输入)
11645
11911
  ACC 把这条轨迹的状态做成一个 **JSON 快照**,从 **stdin** 喂给你的程序。
11646
11912
  字段分四组:identity(身份)/ time(时间)/ body(轨迹身体)/ binding + scheduling(绑定与调度)。
@@ -11891,13 +12157,56 @@ function renderText$3(value) {
11891
12157
  function createTrajectoryTool(ctx) {
11892
12158
  return defineTool({
11893
12159
  name: "container_trajectory",
11894
- description: "Trajectory (AGENT_SESSIONS/ — the persistent body of a trajectory; the dsh conversation is only its rebuildable carrier). Lifecycle + delivery: list (inventory with stats and anomaly marks) / show (read one SESSION.md) / create / use (activate for this conversation, with inline integrity check) / rebuild (clear-and-rebuild the current conversation in place, Ship of Theseus) / send-now (deliver ONE message to a trajectory RIGHT NOW — live: injected immediately; not live: cold-resumed, i.e. equivalent to a direct wake; returns a synchronous receipt) / send-later (schedule ONE message to any trajectory at a future instant — fire-and-forget: no receipt, no recall, no panel) / cro-guide (the CRO programming guide: how to give THIS trajectory its own program that decides when it should be woken). create requires --desc <desc> [--goal] or --issue <ticket> (exactly one) plus --summary (≤20 chars). use requires --summary (≤20 chars) and may pass --force to switch away from the currently-bound trajectory. rebuild requires --summary (next-phase summary ≤20 chars) + optional --note (task focus for the rebuilt self). send-later requires target (S### or AGENT_SESSIONS dir name) + at (RFC3339 or +30m/+2h) + message. send-now requires target + message. It delivers IMMEDIATELY (never queued behind the current turn): a busy target is steered mid-turn so it reads the message in the CURRENT turn, and an idle target starts a turn right away. Its receipt means the message was injected into the target (it does NOT mean the target ran or replied). The summary is appended to the dsh session title (S###-YYYY-MM-DD-<summary>); the S### id and date stay server-derived.",
12160
+ description: [
12161
+ "container_trajectory — one trajectory: its persistent body (SESSION.md) + its timeline.",
12162
+ "A trajectory is NOT the conversation: SESSION.md is the durable body and never moves;",
12163
+ "the dsh conversation is only its rebuildable carrier (Ship of Theseus — the carrier is replaced, the trajectory continues).",
12164
+ "ACTIONS, grouped by EFFECT (read the grouping before picking one):",
12165
+ "(A) LIFECYCLE — changes which trajectory THIS conversation is bound to:",
12166
+ "list (inventory + stats + anomaly marks) / show (read one SESSION.md) / create (make a NEW trajectory) /",
12167
+ "use (bind THIS conversation to an existing trajectory; inline integrity check — silent when fine, problems reported as hints, never errors) /",
12168
+ "rebuild (clear-and-rebuild THIS conversation in place).",
12169
+ "(B) DELIVERY — sends ONE message to ANOTHER trajectory; your own binding is unchanged:",
12170
+ "send-now (deliver RIGHT NOW) / send-later (deliver at ONE future instant).",
12171
+ "(C) REFERENCE — cro-guide (how to give THIS trajectory its own program that decides when it should be woken).",
12172
+ "HOW TO CHOOSE (a rule, not a guess):",
12173
+ "not yet bound + starting new work ⇒ create — with no prior binding it activates immediately, so the new trajectory has a carrier at once.",
12174
+ "already bound + recording a separate future line ⇒ create — it does NOT steal your binding (you stay on the current trajectory);",
12175
+ "activate the new one when you want it by sending it a message.",
12176
+ "want THIS conversation to move to another trajectory ⇒ use (refuses when it would orphan the current binding; --force overrides).",
12177
+ "want to poke another trajectory ⇒ send-now. Its target needs NO prior setup:",
12178
+ "if the target has no live dsh session, one is created and bound automatically, and your message is injected into it.",
12179
+ "RECEIPTS (read no more than is written): send-now returns a synchronous receipt meaning exactly \"injected / turn started\" —",
12180
+ "NEVER \"the target ran or replied\"; to prove the target actually moved, read its SESSION.md.",
12181
+ "It never queues behind the current turn: a busy target is steered mid-turn (read in the CURRENT turn), an idle target starts a turn at once.",
12182
+ "send-later is fire-and-forget by design: no receipt, no recall, no panel.",
12183
+ "ASYMMETRY (deliberate — the two differ in whether a missing target is normal): send-now creates a target on demand,",
12184
+ "because sending to a never-used trajectory is a legitimate intent; send-later does NOT,",
12185
+ "because scheduling for a trajectory that has never existed is almost always a mistyped name — auto-creating would hide that error behind an empty trajectory.",
12186
+ "PARAMETERS: create requires --desc <desc> [--goal] or --issue <ticket> (exactly one) plus --summary (≤20 chars).",
12187
+ "use requires --summary (≤20 chars) and may pass --force to switch away from the currently-bound trajectory.",
12188
+ "rebuild requires --summary (next-phase summary ≤20 chars) + optional --note (task focus for the rebuilt self).",
12189
+ "send-later requires target (S### or AGENT_SESSIONS dir name) + at (RFC3339 or +30m/+2h) + message.",
12190
+ "send-now requires target + message.",
12191
+ "The summary is appended to the dsh session title (S###-YYYY-MM-DD-<summary>); the S### id and date stay server-derived."
12192
+ ].join(" "),
11895
12193
  parameters: {
11896
12194
  action: {
11897
12195
  type: "string",
11898
12196
  enum: [...TRAJECTORY_ACTIONS],
11899
12197
  required: true,
11900
- description: "Subcommand: list (inventory + stats + anomaly marks) / show (S### or dir name or fuzzy keyword) / create (--desc or --issue) / use (activate context; inline integrity check — pass silent, problems reported as hints, not errors) / rebuild (clear-and-rebuild current conversation — requires --summary + optional --note) / send-now (deliver one message RIGHT NOW: live target injected immediately, non-live cold-resumed like a direct wake; returns a synchronous receipt) / send-later (one future instant + one message, delivered to target trajectory) / cro-guide (no parameters: prints the CRO programming guide, including a ready-to-use sample snapshot)"
12198
+ description: [
12199
+ "Subcommand — grouped by effect.",
12200
+ "LIFECYCLE (changes THIS conversation's binding):",
12201
+ "list (inventory + stats + anomaly marks) / show (S### or dir name or fuzzy keyword) /",
12202
+ "create (new trajectory; activates itself when you have no binding, otherwise leaves your binding untouched and tells you how to activate it) /",
12203
+ "use (activate context for THIS conversation; inline integrity check — pass silent, problems reported as hints, not errors) /",
12204
+ "rebuild (clear-and-rebuild current conversation — requires --summary + optional --note).",
12205
+ "DELIVERY (sends one message to ANOTHER trajectory; your binding is unchanged):",
12206
+ "send-now (deliver RIGHT NOW; the target needs no prior setup — if it has no live session, one is created and bound automatically) /",
12207
+ "send-later (one future instant + one message; fire-and-forget, and it does NOT auto-create a target).",
12208
+ "REFERENCE: cro-guide (no parameters: prints the CRO programming guide, including a ready-to-use sample snapshot)"
12209
+ ].join(" ")
11901
12210
  },
11902
12211
  name: {
11903
12212
  type: "string",
@@ -11909,15 +12218,15 @@ function createTrajectoryTool(ctx) {
11909
12218
  },
11910
12219
  desc: {
11911
12220
  type: "string",
11912
- description: "create short description (any language, ≤5 words; mutually exclusive with issue)"
12221
+ description: "create: short description (any language, ≤5 words; mutually exclusive with issue)"
11913
12222
  },
11914
12223
  issue: {
11915
12224
  type: "string",
11916
- description: "create ticket number (e.g. apaas-26116; dir named YYYY-MM-DD--<issue>; mutually exclusive with desc)"
12225
+ description: "create: ticket number (e.g. apaas-26116; dir named YYYY-MM-DD--<issue>; mutually exclusive with desc)"
11917
12226
  },
11918
12227
  goal: {
11919
12228
  type: "string",
11920
- description: "create one-sentence goal (optional)"
12229
+ description: "create: one-sentence goal (optional)"
11921
12230
  },
11922
12231
  summary: {
11923
12232
  type: "string",
@@ -11929,7 +12238,7 @@ function createTrajectoryTool(ctx) {
11929
12238
  },
11930
12239
  dryRun: {
11931
12240
  type: "boolean",
11932
- description: "create preview mode (no actual changes)"
12241
+ description: "create: preview mode (no actual changes)"
11933
12242
  },
11934
12243
  target: {
11935
12244
  type: "string",
@@ -11968,13 +12277,32 @@ function createTrajectoryTool(ctx) {
11968
12277
  dryRun: isDryRun
11969
12278
  });
11970
12279
  const message = result.message;
11971
- if (!isDryRun) appendBound(agentDshSession(exec), "create", {
11972
- dirName: result.dirName,
11973
- mdPath: join(result.sessionPath, "SESSION.md"),
11974
- sessionId: result.sessionId,
11975
- note: "created (binding unchanged until explicit use)"
11976
- });
11977
- return message;
12280
+ if (isDryRun) return message;
12281
+ const scope = agentScope$1(exec);
12282
+ const priorBound = currentBoundDirName(exec, scope);
12283
+ if (priorBound === null) {
12284
+ const active = useSession(root, result.dirName, scope);
12285
+ const info = getActiveSessionInfo(scope);
12286
+ if (info) renameDshSessionForActive(ctx, exec, info, args.summary);
12287
+ const dsh = agentDshSession(exec);
12288
+ if (dsh && info) appendBound(dsh, "activate", {
12289
+ dirName: info.dirName,
12290
+ mdPath: info.mdPath,
12291
+ sessionId: info.sessionId,
12292
+ note: "auto-activated on create (no prior binding)"
12293
+ });
12294
+ return [
12295
+ message,
12296
+ `✓ 已自动激活(此前**无绑定**):${active.dir} —— 后续工作写回该轨迹的 SESSION.md。`,
12297
+ ` (判据:create 前无任何绑定 ⇒ 激活;有绑定则只建不夺,见下。)`
12298
+ ].join("\n");
12299
+ }
12300
+ return [
12301
+ message,
12302
+ `⚠ 本轨迹【未】被激活 —— 当前已绑定 ${priorBound},保绑定优先(不夺走你正在用的轨迹)。`,
12303
+ ` 要激活它(并在它名下开一条会话):用 container_trajectory send-now 向 ${result.dirName} 投一条消息 ——`,
12304
+ ` 目标若没有活跃 dsh 会话,会自动创建一条并把「使用提示词 + 你的消息」一并注入。`
12305
+ ].join("\n");
11978
12306
  }
11979
12307
  case "use": {
11980
12308
  if (!args.name) throw new Error("use requires name (S### or directory name)");
@@ -21488,6 +21816,300 @@ function registerWeixinSendApi(ctx) {
21488
21816
  sync();
21489
21817
  }
21490
21818
  //#endregion
21819
+ //#region src/acp-face.ts
21820
+ /** 面名(面宿主幂等键;与 FACE_PORTS 的 id 同名,便于对账) */
21821
+ const FACE_ACP = "acp";
21822
+ /** 本面绑定的地址(取自集中端口表 —— **单一真相源**,不在本文件硬写) */
21823
+ const ACP_HOST = FACE_PORTS.acp.host ?? "127.0.0.1";
21824
+ /**
21825
+ * `dsh-acp` 需要的服务(**替它声明**,见文件头坑 ①)。
21826
+ *
21827
+ * 逐字出处 = `dsh-acp/lib/index.js` 的 `const inject = ["agents","llm","sessionPersistence","sessions"]`。
21828
+ * ⚠️ **少一个就在它内部抛错**(实测那条是 `sessionPersistence`)⇒ 这里是**照抄它的声明**,
21829
+ * 不是"我们觉得它需要什么"。⇒ 它升版改了 `inject`,这里要跟着改(列为**跨版本注意点**)。
21830
+ */
21831
+ const ACP_REQUIRED_SERVICES = [
21832
+ "agents",
21833
+ "llm",
21834
+ "sessionPersistence",
21835
+ "sessions"
21836
+ ];
21837
+ function acpFaceActive() {
21838
+ return faceActive(FACE_ACP);
21839
+ }
21840
+ /**
21841
+ * 面是否在监听(意图读取)。
21842
+ * ⚠️ 与 `startFace` 的关系同其余各面:**启动本身不读它**(`startFace` 是机械入口),
21843
+ * 由装配层用它决定"起还是停"。
21844
+ */
21845
+ function acpFaceEnabled() {
21846
+ return readSimpleSettings().acpFaceEnabled;
21847
+ }
21848
+ /**
21849
+ * 把一条裸 WS socket 适配成 ACP `Stream`。
21850
+ *
21851
+ * 🔴 与官方 `ndJsonStream` **同语义**:我们自己做「消息 ↔ 文本帧」的分帧(按行)。
21852
+ * 实测注意(P0′):`dsh-acp` 写出的每条消息**自带换行**,客户端同样 ⇒ 按行切分即正确。
21853
+ * ⚠️ **空行要丢**;**非 JSON 帧要容忍**(桥/客户端可能插入控制帧)。
21854
+ */
21855
+ function wsToAcpStream(ws, onWarn) {
21856
+ return {
21857
+ readable: new ReadableStream({ start(controller) {
21858
+ let buf = "";
21859
+ ws.on("message", (data) => {
21860
+ buf += typeof data === "string" ? data : String(data);
21861
+ let i;
21862
+ while ((i = buf.indexOf("\n")) >= 0) {
21863
+ const line = buf.slice(0, i);
21864
+ buf = buf.slice(i + 1);
21865
+ if (!line.trim()) continue;
21866
+ try {
21867
+ controller.enqueue(JSON.parse(line));
21868
+ } catch {
21869
+ onWarn(line.slice(0, 120));
21870
+ }
21871
+ }
21872
+ });
21873
+ ws.on("close", () => {
21874
+ try {
21875
+ controller.close();
21876
+ } catch {}
21877
+ });
21878
+ ws.on("error", () => {
21879
+ try {
21880
+ controller.close();
21881
+ } catch {}
21882
+ });
21883
+ } }),
21884
+ writable: new WritableStream({ write(msg) {
21885
+ ws.send(`${JSON.stringify(msg)}\n`);
21886
+ } })
21887
+ };
21888
+ }
21889
+ function newState() {
21890
+ return {
21891
+ handle: null,
21892
+ wss: null,
21893
+ connections: 0,
21894
+ applied: 0,
21895
+ lastErrors: [],
21896
+ disposers: []
21897
+ };
21898
+ }
21899
+ /** 当前面状态(供 `status()` / 诊断读;无监听时 `handle` 为 null) */
21900
+ let state = newState();
21901
+ /** 保留最近若干条连接错误(诊断窗口;不无限增长) */
21902
+ const MAX_ERR_KEEP = 8;
21903
+ /**
21904
+ * 启动面 E(幂等;重复调用只起一次)。
21905
+ *
21906
+ * 🔴 **失败语义**:本面**默认关**且是**可选面** ⇒ 起不来**不得**影响插件其余部分
21907
+ * (调用方负责 catch;本函数把失败原样抛出,由装配层记日志)。
21908
+ */
21909
+ async function startAcpFace(ctx) {
21910
+ const port = readSimpleSettings().acpFacePort;
21911
+ let dshAcp;
21912
+ let wsMod;
21913
+ try {
21914
+ dshAcp = await import("@deepseek-ai/dsh-acp");
21915
+ wsMod = await import("ws");
21916
+ } catch (err) {
21917
+ const msg = String(err?.message ?? err);
21918
+ throw new Error(`面 E 依赖不可用(${msg})—— 需要宿主提供 @deepseek-ai/dsh-acp,且本插件能 resolve 到 ws`);
21919
+ }
21920
+ if (typeof dshAcp.apply !== "function") throw new Error("面 E:@deepseek-ai/dsh-acp 未导出 apply(版本不匹配?)");
21921
+ const wss = new wsMod.WebSocketServer({ noServer: true });
21922
+ const local = newState();
21923
+ local.wss = wss;
21924
+ const handle = await startFace({
21925
+ name: FACE_ACP,
21926
+ port,
21927
+ host: ACP_HOST,
21928
+ enabled: () => readSimpleSettings().acpFaceEnabled,
21929
+ handler: (_req, res) => {
21930
+ res.writeHead(426, {
21931
+ "content-type": "text/plain; charset=utf-8",
21932
+ "upgrade": "websocket",
21933
+ "cache-control": "no-store"
21934
+ });
21935
+ res.end("This is an ACP (Agent Client Protocol) endpoint over WebSocket. Use an ACP client.\n");
21936
+ }
21937
+ });
21938
+ local.handle = handle;
21939
+ handle.server.on("upgrade", (req, socket, head) => {
21940
+ local.connections += 1;
21941
+ try {
21942
+ wss.handleUpgrade(req, socket, head, (ws) => {
21943
+ try {
21944
+ const stream = wsToAcpStream(ws, (line) => {
21945
+ console.log(`[serenity-hooks] 面 E:丢弃一条非 JSON 帧(前 120 字): ${line}`);
21946
+ });
21947
+ const fiber = ctx.plugin({
21948
+ inject: ACP_REQUIRED_SERVICES,
21949
+ apply: (scoped) => {
21950
+ const sel = resolveAcpSelection(scoped);
21951
+ dshAcp.apply(scoped, {
21952
+ stream,
21953
+ provider: sel.provider,
21954
+ model: sel.model
21955
+ });
21956
+ }
21957
+ });
21958
+ local.disposers.push(() => {
21959
+ try {
21960
+ fiber?.dispose?.();
21961
+ } catch {}
21962
+ });
21963
+ local.applied += 1;
21964
+ } catch (err) {
21965
+ const msg = String(err?.message ?? err).slice(0, 200);
21966
+ local.lastErrors.push({
21967
+ at: Date.now(),
21968
+ applied: false,
21969
+ error: msg
21970
+ });
21971
+ if (local.lastErrors.length > MAX_ERR_KEEP) local.lastErrors.shift();
21972
+ console.warn(`[serenity-hooks] ✗ 面 E:dsh-acp 接线失败: ${msg}`);
21973
+ try {
21974
+ ws.close(1011, "acp bridge failed");
21975
+ } catch {}
21976
+ }
21977
+ });
21978
+ } catch (err) {
21979
+ console.warn(`[serenity-hooks] ✗ 面 E:WebSocket 握手失败: ${String(err?.message ?? err)}`);
21980
+ try {
21981
+ socket.destroy();
21982
+ } catch {}
21983
+ }
21984
+ });
21985
+ state = local;
21986
+ console.log(`[serenity-hooks] ✓ 面 E(官方 ACP):ws://${ACP_HOST}:${port} ⚠️ 协议无认证,靠外部 Access 兜底`);
21987
+ return handle;
21988
+ }
21989
+ /**
21990
+ * 停面 E(幂等)。
21991
+ *
21992
+ * ⚠️ **与其余各面同款注意**:`stopFace` **不用** `closeAllConnections()`(那会掐断在飞的请求);
21993
+ * WS 连接由 `wss.close()` 与 socket 自身生命周期收束。
21994
+ */
21995
+ function stopAcpFace() {
21996
+ const wss = state.wss;
21997
+ if (wss) try {
21998
+ wss.close();
21999
+ } catch {}
22000
+ state.disposers = [];
22001
+ state.wss = null;
22002
+ state.handle = null;
22003
+ const stopped = stopFace(FACE_ACP);
22004
+ if (stopped) console.log("[serenity-hooks] face acp: 已停止");
22005
+ return stopped;
22006
+ }
22007
+ /** ACP 会话用的 provider(取 CCC 现有路由;留空 = 由宿主/请求监听器决定) */
22008
+ function readAcpProvider() {
22009
+ return process.env.SERENITY_ACP_PROVIDER || void 0;
22010
+ }
22011
+ /** ACP 会话用的 model(同上) */
22012
+ function readAcpModel() {
22013
+ return process.env.SERENITY_ACP_MODEL || void 0;
22014
+ }
22015
+ /**
22016
+ * 解析 ACP 会话该用的 `{provider, model}`。
22017
+ *
22018
+ * ## 🔴 为什么必须有这个函数(2026-10-16 容器实测抓出的**真缺陷**)
22019
+ *
22020
+ * 早先我们是**裸传** `provider: readAcpProvider(), model: readAcpModel()` —— 而它们读的是
22021
+ * **环境变量**,默认**未设** ⇒ 传下去就是 `undefined`。后果**不是"用默认模型"**:
22022
+ * 上游 `dsh-acp` 的 `agentOptions()`(`lib/index.js:1356-1361`)**只在字段非 undefined 时才带上**:
22023
+ * ```js
22024
+ * ...config.provider !== void 0 ? { provider: config.provider } : {},
22025
+ * ...config.model !== void 0 ? { model: config.model } : {}
22026
+ * ```
22027
+ * ⇒ **我们传 undefined,agent 就真的没有模型** ⇒ 第一条 `session/prompt` 必失败,逐字报:
22028
+ * `Internal error: turn failed: prompt variable "{{model}}" has no value for this assembly
22029
+ * (section "deployment:persona-prefix")`。
22030
+ *
22031
+ * ⚠️ **网页端不中招**(它经 `agent-default-model` 层拿到默认值)⇒ **只有 ACP 这条入口坏** ——
22032
+ * 也就是说:**手机上第一条消息必然失败,而本机怎么试都正常**。
22033
+ * 🔴 这个缺陷 **typecheck / 2812 条单测 / deploy 预检 全绿**(它是运行时合成形态,
22034
+ * 静态面结构性地看不见)⇒ **只有"容器 + 真模型"那一跑能抓到它**。
22035
+ *
22036
+ * ## 修法 = **读宿主自己的默认模型服务**(不另造真相源)
22037
+ *
22038
+ * 宿主暴露服务 `ctx.agentDefaultModel.currentSelection()`,其契约逐字写着
22039
+ * *"Default model selection for Agents created **without an explicit model**"*
22040
+ * (`dsh-agent-default-model/lib/types/index.d.ts`)—— 这正是"没显式指定时该用什么"的**权威出处**。
22041
+ * ⇒ 我们只是**把它转交给 ACP**,不引入第二套缺省。
22042
+ *
22043
+ * 优先级(**显式 > 宿主默认**):`SERENITY_ACP_PROVIDER/MODEL` 环境变量(调试用)> 宿主默认。
22044
+ * ⚠️ **一处如实标注的弱化**:这里**不打日志**(每次连接都解析一次,打日志会刷屏);
22045
+ * 若宿主服务缺失,**退回"两个都 undefined"=旧行为**(即那条已知会失败的形态)——
22046
+ * 而不是静默编一个模型。⇒ 副作用是"没有默认模型时仍会失败",但那**与宿主自身缺配置时的行为一致**。
22047
+ *
22048
+ * 🔵 **公开导出**(而非模块私有):它是一个**纯函数 + 有独立契约**的单元(本仓同类先例 =
22049
+ * `host/access.ts` 的一组读取器),且**那个真缺陷正是在它这一层**(裸传 undefined)——
22050
+ * 回归钉必须能**直接钉住它**,而不是隔着 WS 握手去间接观察(那样测不出"取到了什么")。
22051
+ */
22052
+ function resolveAcpSelection(ctx) {
22053
+ const envProvider = readAcpProvider();
22054
+ const envModel = readAcpModel();
22055
+ if (envProvider && envModel) return {
22056
+ provider: envProvider,
22057
+ model: envModel
22058
+ };
22059
+ try {
22060
+ const sel = hostInjected(ctx, "agentDefaultModel")?.currentSelection?.();
22061
+ if (sel?.provider && sel?.model) return {
22062
+ provider: sel.provider,
22063
+ model: sel.model
22064
+ };
22065
+ } catch {}
22066
+ return {
22067
+ provider: envProvider,
22068
+ model: envModel
22069
+ };
22070
+ }
22071
+ /**
22072
+ * 装配(`index.ts` 的 `apply` 调用):按**面板/部署键**启停面 E,配置变化热同步。
22073
+ *
22074
+ * 🔴 **W3 接线**(2026-10-16):本轮之前 `startAcpFace` 虽已实现却**没有任何调用点**
22075
+ * —— 即"面写好了但从不起"。本函数补上它,形式与两个兄弟面一致
22076
+ * (`registerWeixinSendApi` / `registerDiagramTool`):
22077
+ *
22078
+ * ① **反应式**:面板拨开关**只改配置、不重跑 `apply`**(v1.51.1 实测过的那条铁律)
22079
+ * ⇒ 必须监听事件才会"拨了就有反应";
22080
+ * ② 监听**宿主的真事件** `settings/document-updated`(按 ns 过滤),
22081
+ * ⛔ **不监听** `serenity/settings-changed` —— 那个事件**全仓没有任何 `emit` 点**
22082
+ * (=死通道;见 `registerDiagramTool` 的注释),照它接线等于接了个永远不响的铃;
22083
+ * ③ 再挂 `agent/created`/`session/created` 兜底(宿主事件面若换名,至少还有这条能自愈);
22084
+ * ④ 启动时同步一次(`apply` 时按当前配置定初始态)。
22085
+ *
22086
+ * ⚠️ **失败语义**:本面**默认关**且**依赖可选**(`dsh-acp` 是 peer)⇒ 起不来**只记日志**,
22087
+ * 绝不抛出(抛出会让整个插件加载失败,而它只是一个可选面)。
22088
+ */
22089
+ function registerAcpFace(ctx) {
22090
+ const sync = () => {
22091
+ if (!acpFaceEnabled()) {
22092
+ if (acpFaceActive()) stopAcpFace();
22093
+ return;
22094
+ }
22095
+ if (acpFaceActive()) return;
22096
+ startAcpFace(ctx).catch((err) => {
22097
+ console.error(`[serenity-hooks] ✗ 面 E(官方 ACP)启动失败: ${String(err?.message ?? err)}`);
22098
+ });
22099
+ };
22100
+ try {
22101
+ ctx.on("settings/document-updated", (ns) => {
22102
+ if (String(ns) === "serenity-hooks") sync();
22103
+ });
22104
+ } catch {}
22105
+ for (const eventName of ["agent/created", "session/created"]) try {
22106
+ ctx.on(eventName, () => {
22107
+ sync();
22108
+ });
22109
+ } catch {}
22110
+ sync();
22111
+ }
22112
+ //#endregion
21491
22113
  //#region src/seams/lifecycle.ts
21492
22114
  /** 从 Agent / Session / 事件负载中取会话 id(形状宽容:未知结构返回 null) */
21493
22115
  function sessionIdOf(value) {
@@ -24124,6 +24746,10 @@ const Config = z.object({
24124
24746
  opencodeProvider: z.object({ autoConfigure: z.boolean().default(true) }),
24125
24747
  visionPatch: z.object({ enabled: z.boolean().default(true) }),
24126
24748
  diagram: z.object({ enabled: z.boolean().default(false) }),
24749
+ acpFace: z.object({
24750
+ enabled: z.boolean().default(false),
24751
+ port: z.number().min(1024).max(65535).default(ACP_PORT)
24752
+ }),
24127
24753
  humanChannel: z.object({
24128
24754
  accounts: z.array(humanChannelAccountSchema).default([]),
24129
24755
  subscriptions: z.array(humanChannelSubscriptionSchema).default([]),
@@ -24136,7 +24762,9 @@ const Config = z.object({
24136
24762
  skiffDebugPort: z.number().min(1024).max(65535).volatile(),
24137
24763
  croEnabled: z.boolean().volatile(),
24138
24764
  unattendedEnabled: z.boolean().volatile(),
24139
- diagramEnabled: z.boolean().volatile()
24765
+ diagramEnabled: z.boolean().volatile(),
24766
+ acpFaceEnabled: z.boolean().volatile(),
24767
+ acpFacePort: z.number().min(1024).max(65535).volatile()
24140
24768
  });
24141
24769
  function apply(ctx, config) {
24142
24770
  try {
@@ -24200,6 +24828,7 @@ function apply(ctx, config) {
24200
24828
  registerWakeScheduler(ctx);
24201
24829
  registerWeixinBridge(ctx);
24202
24830
  registerWeixinSendApi(ctx);
24831
+ registerAcpFace(ctx);
24203
24832
  registerWeixinOutputGuard(ctx);
24204
24833
  registerLifecycle(ctx);
24205
24834
  if (config.webFetch?.enabled !== false) registerWebFetchProvider(ctx);