@shgroup/dsh-serenity-hooks 1.85.0 → 1.87.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1,14 +1,14 @@
1
1
  import { i as __toESM, n as __exportAll } from "./rolldown-runtime-B4iAMlE-.js";
2
- import { c as hostInjected, d as hostSessions, f as hostSettings, h as hostWebServer, i as cccRootForExec, l as hostLlm, m as hostWeb, n as agentCwdFor, o as listCccs, p as hostSubagents, r as cccRootForCwd, s as hostAgents, t as NO_CCC_FROM_AGENT_CWD, u as hostService } from "./ccc-roots-VCJ3aYJA.js";
2
+ import { c as hostInjected, d as hostSessions, f as hostSettings, h as hostWebServer, i as cccRootForExec, l as hostLlm, m as hostWeb, n as agentCwdFor, o as listCccs, p as hostSubagents, r as cccRootForCwd, s as hostAgents, t as NO_CCC_FROM_AGENT_CWD, u as hostService } from "./ccc-roots-BI_4UVcj.js";
3
3
  import { _ as resolveInside, a as findSerenityRoot$1, c as matchBlacklist, d as readCccName$1, f as readCheapWorkerConfig, h as readSafeModeHiddenTools, i as findGitRoot, l as pathInside, m as readExclusiveTools, n as SAFE_MODE_MARKER, o as isSafeModeOn, r as classifyPath, s as loadSerenityConfig, t as DEFAULT_SERENITY_CONFIG_PATHS, u as readBlacklist } from "./ccc-CyYJL4F2.js";
4
- import { A as sessionEvents, B as isoLocal, C as listSessions, E as parseTrajectoryCode, F as trajectoryDescOf, H as localHuman, I as useSession, L as TRAJECTORY_FILENAME, M as setActiveSessionInfo, N as showSession, O as readTrajectoryUid, P as summarize, R as TRAJECTORY_LEGACY_FILENAMES, S as listActiveTrajectories, T as parseSessionContextFromEvents, U as localIdStamp, V as localFileStamp, _ as createSession, a as readTrajectoryConstraintAt, b as getActiveSessionInfo, c as trajectoryConstraintForSession, f as COGNITIVE_TRAJECTORY_ACTIONS, g as clearActiveSessionInfo, h as archiveSession, j as sessionsRoot, k as resolveSessionByTitle, m as UID_FILENAME, n as isTrajectoryConstrained, o as readTrajectoryConstraintByCode, r as listTrajectoryConstraints, s as replacesDefaultIdentity, t as TRAJECTORY_CONSTRAINT_FILENAME, v as extractSessionMdPathFromText, w as namingTitleFor, y as findSession, z as readTrajectoryFile } from "./profile-DnLSb7yv.js";
4
+ import { A as resolveSessionByTitle, B as readTrajectoryFile, C as listActiveTrajectories, D as parseTrajectoryCode, E as parseSessionContextFromEvents, F as summarize, H as localFileStamp, I as trajectoryDescOf, L as useSession, M as sessionsRoot, N as setActiveSessionInfo, P as showSession, R as TRAJECTORY_FILENAME, T as namingTitleFor, U as localHuman, V as isoLocal, W as localIdStamp, _ as clearActiveSessionInfo, b as findSession, c as replacesDefaultIdentity, g as archiveSession, h as UID_FILENAME, i as listTrajectoryConstraints, j as sessionEvents, k as readTrajectoryUid, l as trajectoryConstraintForSession, n as findDirNameByCode, o as readTrajectoryConstraintAt, p as COGNITIVE_TRAJECTORY_ACTIONS, r as isTrajectoryConstrained, s as readTrajectoryConstraintByCode, t as TRAJECTORY_CONSTRAINT_FILENAME, v as createSession, w as listSessions, x as getActiveSessionInfo, y as extractSessionMdPathFromText, z as TRAJECTORY_LEGACY_FILENAMES } from "./profile-vHheCJiU.js";
5
5
  import { n as checkLocalstoreGitCompliance, o as readStore, s as runLocalStore, t as LOCALSTORE_SCOPES } from "./service-DDmHSglV.js";
6
- import { _ as resolveConstraintSystemPrompt, a as listBoundSessionIds, d as constraintCognitiveTrajectory, f as constraintSystemPromptSource, g as profileWriteWhitelist, h as profileToolWhitelist, i as carrierState, l as TRAJECTORY_CONSTRAINT_HINT, n as PLUGIN_SOURCE, o as readLastBound, r as appendBound, s as resolveSessionCognitiveTrajectoryLabel, t as ACC_MESSAGE_KIND, v as ACC_VERSION, y as findPackageRoot } from "./message-source-D7dVNDIh.js";
7
- import { A as buildResourceIndex, C as verifyPassword, D as recordToolUsage, E as verifyTotpCode, F as hasTrajectoryPrompt, M as isSafeSkillName, N as truncateContent, O as REFERENCE_DIR, P as TRAJECTORY_PROMPT_FILENAME, S as toWire, T as MECHANISM_PORTS, _ as registerEntrySkillSectionGlobal, a as trajectoryConstraintFor, b as projectKnownWorkspaces, c as trajectoryWorkspaceMdPath, d as CHEAP_WORKER_GUIDE, f as providerAvailabilityError, g as registerEntrySkillSection, h as registerCognitiveTrajectorySkillSection, i as getTrajectoryAgent, j as buildResourceNameSuffix, k as SKILL_ONTOLOGY_FILE, l as unregisterTrajectorySession, m as splitModel, n as createTrajectoryAgent, o as trajectoryConstraintMsmGate, p as requireWhitelistedModel, r as ensureWorkspacePromptSection, s as trajectoryConstraintParticipates, t as askTrajectory, u as workspaceCognitiveTrajectoryLine, v as applyWirePatch, w as FACE_PORTS, x as readAdvancedSettings, y as migrateLegacyLocalstore } from "./session-I6SM2GT6.js";
6
+ import { _ as resolveConstraintSystemPrompt, a as listBoundSessionIds, d as constraintCognitiveTrajectory, f as constraintSystemPromptSource, g as profileWriteWhitelist, h as profileToolWhitelist, i as carrierState, l as TRAJECTORY_CONSTRAINT_HINT, n as PLUGIN_SOURCE, o as readLastBound, r as appendBound, s as resolveSessionCognitiveTrajectoryLabel, t as ACC_MESSAGE_KIND, v as ACC_VERSION, y as findPackageRoot } from "./message-source-DRkQwfgh.js";
7
+ import { A as buildResourceIndex, C as verifyPassword, D as recordToolUsage, E as verifyTotpCode, F as hasTrajectoryPrompt, M as isSafeSkillName, N as truncateContent, O as REFERENCE_DIR, P as TRAJECTORY_PROMPT_FILENAME, S as toWire, T as MECHANISM_PORTS, _ as registerEntrySkillSectionGlobal, a as trajectoryConstraintFor, b as projectKnownWorkspaces, c as trajectoryWorkspaceMdPath, d as CHEAP_WORKER_GUIDE, f as providerAvailabilityError, g as registerEntrySkillSection, h as registerCognitiveTrajectorySkillSection, i as getTrajectoryAgent, j as buildResourceNameSuffix, k as SKILL_ONTOLOGY_FILE, l as unregisterTrajectorySession, m as splitModel, n as createTrajectoryAgent, o as trajectoryConstraintMsmGate, p as requireWhitelistedModel, r as ensureWorkspacePromptSection, s as trajectoryConstraintParticipates, t as askTrajectory, u as workspaceCognitiveTrajectoryLine, v as applyWirePatch, w as FACE_PORTS, x as readAdvancedSettings, y as migrateLegacyLocalstore } from "./session-DZXSLLEx.js";
8
8
  import { n as hasSessionLogById, r as sessionsRootDir } from "./cleanup-C-92qnDs.js";
9
- import { n as readSimpleSettings, r as registerSettingsSection } from "./settings-4ag1jtR0.js";
9
+ import { n as readSimpleSettings, r as registerSettingsSection } from "./settings-H7TVDayZ.js";
10
+ import { a as readFeishuCredential, c as readWeixinSettings, d as weixinInboundDir, f as weixinSessionIdForTrajectory, i as matchWeixinRoute, n as extractWeixinText, o as readImSettings, r as hasVoiceItem, s as readWeixinCredential, t as extractWeixinMedia, u as sanitizeFileName$1 } from "./route-3OzOAjec.js";
10
11
  import { REGISTERED_TOOLS } from "./invariant.js";
11
- import { a as readFeishuCredential, d as weixinSessionIdForTrajectory, i as matchWeixinRoute, l as sanitizeFileName$1, n as extractWeixinText, o as readWeixinCredential, r as hasVoiceItem, s as readWeixinSettings, t as extractWeixinMedia, u as weixinInboundDir } from "./route-Dr0SFD8A.js";
12
12
  import { c as sendTextMessage, i as getUpdates, l as sendTyping, n as downloadMedia, o as markdownToPlainText, r as getConfig, s as sendFileMessage, t as TypingStatus, u as sniffImageExt } from "./ilink-BMk2w2KY.js";
13
13
  import { a as registerImAccountImpl, i as normalizeAccountChannel, n as getImAccountImpl, r as imAccountChannels } from "./accounts-BHQbakUh.js";
14
14
  import { createRequire } from "node:module";
@@ -11048,6 +11048,152 @@ const TRAJECTORY_SELF_GUIDE = [
11048
11048
  ⚠️ 以上四项都是**读取当前真实状态**,不是"保存成功"的回执。保存成功 ≠ 生效(本仓反复踩过)。`
11049
11049
  ].join("\n");
11050
11050
  //#endregion
11051
+ //#region src/tools/im-docs.ts
11052
+ /**
11053
+ * tools/im-docs.ts — 「IM 桥怎么用、怎么配路由」的**手册正文**(2026-10-22)
11054
+ *
11055
+ * ## 为什么需要这一章(owner 原话)
11056
+ *
11057
+ * 「我在测试飞书,发现 ACC 没提供 IM 桥使用说明和账户查询方法,**导致 CCC 没办法自己配路由**,
11058
+ * 这个要完善下」+「配路由需要的是**账户到轨迹**,轨迹 CCC 知道,**账户 CCC 不知道**,
11059
+ * 需要有个能力让 CCC 查询用户配好的账户」。
11060
+ *
11061
+ * ⇒ 两件事被同时指出来:**没有说明面**(只能猜配置形状)+ **没有查询面**(拿不到对方 id)。
11062
+ * 后者已由 `im-bridge action=contacts` 兑现;本章补前者。
11063
+ *
11064
+ * ## 🔴 写这一章时守的两条纪律
11065
+ *
11066
+ * 1. **只描述已经建成的东西**(D179):本章出现的每个字段、每个动作,都必须在实现里存在。
11067
+ * 写"打算做"的功能进手册 = 教用户按图施工然后把自己锁死。
11068
+ * 2. **指针从读者真正打开的那个工具发出**(D178):读这一章的人是 **CCC 的 agent**,
11069
+ * 它手边常用的入口是 `im-bridge`(工具描述已指向本章)。⛔ 不能只藏在 container_admin 里。
11070
+ */
11071
+ const IM_BRIDGE_GUIDE = `# IM 桥(im-bridge)—— 怎么用、怎么配、怎么查
11072
+
11073
+ ## 一、它是什么
11074
+
11075
+ 一个 CCC 通过 IM 渠道(微信 / 飞书)跟家人收发消息的能力。
11076
+ **只作用于本容器**——没有"目标容器"参数(跨 CCC 发送不在能力面)。
11077
+
11078
+ 工具在**未配置任何 IM 渠道时根本不可见**(不是"看得到但被拒")。
11079
+ ⇒ 你看到这个工具,说明本 CCC 已配了至少一个渠道。
11080
+
11081
+ ## 二、五个动作
11082
+
11083
+ | 动作 | 用途 | 关键参数 |
11084
+ |---|---|---|
11085
+ | \`send\` | 发一条文本 | \`user\`(对方)+ \`text\` |
11086
+ | \`send-file\` | 发本容器内的一个文件 | \`user\` + \`file\`(**CCC 根相对路径**)+ 可选 \`caption\` |
11087
+ | \`users\` | 已配的**别名**表 | — |
11088
+ | \`status\` | 渠道健康 + **账号** + **当前路由**(含每条路由的目标是否存在) | — |
11089
+ | \`contacts\` | **跟本容器有过往来的人**(含对方 id)+ 未配路由的 / 配了却没往来的 | — |
11090
+
11091
+ ⚠️ 渠道不一定支持全部动作(例如飞书目前不支持 \`send-file\`,会如实返回 \`ACTION_UNSUPPORTED\`)。
11092
+ **如实返回"不支持"比假装支持好**——后者会得到一条"发出去但对方收不到"的静默故障。
11093
+
11094
+ ## 三、🔴 配路由:先 \`contacts\`,再写,再 \`status\` 自检
11095
+
11096
+ 路由的形状(\`.opencode/serenity.json\` 的 \`im.routes\`):
11097
+
11098
+ \`\`\`json
11099
+ {
11100
+ "im": {
11101
+ "enabled": true,
11102
+ "accounts": [
11103
+ { "accountId": "wechat-1", "channel": "weixin", "enabled": true },
11104
+ { "accountId": "feishu-1", "channel": "feishu", "enabled": true }
11105
+ ],
11106
+ "routes": [
11107
+ { "user": "o9cq…@im.wechat", "trajectory": "zhaocai-xingzai" },
11108
+ { "user": "ou_xxxxxxxx", "trajectory": "llm-intel" }
11109
+ ]
11110
+ }
11111
+ }
11112
+ \`\`\`
11113
+
11114
+ - \`user\` = **对方在该渠道内的 id**(不是我们的账号,也不是别名)
11115
+ · 微信:形如 \`xxx@im.wechat\`
11116
+ · 飞书:形如 \`ou_…\`(open_id)
11117
+ - \`trajectory\` = **认知轨迹编码**(目录名里那一段,如 \`S142\` / \`llm-intel\`)
11118
+ - \`*\` 可作为 \`user\` 的**通配兜底**(exact 优先)
11119
+
11120
+ ### 那个 \`user\` id 从哪里来 —— 这是本节存在的原因
11121
+
11122
+ **你不知道对方 id,而它也不在任何配置文件里。** 它只出现在聊天记录中。
11123
+ ⇒ 用 \`im-bridge({channel:"weixin", action:"contacts"})\`,它会给出:
11124
+
11125
+ \`\`\`json
11126
+ {
11127
+ "contacts": [
11128
+ { "channel": "weixin", "userId": "o9cq…@im.wechat", "accountIds": ["wechat-1"],
11129
+ "route": null, "firstSeen": "2026-09-04T…", "lastSeen": "2026-10-22T…", "messages": 1284 }
11130
+ ],
11131
+ "unrouted": ["o9cq…@im.wechat"],
11132
+ "orphanRoutes": ["ou_typo_never_seen"],
11133
+ "sources": ["AGENT_SESSIONS/_im-logs/weixin/2026-10-22.jsonl", "…"]
11134
+ }
11135
+ \`\`\`
11136
+
11137
+ 三行输出**各有用途,缺一行这个动作就只剩"列名字"**:
11138
+
11139
+ | 字段 | 含义 | 你要做什么 |
11140
+ |---|---|---|
11141
+ | \`contacts[].userId\` | 对方 id | **复制它**填进 \`routes[].user\` |
11142
+ | \`unrouted\` | ⚠️ 有往来、但**没配路由** | 这些人的消息现在**无处可去** ⇒ 这就是该配的 |
11143
+ | \`orphanRoutes\` | ❓ 配了路由、但记录里**从没出现过** | 大概率 **id 写错**(或从未有人发过) |
11144
+ | \`sources\` | 实际读过的文件 | **否定性结论的可信度上限** = 这个列表;它空着就别下"没人"的结论 |
11145
+
11146
+ ### 写完怎么自检
11147
+
11148
+ \`im-bridge({channel:"weixin", action:"status"})\` 的 \`routes[]\` 每条都带 **\`targetExists\`**:
11149
+
11150
+ - \`true\` ⇒ 该轨迹目录真实存在,路由**有机会**生效
11151
+ - \`false\` ⇒ **轨迹编码写错了**(或那条轨迹已被归档/删除)
11152
+
11153
+ 🔴 这一格是"配了不生效"的**唯一机械信号**:写错一个编码**不会有任何报错**,
11154
+ 消息进来找不到目标就静默不投递,你只会看到"没回音"。
11155
+
11156
+ ## 四、多账号 / 多渠道
11157
+
11158
+ 一个 CCC 绑多个账号是常态(owner 2026-10-22:「一个 CCC 绑定多个账户很常见」)。
11159
+
11160
+ - **账号**在 \`im.accounts[]\`,每条自带 \`channel\`(缺省 \`weixin\`)
11161
+ - **路由是通道无关的**(\`user → trajectory\`);同一轨迹可以被多个渠道的对端指向
11162
+ - \`contacts\` 的每条带 \`accountIds\`(该对端被哪些账号收过)——**多账号靠它区分**
11163
+ - 发消息时可用 \`account\` 参数指定用哪个账号(缺省:第一个启用且已绑定的)
11164
+
11165
+ ## 五、聊天记录落在哪
11166
+
11167
+ \`\`\`
11168
+ AGENT_SESSIONS/_im-logs/<channel>/<YYYY-MM-DD>.jsonl
11169
+ \`\`\`
11170
+
11171
+ - 按**渠道**分目录(\`weixin\` / \`feishu\`),按**本地自然天**分文件,逐行 JSON 追加
11172
+ - 老路径 \`AGENT_SESSIONS/_weixin-logs/\` 是**同一族的前代**(2026-10-22 之前的微信记录);
11173
+ 读侧**两个都认**,⛔ 但**只往新路径写**
11174
+ - 每条事件带 \`channel\` 字段 ⇒ 飞书事件不会再混进微信目录(此前会,目录名会说谎)
11175
+ - 媒体的持久副本在 \`_im-logs/<channel>/media/<日期>/\`
11176
+
11177
+ ⚠️ **记录归 CCC**(\`im.hook\` 指向本容器的脚本),ACC 不认识存储格式 —— 这层分离是有意的。
11178
+
11179
+ ## 六、出错时
11180
+
11181
+ 工具返回**稳定错误码 + 可行动提示**,常见的:
11182
+
11183
+ | 码 | 意思 | 怎么改 |
11184
+ |---|---|---|
11185
+ | \`CHANNEL_NOT_CONFIGURED\` | 本 CCC 没启用该渠道 | 看提示里的**那一半**(总开关 vs 缺账号,两者改法不同) |
11186
+ | \`ALIAS_UNKNOWN\` | \`user\` 既不是已配别名、也不是合法的裸 id | 先 \`action=users\` 看别名,或用 \`contacts\` 拿裸 id |
11187
+ | \`ACTION_UNSUPPORTED\` | 该渠道没实现这个动作 | 换动作;⛔ 不要重试 |
11188
+ | \`FILE_ESCAPE\` | \`file\` 跑出了 CCC 根 | 用容器内相对路径 |
11189
+
11190
+ ## 七、边界(明确不做)
11191
+
11192
+ - ⛔ 跨 CCC 发送(只作用于本容器)
11193
+ - ⛔ 修改配置(\`contacts\` 只读;加路由要人/CCC 自己写 \`serenity.json\`)
11194
+ - ⛔ 判断"对方读没读"——记录只证明**已发出**
11195
+ `;
11196
+ //#endregion
11051
11197
  //#region src/tools/container-admin.ts
11052
11198
  /**
11053
11199
  * container-admin.ts — container_admin(v1.30:机务舱——容器管理面统一入口)
@@ -11198,6 +11344,11 @@ const DOCS_CHAPTERS = [
11198
11344
  id: "trajectory-constraint",
11199
11345
  summary: "轨迹约束(只讲限制面):trajectory.json 的双白名单 / 模型 / 纪律参与项 / IM 路由",
11200
11346
  render: () => TRAJECTORY_CONSTRAINT_GUIDE
11347
+ },
11348
+ {
11349
+ id: "im-bridge",
11350
+ summary: "IM 桥怎么用、怎么配路由、怎么查对方 id —— send/send-file/users/status/contacts 五个动作 + 路由三步法(contacts → 写 → status 自检)",
11351
+ render: () => IM_BRIDGE_GUIDE
11201
11352
  }
11202
11353
  ];
11203
11354
  /**
@@ -11208,7 +11359,7 @@ const DOCS_CHAPTERS = [
11208
11359
  function createContainerAdminTool(ctx) {
11209
11360
  return defineTool({
11210
11361
  name: "container_admin",
11211
- description: "Container administration (the ship's maintenance bay — one entry for managing the CCC): config — CCC configuration: docs (usage-manual index) / docs <chapter> (one chapter in full) / view (current values) + 轨迹约束 (guide/validate/apply/list); msm — MSM registry management (register/deregister/check quality DC-M1~M4) + manuals (guide dev manual / catalog ACC capability directory / ccc-config CCC configuration reference). TRAJECTORY SELF-DOCS: a cognitive trajectory describing ITSELF — charter (PROMPT.md) / resident declaration / self-restriction (whitelists) ⇒ config docs trajectory-self; the restriction side only ⇒ config docs trajectory-constraint. Execution of registered MSMs belongs to the msm tool; this tool manages the container.",
11362
+ description: "Container administration (the ship's maintenance bay — one entry for managing the CCC): config — CCC configuration: docs (usage-manual index) / docs <chapter> (one chapter in full) / view (current values) + 轨迹约束 (guide/validate/apply/list); msm — MSM registry management (register/deregister/check quality DC-M1~M4) + manuals (guide dev manual / catalog ACC capability directory / ccc-config CCC configuration reference). TRAJECTORY SELF-DOCS: a cognitive trajectory describing ITSELF — charter (PROMPT.md) / resident declaration / self-restriction (whitelists) ⇒ config docs trajectory-self; the restriction side only ⇒ config docs trajectory-constraint. IM BRIDGE: how to send / configure routes / look up the other party's channel user id — send / send-file / users / status / contacts + the three-step route recipe ⇒ config docs im-bridge. Execution of registered MSMs belongs to the msm tool; this tool manages the container.",
11212
11363
  parameters: {
11213
11364
  domain: {
11214
11365
  type: "string",
@@ -11321,6 +11472,138 @@ function createContainerAdminTool(ctx) {
11321
11472
  });
11322
11473
  }
11323
11474
  //#endregion
11475
+ //#region src/im/log-scan.ts
11476
+ /**
11477
+ * im/log-scan.ts — 读聊天记录落盘,汇总出「与谁有过往来」(`contacts` 的数据源)
11478
+ *
11479
+ * ## 这个文件解决什么
11480
+ *
11481
+ * owner 2026-10-22:「配路由需要的是**账户到轨迹**,轨迹 CCC 知道,**账户 CCC 不知道**,
11482
+ * 需要有个能力让 CCC 查询用户配好的账户」。这里的"账户"= **通道用户**(对方是谁),
11483
+ * 不是 IM 账号(我们自己登录的身份,`status` 早就能查)。
11484
+ *
11485
+ * 那个 id(微信 `xxx@im.wechat` / 飞书 `ou_…`)不在任何配置文件里 ——
11486
+ * 它**只在聊天记录里出现过**。⇒ 本文件把记录读成联系人表。
11487
+ *
11488
+ * ## 🔴 两条硬边界
11489
+ *
11490
+ * 1. **只读**:本文件不写任何东西。真相源仍是那些 jsonl(append-only 事件流),
11491
+ * 本文件只是它的一次投影 —— ⛔ 不产生第二真相源。
11492
+ * 2. **不猜渠道**:事件的 `channel` 字段为准;缺它(存量事件)⇒ 按
11493
+ * `accountId → im.accounts[].channel` 回填,仍取不到才落 `weixin`
11494
+ * (那是该字段存在之前唯一的渠道)。⛔ **绝不按 userId 的形状猜**
11495
+ * (`ou_` 前缀 / `@im.wechat` 后缀都是命名习惯,不是判据 —— D167 认族不认名)。
11496
+ */
11497
+ /** 日志根目录族(**新旧两代并存**:`_im-logs/<channel>/` 与存量 `_weixin-logs/`) */
11498
+ const IM_LOG_DIR_NEW = "_im-logs";
11499
+ const IM_LOG_DIR_LEGACY = "_weixin-logs";
11500
+ /** 某 CCC 下**存在**的日志目录(相对根),新代在前 */
11501
+ function imLogRoots(root) {
11502
+ const out = [];
11503
+ const newBase = join(root, "AGENT_SESSIONS", IM_LOG_DIR_NEW);
11504
+ const legacy = join(root, "AGENT_SESSIONS", IM_LOG_DIR_LEGACY);
11505
+ if (existsSync(newBase)) out.push(join("AGENT_SESSIONS", IM_LOG_DIR_NEW));
11506
+ if (existsSync(legacy)) out.push(join("AGENT_SESSIONS", IM_LOG_DIR_LEGACY));
11507
+ return out;
11508
+ }
11509
+ /** 收集某个日志根下所有 `*.jsonl`(含 `_im-logs/<channel>/` 一层;按文件名排序) */
11510
+ function jsonlFilesIn(root, rel) {
11511
+ const abs = join(root, rel);
11512
+ const out = [];
11513
+ let entries = [];
11514
+ try {
11515
+ entries = readdirSync(abs, { withFileTypes: true }).map((d) => d.name);
11516
+ } catch {
11517
+ return out;
11518
+ }
11519
+ for (const name of entries.sort()) {
11520
+ if (name.startsWith(".")) continue;
11521
+ if (name.endsWith(".jsonl")) {
11522
+ out.push(join(rel, name));
11523
+ continue;
11524
+ }
11525
+ if (rel === join("AGENT_SESSIONS", "_im-logs")) {
11526
+ const sub = join(rel, name);
11527
+ try {
11528
+ for (const child of readdirSync(join(root, sub)).sort()) if (child.endsWith(".jsonl")) out.push(join(sub, child));
11529
+ } catch {}
11530
+ }
11531
+ }
11532
+ return out;
11533
+ }
11534
+ /** 从文件名 / 所在目录猜渠道(**只作为回填的最后一级**,不是判据) */
11535
+ function channelFromPath(rel) {
11536
+ const parts = rel.split("/");
11537
+ const idx = parts.indexOf(IM_LOG_DIR_NEW);
11538
+ const candidate = idx >= 0 && parts.length > idx + 2 ? parts[idx + 1] : void 0;
11539
+ return candidate !== void 0 && candidate !== "" ? candidate : null;
11540
+ }
11541
+ /**
11542
+ * 扫描本 CCC 的全部聊天记录,按 `(channel, userId)` 汇总。
11543
+ *
11544
+ * @param root CCC 根
11545
+ * @param accountChannel 存量事件缺 `channel` 时的回填表(`accountId → channel`)
11546
+ * @returns 汇总结果 + **扫描域** + 读取错误(三者都不可省)
11547
+ */
11548
+ function scanImContacts(root, accountChannel = {}) {
11549
+ const byKey = /* @__PURE__ */ new Map();
11550
+ const sources = [];
11551
+ const errors = [];
11552
+ for (const rel of imLogRoots(root)) for (const fileRel of jsonlFilesIn(root, rel)) {
11553
+ sources.push(fileRel);
11554
+ let text = "";
11555
+ try {
11556
+ text = readFileSync(join(root, fileRel), "utf8");
11557
+ } catch (err) {
11558
+ errors.push(`${fileRel}: ${err instanceof Error ? err.message : String(err)}`);
11559
+ continue;
11560
+ }
11561
+ const pathChannel = channelFromPath(fileRel);
11562
+ for (const line of text.split("\n")) {
11563
+ const trimmed = line.trim();
11564
+ if (trimmed === "") continue;
11565
+ let ev;
11566
+ try {
11567
+ ev = JSON.parse(trimmed);
11568
+ } catch {
11569
+ errors.push(`${fileRel}: 有 1 行不是合法 JSON(已跳过)`);
11570
+ continue;
11571
+ }
11572
+ const userId = typeof ev.userId === "string" ? ev.userId.trim() : "";
11573
+ if (userId === "") continue;
11574
+ const accountId = typeof ev.accountId === "string" ? ev.accountId.trim() : "";
11575
+ const channel = (typeof ev.channel === "string" && ev.channel.trim() !== "" ? ev.channel.trim() : null) ?? pathChannel ?? (accountId !== "" ? accountChannel[accountId] ?? null : null) ?? "weixin";
11576
+ const key = `${channel}\u0000${userId}`;
11577
+ let entry = byKey.get(key);
11578
+ if (!entry) {
11579
+ entry = {
11580
+ channel,
11581
+ userId,
11582
+ accountIds: [],
11583
+ roleAtEvent: null,
11584
+ firstSeen: null,
11585
+ lastSeen: null,
11586
+ messages: 0
11587
+ };
11588
+ byKey.set(key, entry);
11589
+ }
11590
+ if (accountId !== "" && !entry.accountIds.includes(accountId)) entry.accountIds.push(accountId);
11591
+ if (typeof ev.role === "string" && ev.role.trim() !== "") entry.roleAtEvent = ev.role.trim();
11592
+ const ts = typeof ev.ts === "number" && Number.isFinite(ev.ts) ? ev.ts : null;
11593
+ if (ts !== null) {
11594
+ if (entry.firstSeen === null || ts < entry.firstSeen) entry.firstSeen = ts;
11595
+ if (entry.lastSeen === null || ts > entry.lastSeen) entry.lastSeen = ts;
11596
+ }
11597
+ entry.messages += 1;
11598
+ }
11599
+ }
11600
+ return {
11601
+ contacts: [...byKey.values()].sort((a, b) => (b.lastSeen ?? 0) - (a.lastSeen ?? 0)),
11602
+ sources,
11603
+ errors
11604
+ };
11605
+ }
11606
+ //#endregion
11324
11607
  //#region src/im/channels.ts
11325
11608
  /**
11326
11609
  * im/channels.ts — IM 发送能力层(v1.31.0,S142 用户洞察)
@@ -11355,15 +11638,39 @@ function imChannelIds() {
11355
11638
  return [...channels.keys()];
11356
11639
  }
11357
11640
  /**
11358
- * 可见性判据:本 CCC 是否启用了**任一** IM 通道。
11641
+ * 可见性判据:本 CCC 是否**有任何可用的 IM 账号**。
11359
11642
  * 未启用 → 工具面对该 agent **不可见**(`tools.restrict` 移除,而非调用时报错)。
11643
+ *
11644
+ * ## 🔴 2026-10-22 判据修正(owner 令:「把开关升级为有任意账号即可,而不是盯着微信」)
11645
+ *
11646
+ * **旧判据** = 遍历已注册通道、任一 `channel.isEnabled(root)` 为真即可见。
11647
+ * **旧判据的缺陷**(实测于一个只配飞书的 CCC):`feishuChannel.isEnabled()` 首行是
11648
+ * `if (settings.enabled !== true) return false` —— 总开关一关,**后面的账号判断根本不执行**
11649
+ * ⇒ 账号明明配了、`enabled:true`,却因为一个**渠道无关的总开关**被永久判成"未启用"
11650
+ * ⇒ `im-bridge` 在模型清单里彻底消失,而配置面看上去是对的(`accounts[]` 里躺着一条 feishu)。
11651
+ * 这正是 D179 的形态:**配置说它有,行为说它没有**,且零报错。
11652
+ *
11653
+ * **新判据** = **账号即事实**:`readImSettings(root).accounts` 里存在
11654
+ * 「`enabled !== false` 的账号」即视为可见。理由三条:
11655
+ * ① **可见性问的是"这个 CCC 有没有 IM 能力"**,而能力的载体是**账号**,不是总开关;
11656
+ * ② 总开关的语义是"**要不要让桥跑起来**",把它当成可见性上限,会让"配好了但暂时不想让它跑"
11657
+ * 这种合法状态变成"工具凭空消失";
11658
+ * ③ 判**按账号判**后,微信与飞书不再各有一套"总开关 + 账号"的双重判据 ⇒ 消除同族漂移。
11659
+ *
11660
+ * ⚠️ **不放宽的部分**:没有账号 ⇒ 仍然不可见(`accounts` 为空即 false)。
11661
+ * ⚠️ 单个通道自己的 `isEnabled` 仍是**发送/启停**判据(那是通道自己的事),
11662
+ * 只是**不再兼任**工具可见性判据 —— 两件事被这次修正分开了。
11663
+ *
11360
11664
  * @param root 本会话 CCC 根(由调用方解析)
11361
11665
  */
11362
11666
  function hasEnabledImChannel(root) {
11363
- for (const channel of channels.values()) try {
11364
- if (channel.isEnabled(root)) return true;
11365
- } catch {}
11366
- return false;
11667
+ let settings;
11668
+ try {
11669
+ settings = readImSettings(root);
11670
+ } catch {
11671
+ return false;
11672
+ }
11673
+ return (settings.accounts ?? []).some((a) => a.enabled !== false);
11367
11674
  }
11368
11675
  /**
11369
11676
  * 错误码 → 可行动提示(模型视角:说清怎么改)。
@@ -11464,6 +11771,39 @@ async function runImBridge(root, req) {
11464
11771
  action,
11465
11772
  detail: { users: channel.listUsers(root) }
11466
11773
  };
11774
+ if (action === "contacts") {
11775
+ if (!channel.listContacts) return fail("ACTION_UNSUPPORTED", `channel "${channelId}" has no record source for contacts`);
11776
+ const settings = readWeixinSettings(root);
11777
+ const accountChannel = {};
11778
+ for (const account of settings.accounts ?? []) if (typeof account.accountId === "string" && account.accountId !== "") accountChannel[account.accountId] = typeof account.channel === "string" && account.channel !== "" ? account.channel : "weixin";
11779
+ const scanned = scanImContacts(root, accountChannel);
11780
+ const mine = scanned.contacts.filter((c) => c.channel === channelId);
11781
+ /** 当前路由表(`user → trajectory`);同一 user 多条时后者覆盖 */
11782
+ const routeMap = /* @__PURE__ */ new Map();
11783
+ for (const route of settings.routes ?? []) if (typeof route.user === "string" && route.user !== "") routeMap.set(route.user, typeof route.trajectory === "string" ? route.trajectory : "");
11784
+ const contacts = mine.map((c) => ({
11785
+ channel: c.channel,
11786
+ userId: c.userId,
11787
+ accountIds: c.accountIds,
11788
+ route: routeMap.get(c.userId) ?? null,
11789
+ firstSeen: c.firstSeen === null ? null : new Date(c.firstSeen).toISOString(),
11790
+ lastSeen: c.lastSeen === null ? null : new Date(c.lastSeen).toISOString(),
11791
+ messages: c.messages
11792
+ }));
11793
+ const seenUsers = new Set(mine.map((c) => c.userId));
11794
+ return {
11795
+ ok: true,
11796
+ channel: channelId,
11797
+ action,
11798
+ detail: {
11799
+ contacts,
11800
+ unrouted: mine.filter((c) => !routeMap.has(c.userId)).map((c) => c.userId),
11801
+ orphanRoutes: [...routeMap.keys()].filter((user) => !seenUsers.has(user)),
11802
+ sources: scanned.sources,
11803
+ ...scanned.errors.length > 0 ? { errors: scanned.errors } : {}
11804
+ }
11805
+ };
11806
+ }
11467
11807
  if (action === "status") return {
11468
11808
  ok: true,
11469
11809
  channel: channelId,
@@ -11531,6 +11871,114 @@ async function runImBridge(root, req) {
11531
11871
  return fail("ACTION_UNKNOWN", `unknown action "${action}"`);
11532
11872
  }
11533
11873
  //#endregion
11874
+ //#region src/seams/tool-visibility.ts
11875
+ /** 收集器:把各来源的 deny 合并成一个判决(**纯函数、无副作用**) */
11876
+ function collectVisibilityDecision(sources) {
11877
+ const deny = /* @__PURE__ */ new Set();
11878
+ let hasError = false;
11879
+ for (const s of sources) {
11880
+ if (s.error !== void 0) hasError = true;
11881
+ if (!s.ok) continue;
11882
+ for (const name of s.deny) if (typeof name === "string" && name !== "") deny.add(name);
11883
+ }
11884
+ return {
11885
+ deny: [...deny].sort(),
11886
+ sources,
11887
+ hasError
11888
+ };
11889
+ }
11890
+ /** 判决签名(幂等判据:签名相同 ⇒ 目标态已达到 ⇒ **一次 restrict 都不发**) */
11891
+ function decisionSignature(decision) {
11892
+ return decision.deny.join(",");
11893
+ }
11894
+ /** `${sessionId}` → 已发射状态(**全模块唯一的一份**) */
11895
+ const applied = /* @__PURE__ */ new Map();
11896
+ /**
11897
+ * 发射一次判决(**唯一的写点**)。
11898
+ *
11899
+ * 三条纪律:
11900
+ * ① **幂等**:签名与当前已发射的一致 ⇒ 直接返回 `'unchanged'`,**不碰宿主**
11901
+ * (这正是消除"工具清单闪动"的那一步)。
11902
+ * ② **先撤后发**:宿主的 disposer 才是"撤销上一次",先撤再发才不会叠加旧集合。
11903
+ * (旧集合是新集合的子集时叠不叠都看不出来,但**不是子集时**会残留 —— 必须撤。)
11904
+ * ③ 🔴 **未注册的名字不许炸掉整次发射**:宿主契约"unknown names … fail"。
11905
+ * 合并后集合里只要有一个不在册的名字,整次 restrict 就失败 ⇒ 六个机制**全都不生效**。
11906
+ * ⇒ 发射前**先逐个探活**(`tools.get(name)`),把不在册的名字剔掉并**如实记账**
11907
+ * (剔除的名字进 `dropped`,由调用方决定要不要告警)。
11908
+ * ⚠️ 这比旧行为**更保守**:旧行为是"某条 restrict 失败 ⇒ 那一格静默不生效",
11909
+ * 新行为是"剔掉那个名字、其余照常生效" —— **等价或更好**,且总有日志。
11910
+ *
11911
+ * @returns `'unchanged'` / `'applied'` / `'failed'`(`failed` 时 `deny` 保持原样)
11912
+ */
11913
+ function applyVisibilityDecision(agent, sessionId, decision) {
11914
+ const signature = decisionSignature(decision);
11915
+ const existing = applied.get(sessionId);
11916
+ if (existing !== void 0 && existing.signature === signature) return {
11917
+ status: "unchanged",
11918
+ dropped: []
11919
+ };
11920
+ if (existing !== void 0) {
11921
+ try {
11922
+ existing.dispose();
11923
+ } catch {}
11924
+ applied.delete(sessionId);
11925
+ }
11926
+ const tools = agent.ctx.tools;
11927
+ const dropped = [];
11928
+ let names = decision.deny;
11929
+ if (typeof tools.get === "function") {
11930
+ names = [];
11931
+ for (const n of decision.deny) {
11932
+ let present = false;
11933
+ try {
11934
+ present = tools.get(n) !== void 0;
11935
+ } catch {
11936
+ present = false;
11937
+ }
11938
+ if (present) names.push(n);
11939
+ else dropped.push(n);
11940
+ }
11941
+ }
11942
+ if (names.length === 0) {
11943
+ applied.set(sessionId, {
11944
+ signature,
11945
+ deny: [],
11946
+ dispose: () => {}
11947
+ });
11948
+ return {
11949
+ status: "applied",
11950
+ dropped
11951
+ };
11952
+ }
11953
+ try {
11954
+ const dispose = tools.restrict({ deny: [...names] });
11955
+ applied.set(sessionId, {
11956
+ signature,
11957
+ deny: names,
11958
+ dispose
11959
+ });
11960
+ return {
11961
+ status: "applied",
11962
+ dropped
11963
+ };
11964
+ } catch (e) {
11965
+ return {
11966
+ status: "failed",
11967
+ dropped,
11968
+ error: e.message
11969
+ };
11970
+ }
11971
+ }
11972
+ /** 会话销毁清理(防 per-会话 Map 无界增长;lifecycle 调用) */
11973
+ function forgetToolVisibility(sessionId) {
11974
+ const existing = applied.get(sessionId);
11975
+ if (existing === void 0) return;
11976
+ try {
11977
+ existing.dispose();
11978
+ } catch {}
11979
+ applied.delete(sessionId);
11980
+ }
11981
+ //#endregion
11534
11982
  //#region src/seams/guards.ts
11535
11983
  /**
11536
11984
  * guards.ts — 拦截缝:安全模式 + 路径守卫(P3 语义的机械层)
@@ -11667,6 +12115,119 @@ function decideGuard(input) {
11667
12115
  }
11668
12116
  return { kind: "allow" };
11669
12117
  }
12118
+ /**
12119
+ * 🔴 **工具可见性的唯一发射入口**(方案 C 的落地)。
12120
+ *
12121
+ * ## 它取代了什么
12122
+ *
12123
+ * 改之前有六条独立 sync,每条在自己的 per-会话 Map 里放一个 `restrict` disposer,
12124
+ * 且都由 `pre-step`(每步)与 `seed`(冷载入)各自调用 ⇒ 一次交互里可能发出多次
12125
+ * `tools.restrict` ⇒ 宿主每次发 `tools/change` ⇒ 模型侧看到工具清单**先进后出**
12126
+ * (实测症状:`新增 7 个` 紧跟 `移除 7 个`)。
12127
+ *
12128
+ * ## 为什么合并是对的(不是简化)
12129
+ *
12130
+ * 宿主 `ToolRuntime.restrict` 契约逐字:**Restrictions intersect**。
12131
+ * ⇒ N 次 `deny: Dᵢ` 的净效果 = `⋃Dᵢ`。先求并集只发一次,**结果相同**、只触发一次 `tools/change`。
12132
+ * 前提 = **只用 `deny`**(`allow` 的"交集"不满足并集律);本仓 18 处 restrict 全部只发 `deny`。
12133
+ *
12134
+ * ## 正确性怎么保住(这是本设计被质疑的那一点)
12135
+ *
12136
+ * 六源**各自独立**算自己的 deny 并**各自 catch**:某一个来源读配置抛错 ⇒ 它返回
12137
+ * `ok:false`(不贡献 deny),**其余五源照常生效**。⇒ 合并**不会**把一格的失败放大成六格。
12138
+ * 全量计算见 {@link computeVisibilitySources}(纯函数)。
12139
+ *
12140
+ * @param agent 目标会话
12141
+ * @param root CCC 根;`null` ⇒ 不在 CCC 内 ⇒ 隐藏**全部** ACC 工具
12142
+ */
12143
+ function syncToolVisibility(agent, root) {
12144
+ const sessionId = agent.session.id ?? "global";
12145
+ const sources = computeVisibilitySources(sessionId, root);
12146
+ const decision = collectVisibilityDecision(sources);
12147
+ if (root !== null && decision.hasError) {
12148
+ const bad = sources.filter((s) => s.error !== void 0);
12149
+ for (const s of bad) console.error(`[serenity-hooks] 工具可见性来源「${s.id}」判据失败(其余来源照常生效): ${s.error ?? ""}`);
12150
+ }
12151
+ const r = applyVisibilityDecision(agent, sessionId, decision);
12152
+ if (r.status === "failed") console.error(`[serenity-hooks] 工具可见性发射失败 (session=${sessionId}): ${r.error ?? ""}`);
12153
+ else if (r.dropped.length > 0) console.log(`[serenity-hooks] 工具可见性:跳过 ${r.dropped.length} 个未注册名(${r.dropped.join(", ")})`);
12154
+ }
12155
+ /**
12156
+ * 六源 → 各自的 deny 集合(**纯函数:只读配置、不碰宿主**)。
12157
+ *
12158
+ * 每条来源**自己负责自己的失败**:抛错就 catch 在这里,转成 `ok:false + error`。
12159
+ * ⛔ 绝不让一个来源的异常冒泡 —— 那会让另外五源一起失效(正是合并最容易犯的错)。
12160
+ */
12161
+ function computeVisibilitySources(sessionId, root) {
12162
+ const out = [];
12163
+ out.push(sourceOf("nonCcc", () => {
12164
+ if (root !== null) return { deny: [] };
12165
+ return { deny: [...REGISTERED_TOOLS] };
12166
+ }));
12167
+ if (root === null) {
12168
+ for (const id of [
12169
+ "safeMode",
12170
+ "imBridge",
12171
+ "exclusive",
12172
+ "hostSchedule",
12173
+ "trajectoryConstraint"
12174
+ ]) out.push({
12175
+ id,
12176
+ deny: [],
12177
+ ok: false
12178
+ });
12179
+ return out;
12180
+ }
12181
+ out.push(sourceOf("safeMode", () => {
12182
+ if (!isSafeModeOn(root)) return { deny: [] };
12183
+ let declared = [];
12184
+ try {
12185
+ declared = readSafeModeHiddenTools(root);
12186
+ } catch (e) {
12187
+ return {
12188
+ deny: [...SAFE_MODE_DENY_TOOLS],
12189
+ error: e.message
12190
+ };
12191
+ }
12192
+ return { deny: [.../* @__PURE__ */ new Set([...SAFE_MODE_DENY_TOOLS, ...declared])] };
12193
+ }));
12194
+ out.push(sourceOf("imBridge", () => hasEnabledImChannel(root) ? { deny: [] } : { deny: [IM_BRIDGE_TOOL_NAME] }));
12195
+ out.push(sourceOf("exclusive", () => {
12196
+ const declared = readExclusiveTools(root);
12197
+ return { deny: EXCLUSIVE_TOOL_NAMES.filter((t) => !declared.includes(t)) };
12198
+ }));
12199
+ out.push(sourceOf("hostSchedule", () => ({ deny: [...HOST_SCHEDULE_TOOL_NAMES] })));
12200
+ out.push(sourceOf("trajectoryConstraint", () => {
12201
+ const profile = trajectoryConstraintForSession(root, sessionId);
12202
+ if (profile === null) return { deny: [] };
12203
+ const allowed = profileToolWhitelist(profile);
12204
+ if (allowed === null) return { deny: [] };
12205
+ return { deny: REGISTERED_TOOLS.filter((n) => !allowed.has(n)) };
12206
+ }));
12207
+ return out;
12208
+ }
12209
+ /**
12210
+ * 把一个"可能抛错的 denies 计算"包成来源(**统一在这里 catch**)。
12211
+ * 失败 ⇒ `ok:false`:本来源不贡献 deny,**其余来源不受影响**。
12212
+ */
12213
+ function sourceOf(id, fn) {
12214
+ try {
12215
+ const r = fn();
12216
+ return {
12217
+ id,
12218
+ deny: r.deny,
12219
+ ok: true,
12220
+ ...r.error !== void 0 ? { error: r.error } : {}
12221
+ };
12222
+ } catch (e) {
12223
+ return {
12224
+ id,
12225
+ deny: [],
12226
+ ok: false,
12227
+ error: e.message
12228
+ };
12229
+ }
12230
+ }
11670
12231
  /** 从 exec 提取 agent 会话 cwd(CCC 根检测基准);无则回退进程 cwd */
11671
12232
  function resolveAgentCwd(exec) {
11672
12233
  return exec.agent?.session?.header?.cwd ?? process.cwd();
@@ -11692,168 +12253,17 @@ function getRestrictDiagnostics() {
11692
12253
  denyByKey: Object.fromEntries([...safeModeRestrictions].map(([k, v]) => [k, v.deny]))
11693
12254
  };
11694
12255
  }
11695
- /** 诊断落盘:AGENT_SESSIONS/.restrict-diag.json(文件通道,避免 HTTP 自锁) */
11696
- function writeRestrictDiag(root) {
11697
- try {
11698
- const dir = resolve(root, "AGENT_SESSIONS");
11699
- mkdirSync(dir, { recursive: true });
11700
- writeFileSync(resolve(dir, ".restrict-diag.json"), JSON.stringify({
11701
- ...getRestrictDiagnostics(),
11702
- cccRoot: root
11703
- }, null, 2) + "\n", "utf-8");
11704
- } catch {}
11705
- }
11706
- /**
11707
- * 同步安全模式工具隐藏:标记存在 → `agent.ctx.tools.restrict deny` 隐藏
11708
- * **基础表(bash)∪ CCC 声明的 `safeMode.hiddenTools`**;标记消失 → 全部解除。
11709
- * pre-step 每步调用 ⇒ 开/关/**改声明**三种变化都实时生效(v1.51.4 起含"改声明")。
11710
- *
11711
- * 与 `readSafeModeHiddenTools` 的分工(R↓):本函数负责**形状与失败退化**,reader 只管取值。
11712
- * 🔴 退化规则:DSH 契约规定 **未注册的工具名会让整次 restrict 失败**("unknown names … fail")
11713
- * ⇒ 绝不能让 CCC 写错一个名字就丢掉 safe-mode 的核心隐藏(bash)。失败时退化为**只隐藏基础表**,
11714
- * 并在日志响亮报错;退化态**按期望签名记账**,不会每步重试刷屏。
11715
- */
11716
- function syncSafeModeRestriction(agent, root) {
11717
- const key = agent.session.id ?? "global";
11718
- const on = isSafeModeOn(root);
11719
- const existing = safeModeRestrictions.get(key);
11720
- if (!on) {
11721
- if (existing !== void 0) {
11722
- try {
11723
- existing.dispose();
11724
- } catch (e) {
11725
- console.error(`[serenity-hooks] restrict 解除失败 (key=${key}):`, e.message);
11726
- }
11727
- safeModeRestrictions.delete(key);
11728
- }
11729
- writeRestrictDiag(root);
11730
- return;
11731
- }
11732
- let declared = [];
11733
- try {
11734
- declared = readSafeModeHiddenTools(root);
11735
- } catch (e) {
11736
- console.error(`[serenity-hooks] safeMode.hiddenTools 判据读取失败 (key=${key}):`, e.message);
11737
- }
11738
- const deny = [.../* @__PURE__ */ new Set([...SAFE_MODE_DENY_TOOLS, ...declared])];
11739
- const signature = deny.join(",");
11740
- if (existing !== void 0 && existing.signature === signature) {
11741
- writeRestrictDiag(root);
11742
- return;
11743
- }
11744
- if (existing !== void 0) {
11745
- try {
11746
- existing.dispose();
11747
- } catch (e) {
11748
- console.error(`[serenity-hooks] restrict 重算前解除失败 (key=${key}):`, e.message);
11749
- }
11750
- safeModeRestrictions.delete(key);
11751
- }
11752
- let applied = deny;
11753
- let dispose;
11754
- try {
11755
- dispose = agent.ctx.tools.restrict({ deny: [...deny] });
11756
- } catch (e) {
11757
- restrictDiag.lastKey = key;
11758
- restrictDiag.lastAttemptAt = isoLocal();
11759
- restrictDiag.lastSuccess = false;
11760
- restrictDiag.lastError = e.message;
11761
- console.error(`[serenity-hooks] restrict 失败,已退化为基础表(多为 safeMode.hiddenTools 里有本 CCC **未注册**的工具名)(key=${key}):`, e.message);
11762
- applied = [...SAFE_MODE_DENY_TOOLS];
11763
- try {
11764
- dispose = agent.ctx.tools.restrict({ deny: applied });
11765
- } catch (e2) {
11766
- restrictDiag.lastError = `${e.message} / 退化亦失败: ${e2.message}`;
11767
- console.error(`[serenity-hooks] restrict 退化失败(守卫仍兜底拦截)(key=${key}):`, e2.message);
11768
- safeModeRestrictions.set(key, {
11769
- signature,
11770
- deny: [],
11771
- dispose: () => {}
11772
- });
11773
- writeRestrictDiag(root);
11774
- return;
11775
- }
11776
- }
11777
- restrictDiag.lastKey = key;
11778
- restrictDiag.lastAttemptAt = isoLocal();
11779
- restrictDiag.lastSuccess = true;
11780
- restrictDiag.lastError = null;
11781
- safeModeRestrictions.set(key, {
11782
- signature,
11783
- deny: applied,
11784
- dispose
11785
- });
11786
- writeRestrictDiag(root);
11787
- }
11788
12256
  /** 条件可见的 IM 工具名(与 tools/im/channels.ts 的注册名一致) */
11789
12257
  const IM_BRIDGE_TOOL_NAME = "im-bridge";
11790
- /** agent key → restrict disposer(im-bridge 隐藏状态;key 同安全模式 = 会话 id) */
11791
- const imBridgeRestrictions = /* @__PURE__ */ new Map();
11792
- /**
11793
- * 同步 im-bridge 可见性(v1.31.0,S142 用户拍板 Q3「未配置则不可见」)。
11794
- *
11795
- * 判据 = 本会话 CCC 是否启用了**任一** IM 通道(`hasEnabledImChannel`,当前只有 weixin):
11796
- * - 未启用 → `agent.ctx.tools.restrict({ deny: ['im-bridge'] })`:**从 schema 移除**
11797
- * (模型看不到,而不是看得到但调用被拒——用户原话"不配置则不可见")
11798
- * - 启用 → 解除隐藏(CCC 配置热更新即时生效)
11799
- *
11800
- * 与 `syncSafeModeRestriction` 同点调用(pre-step 每步 + 会话就绪),失败不阻断
11801
- * (守卫仍兜底;restrict 不可用时最坏是"看得到但通道未配置 → 返回 CHANNEL_NOT_CONFIGURED")。
11802
- */
11803
- function syncImBridgeVisibility(agent, root) {
11804
- const key = agent.session.id ?? "global";
11805
- let enabled = false;
11806
- try {
11807
- enabled = hasEnabledImChannel(root);
11808
- } catch (e) {
11809
- console.error(`[serenity-hooks] im-bridge 可见性判据失败 (key=${key}):`, e.message);
11810
- return;
11811
- }
11812
- const existing = imBridgeRestrictions.get(key);
11813
- if (!enabled && !existing) try {
11814
- const dispose = agent.ctx.tools.restrict({ deny: [IM_BRIDGE_TOOL_NAME] });
11815
- imBridgeRestrictions.set(key, dispose);
11816
- } catch (e) {
11817
- console.error(`[serenity-hooks] im-bridge 隐藏失败 (key=${key}):`, e.message);
11818
- }
11819
- else if (enabled && existing) {
11820
- try {
11821
- existing();
11822
- } catch (e) {
11823
- console.error(`[serenity-hooks] im-bridge 解除隐藏失败 (key=${key}):`, e.message);
11824
- }
11825
- imBridgeRestrictions.delete(key);
11826
- }
11827
- }
11828
- /** 会话销毁清理(lifecycle 调用;防 per-会话 Map 无界增长——review F-08 同族) */
11829
- function forgetImBridgeVisibility(sessionId) {
11830
- const existing = imBridgeRestrictions.get(sessionId);
11831
- if (!existing) return;
11832
- try {
11833
- existing();
11834
- } catch {}
11835
- imBridgeRestrictions.delete(sessionId);
11836
- }
11837
12258
  /**
11838
- * 宿主 `dsh-tool-schedule` 注册的 4 个模型可见工具名(**逐字**,来自该包 `lib/index.js` 的
11839
- * `defineTool({ name: … })`:`schedule_create` / `schedule_list` / `schedule_update` / `schedule_delete`)。
12259
+ * 宿主 `dsh-tool-schedule` 注册的 4 个模型可见工具名(**逐字**)。
11840
12260
  *
11841
- * 🔴 **为什么要在 CCC 内屏蔽(owner 原话:「这和 CRO 冲突,要求在宁静号激活情况下屏蔽掉」)**:
11842
- * 宿主从 `0.2.1-rc.2` 起自带一套**模型可见的定时器工具**(`ctx.schedule` 服务 + 这 4 个工具)。
11843
- * 而宁静号的唤醒/巡航走的是**另一套**机制:`cognitive_trajectory send-later` → 落 CCC 的
11844
- * `AGENT_SESSIONS/wake-registry.json` → 由**中心调度器 5min tick 统一投递**(D58)。
11845
- * 两套并存会产生**三个具体问题**(不是"重复功能"这种审美问题):
11846
- * ① **两处真相源**:宿主的 schedule 状态在宿主域、我们的在 CCC 文件里,**谁在跑说不清**;
11847
- * ② **CRO 判据失真**:CRO 程序读的是 `wake-registry.json`,宿主定时器触发的唤起**它看不见**
11848
- * ⇒ 巡检/记账全部遗漏(本轨已有同族教训:「CRO 只在 `wake===true` 时算」那条被推翻);
11849
- * ③ **安静失败**:模型可能顺手用 `schedule_create` 排一个唤醒,**它不在任何人的账本里**。
11850
- * ⇒ 故**在 CCC 内隐藏**(从模型清单移除),非 CCC 会话**不受影响**(那是宿主的正常能力)。
11851
- *
11852
- * ⚠️ **一处必须防的坑**(与 `hideAllFor` 同族):宿主 `restrict` 对**未注册的名字**会让**整次调用失败**
11853
- * (`dsh-tools` 契约逐字:"Empty filters, unknown names … fail")。
11854
- * 这 4 个名字是否在册取决于**宿主是否装了该 tool 包**(不同宿主版本/裁剪可能没有)
11855
- * ⇒ 所以**逐个名字单独发 restrict**,某个失败就跳过那个,**绝不整批发**
11856
- * (整批发一旦撞到缺名的宿主 ⇒ 一个都不隐藏 + 只有一行 log ⇒ 缺陷静默)。
12261
+ * 🔴 **为什么要在 CCC 内屏蔽**(owner 原话:「这和 CRO 冲突,要求在宁静号激活情况下屏蔽掉」):
12262
+ * 宁静号的唤醒/巡航走 `cognitive_trajectory send-later` → `AGENT_SESSIONS/wake-registry.json`
12263
+ * → 中心调度器 5min tick(D58)。宿主这套定时器是**另一个**真相源 ⇒ 两处记账、
12264
+ * CRO 判据失真、且模型可能顺手排一个"不在任何账本里"的唤醒。
12265
+ * ⚠️ 本清单**合并发射时不需要探活**(`applyVisibilityDecision` 统一个剔掉未注册名),
12266
+ * 所以宿主没装该包时不会炸掉整次发射 —— 这是方案 C 顺带修掉的一个旧坑。
11857
12267
  */
11858
12268
  const HOST_SCHEDULE_TOOL_NAMES = [
11859
12269
  "schedule_create",
@@ -11861,42 +12271,6 @@ const HOST_SCHEDULE_TOOL_NAMES = [
11861
12271
  "schedule_update",
11862
12272
  "schedule_delete"
11863
12273
  ];
11864
- /** `${sessionId}::${toolName}` → restrict disposer(逐工具,便于某个名字不成立时单独跳过) */
11865
- const hostScheduleRestrictions = /* @__PURE__ */ new Map();
11866
- /**
11867
- * 在 **CCC 内**屏蔽宿主自带的定时器工具(v1.59.5)。
11868
- *
11869
- * 判据 = **会话是否落在某个 CCC 内**(`root !== null`)——与 `syncCccToolVisibility` 互补:
11870
- * 那个管"不在 CCC ⇒ 隐藏**我们自己的**全部工具",本函数管"**在** CCC ⇒ 隐藏**宿主的**定时器工具"。
11871
- *
11872
- * 幂等;失败不阻断 step(最坏 = 那一个工具仍可见,但那只是"宿主的正常能力没被藏起来",
11873
- * 不会让会话坏掉)。逐个名字独立发 restrict ⇒ 任何一个名字不在册都不会连累其余三个。
11874
- *
11875
- * @param agent 目标会话
11876
- * @param root 该会话的 CCC 根;`null` ⇒ 非 CCC ⇒ **不动**(宿主的定时器在别处是正常能力)
11877
- */
11878
- function syncHostScheduleToolHiding(agent, root) {
11879
- if (root === null) return;
11880
- const sessionId = agent.session.id ?? "global";
11881
- for (const tool of HOST_SCHEDULE_TOOL_NAMES) {
11882
- const key = `${sessionId}::${tool}`;
11883
- if (hostScheduleRestrictions.has(key)) continue;
11884
- try {
11885
- hostScheduleRestrictions.set(key, agent.ctx.tools.restrict({ deny: [tool] }));
11886
- } catch {}
11887
- }
11888
- }
11889
- /** 会话销毁清理(lifecycle 调用;按 `${sessionId}::` 前缀回收) */
11890
- function forgetHostScheduleToolHiding(sessionId) {
11891
- const prefix = `${sessionId}::`;
11892
- for (const [key, dispose] of [...hostScheduleRestrictions]) {
11893
- if (!key.startsWith(prefix)) continue;
11894
- try {
11895
- dispose();
11896
- } catch {}
11897
- hostScheduleRestrictions.delete(key);
11898
- }
11899
- }
11900
12274
  /**
11901
12275
  * ACC 注册但**默认对所有 CCC 隐藏**的专属工具清单。
11902
12276
  *
@@ -11906,156 +12280,6 @@ function forgetHostScheduleToolHiding(sessionId) {
11906
12280
  * 本族是"被点名才可见"(能力归属 ACC 负责人)。
11907
12281
  */
11908
12282
  const EXCLUSIVE_TOOL_NAMES = ["acc-diag"];
11909
- /** `${sessionId}::${toolName}` → restrict disposer */
11910
- const exclusiveRestrictions = /* @__PURE__ */ new Map();
11911
- /**
11912
- * `sessionId` → { signature, dispose }(角色工具面 restrict 状态)
11913
- *
11914
- * `signature` = 本轮实际发出的 deny 清单(排序后 join)。用它做**幂等判据**:
11915
- * 每次 pre-step 都重算白名单,只有**算出来的结果变了**才重新发 restrict
11916
- * —— 配置不变就不重复发(restrict 会重建 schema,没必要每步都做)。
11917
- */
11918
- const trajectoryConstraintRestrictions = /* @__PURE__ */ new Map();
11919
- /**
11920
- * 同步 **轨迹约束会话的工具面**:把白名单之外的工具**从 schema 移除**(不是"看得到但被拒")。
11921
- *
11922
- * ## 为什么必须有这条(owner 2026-10-19:「工具清单也要热加载」)
11923
- *
11924
- * 在它之前,轨迹约束白名单**只由运行时守卫拦截**(`decideGuard` 的 轨迹约束 分支):
11925
- * 工具全部出现在模型 schema 里,调用时才被打回。⇒ 两个真实后果:
11926
- * ① **模型看得见它不该有的工具** —— 它会去试,试了才撞墙(一轮白烧,且错误文案泛化,
11927
- * 看不出"是不在清单里"还是"临时性拒绝");
11928
- * ② **配置变动在 schema 上永远不生效** —— 改 `trajectory-constraint.json` 的 `tools` 只影响"调用是否放行"。
11929
- *
11930
- * ## 判据与降级
11931
- *
11932
- * - deny 集合 = **`REGISTERED_TOOLS` − 白名单**(只对**我们自己注册的**工具算,
11933
- * 不去动宿主的工具 —— 宿主的工具由各自的条件同步管)。
11934
- * - ⚠️ **未注册的名字会让整次 restrict 失败**(宿主契约逐字 "unknown names … fail"),
11935
- * 而 `diagram` 是按开关注册的 ⇒ 与 `hideAllFor` 同款**两档尝试**(先全覆盖,失败退常驻段)。
11936
- * - 读不到约束面(不是 轨迹约束 / 配置删了)⇒ **撤销既有隐藏**(回到"无约束"):
11937
- * ⛔ 不能只是 `return` —— 那会把上一步的隐藏**永久留在**一个已不再是 轨迹约束的会话上
11938
- * (这正是 owner 说的"删掉 `trajectory-constraint.json` 就该立刻恢复")。
11939
- * - 幂等:算出来的 deny 签名不变就跳过(不每步重建 schema)。
11940
- *
11941
- * @param agent 目标会话
11942
- * @param root CCC 根
11943
- */
11944
- function syncTrajectoryConstraintToolVisibility(agent, root) {
11945
- const sessionId = agent.session.id ?? "global";
11946
- const profile = trajectoryConstraintForSession(root, sessionId);
11947
- const existing = trajectoryConstraintRestrictions.get(sessionId);
11948
- if (profile === null) {
11949
- if (existing !== void 0) {
11950
- try {
11951
- existing.dispose();
11952
- } catch {}
11953
- trajectoryConstraintRestrictions.delete(sessionId);
11954
- }
11955
- return;
11956
- }
11957
- const allowed = profileToolWhitelist(profile);
11958
- if (allowed === null) {
11959
- if (existing !== void 0) {
11960
- try {
11961
- existing.dispose();
11962
- } catch {}
11963
- trajectoryConstraintRestrictions.delete(sessionId);
11964
- }
11965
- return;
11966
- }
11967
- const resident = REGISTERED_TOOLS.slice(0, REGISTERED_TOOLS.length - 3);
11968
- const tiers = [REGISTERED_TOOLS, resident];
11969
- for (const names of tiers) {
11970
- const deny = names.filter((n) => !allowed.has(n));
11971
- const signature = [...deny].sort().join(",");
11972
- if (existing !== void 0 && existing.signature === signature) return;
11973
- if (deny.length === 0) {
11974
- if (existing !== void 0) {
11975
- try {
11976
- existing.dispose();
11977
- } catch {}
11978
- trajectoryConstraintRestrictions.delete(sessionId);
11979
- }
11980
- return;
11981
- }
11982
- try {
11983
- if (existing !== void 0) {
11984
- try {
11985
- existing.dispose();
11986
- } catch {}
11987
- trajectoryConstraintRestrictions.delete(sessionId);
11988
- }
11989
- const dispose = agent.ctx.tools.restrict({ deny: [...deny] });
11990
- trajectoryConstraintRestrictions.set(sessionId, {
11991
- signature,
11992
- deny,
11993
- dispose
11994
- });
11995
- return;
11996
- } catch {
11997
- continue;
11998
- }
11999
- }
12000
- }
12001
- /** 会话销毁清理(lifecycle 调用) */
12002
- function forgetTrajectoryConstraintToolVisibility(sessionId) {
12003
- const existing = trajectoryConstraintRestrictions.get(sessionId);
12004
- if (!existing) return;
12005
- try {
12006
- existing.dispose();
12007
- } catch {}
12008
- trajectoryConstraintRestrictions.delete(sessionId);
12009
- }
12010
- /**
12011
- * 同步专属工具可见性(v1.33)。
12012
- *
12013
- * 判据 = CCC 配置 `exclusiveTools`(`readExclusiveTools`):
12014
- * - **未声明** → `agent.ctx.tools.restrict({ deny: [tool] })`:从 schema 移除(模型看不到)
12015
- * - **声明** → 解除隐藏(CCC 配置热更新即时生效)
12016
- *
12017
- * 判据失败按"未声明"处理(fail-closed:专属工具默认隐藏),原因见 `readExclusiveTools` 注释。
12018
- * 与 `syncImBridgeVisibility` 同点调用(pre-step 每步 + 会话就绪),失败不阻断一步——
12019
- * 最坏情况是"看得到但被拒",而不是让 step 出错。
12020
- */
12021
- function syncExclusiveToolsVisibility(agent, root) {
12022
- const sessionId = agent.session.id ?? "global";
12023
- let declared = [];
12024
- try {
12025
- declared = readExclusiveTools(root);
12026
- } catch (e) {
12027
- console.error(`[serenity-hooks] 专属工具可见性判据失败 (session=${sessionId}):`, e.message);
12028
- }
12029
- for (const tool of EXCLUSIVE_TOOL_NAMES) {
12030
- const key = `${sessionId}::${tool}`;
12031
- const existing = exclusiveRestrictions.get(key);
12032
- const visible = declared.includes(tool);
12033
- if (!visible && !existing) try {
12034
- exclusiveRestrictions.set(key, agent.ctx.tools.restrict({ deny: [tool] }));
12035
- } catch (e) {
12036
- console.error(`[serenity-hooks] 专属工具隐藏失败 (${key}):`, e.message);
12037
- }
12038
- else if (visible && existing) {
12039
- try {
12040
- existing();
12041
- } catch (e) {
12042
- console.error(`[serenity-hooks] 专属工具解除隐藏失败 (${key}):`, e.message);
12043
- }
12044
- exclusiveRestrictions.delete(key);
12045
- }
12046
- }
12047
- }
12048
- /** 会话销毁清理(lifecycle 调用;本函数按 `${sessionId}::` 前缀回收全部专属工具项) */
12049
- function forgetExclusiveToolsVisibility(sessionId) {
12050
- const prefix = `${sessionId}::`;
12051
- for (const [key, dispose] of [...exclusiveRestrictions]) {
12052
- if (!key.startsWith(prefix)) continue;
12053
- try {
12054
- dispose();
12055
- } catch {}
12056
- exclusiveRestrictions.delete(key);
12057
- }
12058
- }
12059
12283
  /**
12060
12284
  * 非 CCC 会话的工具隐藏状态(sessionId → restrict disposer)。
12061
12285
  *
@@ -12079,74 +12303,6 @@ function forgetExclusiveToolsVisibility(sessionId) {
12079
12303
  * **静默退回"工具照样可见"**=缺陷没修好。故此处走**两档尝试**(见 `hideAllFor`)。
12080
12304
  */
12081
12305
  const nonCccRestrictions = /* @__PURE__ */ new Map();
12082
- /**
12083
- * 在非 CCC 会话上隐藏全部 ACC 工具(两档尝试)。
12084
- *
12085
- * 🔴 **为什么要两档(D119:不许照源码"意图"断言宿主行为)**:取消注册的工具名会让整次
12086
- * `restrict` 失败,而 `diagram` 缺省不在册。若只发一次全表 deny,"deny 里有个不在册的名字"
12087
- * 就会让**整次调用**失败 ⇒ 一个都不隐藏(且只有一行 console.error)。
12088
- *
12089
- * 档位(**先全覆盖、失败再退到"恒在册"那一段**):
12090
- * ① 全表 `REGISTERED_TOOLS`(14:含条件工具)—— 首选,覆盖最全
12091
- * ② 常驻段(前 `length - CONDITIONAL_TOOL_COUNT` 个)—— 退路:只保证**恒注册**的那些被隐藏
12092
- *
12093
- * 返回**实际生效的注销器**;两档都失败 → `{ failed: [...] }`(原因一并带回,由调用方决定怎么响)。
12094
- *
12095
- * ⚠️ **档位失败不在这里逐条告警**:档 ① 在 `diagram` 缺省关时**按设计**就会失败(它不在册),
12096
- * 那是**常态**、不是故障 ⇒ 逐条 error 会在每个非 CCC 会话上刷**假警报**(看着像坏了,其实档 ② 已成功)。
12097
- * 真正的失败只由调用方在"两档全失败"时**响亮报一次**。
12098
- */
12099
- function hideAllFor(agent) {
12100
- const resident = REGISTERED_TOOLS.slice(0, REGISTERED_TOOLS.length - 3);
12101
- const tiers = [{
12102
- label: "全表(含条件工具)",
12103
- names: REGISTERED_TOOLS
12104
- }, {
12105
- label: "常驻段(跳过条件工具)",
12106
- names: resident
12107
- }];
12108
- const failed = [];
12109
- for (const tier of tiers) try {
12110
- return {
12111
- dispose: agent.ctx.tools.restrict({ deny: [...tier.names] }),
12112
- names: tier.names
12113
- };
12114
- } catch (e) {
12115
- failed.push(`${tier.label}: ${e.message}`);
12116
- }
12117
- return { failed };
12118
- }
12119
- /**
12120
- * 同步「本会话在不在 CCC 内」的工具可见性(v1.56.6)。
12121
- *
12122
- * @param agent 目标会话的 agent
12123
- * @param root 该会话的 CCC 根;`null` ⇒ **不在任何 CCC 内** ⇒ 隐藏全部 ACC 工具
12124
- *
12125
- * 幂等:同一会话重复调用不会重复 restrict(已是目标态即直接返回)。
12126
- * 失败不抛(最坏情况:工具可见,但**调用时**仍被各工具自己的根解析拒绝——既有兜底不变)。
12127
- */
12128
- function syncCccToolVisibility(agent, root) {
12129
- const sessionId = agent.session.id ?? "global";
12130
- const existing = nonCccRestrictions.get(sessionId);
12131
- const shouldHide = root === null;
12132
- if (shouldHide && !existing) {
12133
- const hidden = hideAllFor(agent);
12134
- if ("failed" in hidden) {
12135
- console.error(`[serenity-hooks] 非 CCC 工具隐藏**两档全失败**(session=${sessionId}) —— 工具仍可见(但有各工具自身的 !root 兜底):${hidden.failed.join(" / ")}`);
12136
- return;
12137
- }
12138
- nonCccRestrictions.set(sessionId, hidden.dispose);
12139
- return;
12140
- }
12141
- if (!shouldHide && existing) {
12142
- try {
12143
- existing();
12144
- } catch (e) {
12145
- console.error(`[serenity-hooks] 非 CCC 工具恢复失败 (session=${sessionId}):`, e.message);
12146
- }
12147
- nonCccRestrictions.delete(sessionId);
12148
- }
12149
- }
12150
12306
  /** 会话销毁清理(lifecycle 调用;防 per-会话 Map 无界增长) */
12151
12307
  function forgetNonCccToolVisibility(sessionId) {
12152
12308
  const existing = nonCccRestrictions.get(sessionId);
@@ -13352,11 +13508,65 @@ function accMessage(root, configPaths, entrySkillMaxChars) {
13352
13508
  source: PLUGIN_SOURCE
13353
13509
  });
13354
13510
  }
13355
- /** 每 agent 是否已注入过(进程内存态) */
13511
+ /**
13512
+ * 每 agent 是否已注入过 —— **只作为本进程内的快路径**,⛔ 不是判据。
13513
+ *
13514
+ * 🔴🔴 2026-10-22(owner 报「send-now 创建的会话消息注入两次」的取证结论):
13515
+ * 此前**它就是判据**,于是 `pre-step` 那道门拿它回答"**这条会话**注入过没有"——
13516
+ * 而它实际回答的是"**本进程**注入过没有"。进程一换(`restart-web` / 看门狗拉起 /
13517
+ * 崩溃重启)它就清空 ⇒ 老会话被冷载入时 `pre-step` 判"没注入过" ⇒ **再注入一遍**。
13518
+ *
13519
+ * 实测铁证(同一会话两条 `[ACC] … active` 横幅,相隔 18.6 小时):
13520
+ * 只有**跨过重启**的会话有两条,没跨的只有一条 ⇒ 重复的量 = 跨了几个进程。
13521
+ *
13522
+ * ⇒ 判据改为 {@link hasAccBanner}(读**会话历史**)。本 Set 降级为"同进程免重复扫"的优化:
13523
+ * 命中它就直接返回(省一次扫),⛔ 它为空**不代表**没注入过。
13524
+ */
13356
13525
  const injected = /* @__PURE__ */ new Set();
13357
13526
  function agentKey(agent) {
13358
13527
  return agent.session.id ?? "global";
13359
13528
  }
13529
+ /**
13530
+ * ACC 身份横幅在**会话历史**里的识别特征(结构判据,不依赖措辞顺序)。
13531
+ *
13532
+ * 🔴 为什么认 `source.kind` + 正文前缀两件:
13533
+ * - 只认正文 ⇒ 将来文案一改就静默失效(D151 同族:判据不能长在措辞上);
13534
+ * - 只认 `source.kind` ⇒ 本插件注入的**所有**消息都算(`skill-catalog` 块、假首轮横幅…)
13535
+ * ⇒ 会把"别的注入出现过"误当成"身份横幅出现过",导致**永不再注入**。
13536
+ * ⇒ 两者与:由本插件发出 **且** 正文以 ACC 横幅首词开头。
13537
+ */
13538
+ const ACC_BANNER_PREFIX = "[ACC] Serenity cognitive container active";
13539
+ /**
13540
+ * 这条会话的历史里**是否已经有** ACC 身份横幅(= 判据的唯一权威)。
13541
+ *
13542
+ * 🔴 这是本条修复的核心:**"注入过没有"是会话的属性,不是进程的属性。**
13543
+ * 会话历史随会话走(落盘、重启后仍在),进程内状态随进程死。
13544
+ *
13545
+ * ⚠️ 读不到 events(受限宿主 / 投影未就绪)⇒ 返回 `false`(退回"注入一次")。
13546
+ * 这是**偏保守**的选择:多注入一条横幅(可见、可清理)远好于
13547
+ * **永远不再注入**(陈旧会话里 ACC 身份彻底缺失,且零信号)。
13548
+ *
13549
+ * @param session 目标会话(`sessionEvents` 能读的形状)
13550
+ */
13551
+ function hasAccBanner(session) {
13552
+ return sessionEvents(session).some((event) => {
13553
+ const e = event;
13554
+ if (e.type !== "user/message") return false;
13555
+ if (e.data?.source?.kind !== PLUGIN_SOURCE.kind) return false;
13556
+ return textOfUserMessage(e.data)?.trimStart().startsWith(ACC_BANNER_PREFIX) === true;
13557
+ });
13558
+ }
13559
+ /** 从 `user/message` 的 content 块数组里拼出纯文本(无 text 块 ⇒ undefined) */
13560
+ function textOfUserMessage(data) {
13561
+ const content = data?.content;
13562
+ if (!Array.isArray(content)) return void 0;
13563
+ const parts = [];
13564
+ for (const block of content) {
13565
+ const b = block;
13566
+ if (b?.type === "text" && typeof b.text === "string") parts.push(b.text);
13567
+ }
13568
+ return parts.length === 0 ? void 0 : parts.join("");
13569
+ }
13360
13570
  function agentScope(agent) {
13361
13571
  return agent.session.id ?? "default";
13362
13572
  }
@@ -13394,7 +13604,7 @@ function registerContext(ctx, opts = {}) {
13394
13604
  const cwd = agent.session?.header?.cwd ?? process.cwd();
13395
13605
  const root = cccRootForCwd(cwd);
13396
13606
  try {
13397
- syncCccToolVisibility(agent, root);
13607
+ syncToolVisibility(agent, root);
13398
13608
  } catch {}
13399
13609
  if (!root) return;
13400
13610
  const key = agentKey(agent);
@@ -13456,18 +13666,14 @@ function registerContext(ctx, opts = {}) {
13456
13666
  }
13457
13667
  }
13458
13668
  } catch {}
13459
- if (injected.has(key)) return;
13669
+ if (injected.has(key) || hasAccBanner(agent.session)) {
13670
+ injected.add(key);
13671
+ return;
13672
+ }
13460
13673
  injected.add(key);
13461
13674
  agent.inject(accMessage(root, configPaths, entrySkillMaxChars));
13462
- syncSafeModeRestriction(agent, root);
13463
- try {
13464
- syncImBridgeVisibility(agent, root);
13465
- } catch {}
13466
- try {
13467
- syncExclusiveToolsVisibility(agent, root);
13468
- } catch {}
13469
13675
  try {
13470
- syncHostScheduleToolHiding(agent, root);
13676
+ syncToolVisibility(agent, root);
13471
13677
  } catch {}
13472
13678
  try {
13473
13679
  syncTrajectoryModelForSession(agent, root);
@@ -13484,35 +13690,22 @@ function registerContext(ctx, opts = {}) {
13484
13690
  const root = cccRootForCwd(cwd);
13485
13691
  const key = agentKey(agent);
13486
13692
  const downstream = await next();
13487
- if (root) {
13488
- try {
13489
- syncTrajectoryConstraintToolVisibility(agent, root);
13490
- } catch {}
13491
- try {
13492
- syncTrajectoryModelForSession(agent, root);
13493
- } catch {}
13494
- }
13495
- if (root && hasTrajectoryOwnCharter(root, key)) return downstream;
13496
13693
  try {
13497
- syncCccToolVisibility(agent, root);
13694
+ syncToolVisibility(agent, root);
13498
13695
  } catch {}
13696
+ if (root) try {
13697
+ syncTrajectoryModelForSession(agent, root);
13698
+ } catch {}
13699
+ if (root && hasTrajectoryOwnCharter(root, key)) return downstream;
13499
13700
  if (root) {
13500
- try {
13501
- syncSafeModeRestriction(agent, root);
13502
- } catch {}
13503
- try {
13504
- syncImBridgeVisibility(agent, root);
13505
- } catch {}
13506
- try {
13507
- syncExclusiveToolsVisibility(agent, root);
13508
- } catch {}
13509
- try {
13510
- syncHostScheduleToolHiding(agent, root);
13511
- } catch {}
13512
13701
  registerEntrySkillSection(agent, root);
13513
13702
  registerCognitiveTrajectorySkillSection(agent, root);
13514
13703
  }
13515
- if (!root || injected.has(key) || downstream.kind !== "enter") return downstream;
13704
+ if (!root || downstream.kind !== "enter") return downstream;
13705
+ if (injected.has(key) || hasAccBanner(agent.session)) {
13706
+ injected.add(key);
13707
+ return downstream;
13708
+ }
13516
13709
  injected.add(key);
13517
13710
  return {
13518
13711
  kind: "enter",
@@ -14101,11 +14294,11 @@ function registerStatusApi(ctx, opts = {}) {
14101
14294
  sendJson$2(res, 400, { error: chk.error });
14102
14295
  return;
14103
14296
  }
14104
- const { readWeixinSettings } = await import("./route-Dr0SFD8A.js").then((n) => n.c);
14297
+ const { readWeixinSettings } = await import("./route-3OzOAjec.js").then((n) => n.l);
14105
14298
  const { weixinBridgeStatus } = await Promise.resolve().then(() => bridge_exports);
14106
14299
  const settings = readWeixinSettings(chk.root);
14107
14300
  const bridge = weixinBridgeStatus().find((b) => b.ccc === chk.root);
14108
- const { imCredentialBound } = await import("./route-Dr0SFD8A.js").then((n) => n.c);
14301
+ const { imCredentialBound } = await import("./route-3OzOAjec.js").then((n) => n.l);
14109
14302
  const { normalizeAccountChannel } = await import("./accounts-BHQbakUh.js").then((n) => n.t);
14110
14303
  const accounts = (settings.accounts ?? []).map((a) => {
14111
14304
  const channel = normalizeAccountChannel(a.channel);
@@ -14142,7 +14335,7 @@ function registerStatusApi(ctx, opts = {}) {
14142
14335
  const { fetchQRCode } = await import("./ilink-BMk2w2KY.js").then((n) => n.a);
14143
14336
  const { randomUUID } = await import("node:crypto");
14144
14337
  for (const [key, v] of weixinLogins) if (Date.now() - v.startedAt > WEIXIN_LOGIN_TTL_MS) weixinLogins.delete(key);
14145
- const { readWeixinSettings } = await import("./route-Dr0SFD8A.js").then((n) => n.c);
14338
+ const { readWeixinSettings } = await import("./route-3OzOAjec.js").then((n) => n.l);
14146
14339
  const qr = await fetchQRCode({ botType: readWeixinSettings(root).botType ?? void 0 });
14147
14340
  const loginKey = randomUUID();
14148
14341
  weixinLogins.set(loginKey, {
@@ -14163,7 +14356,7 @@ function registerStatusApi(ctx, opts = {}) {
14163
14356
  sendJson$2(res, 400, { error: "missing accountId" });
14164
14357
  return;
14165
14358
  }
14166
- const { removeWeixinAccount } = await import("./route-Dr0SFD8A.js").then((n) => n.c);
14359
+ const { removeWeixinAccount } = await import("./route-3OzOAjec.js").then((n) => n.l);
14167
14360
  const { syncCccBridge } = await Promise.resolve().then(() => bridge_exports);
14168
14361
  removeWeixinAccount(root, body.accountId);
14169
14362
  syncCccBridge(ctx, root);
@@ -14189,8 +14382,8 @@ function registerStatusApi(ctx, opts = {}) {
14189
14382
  trajectory: code
14190
14383
  });
14191
14384
  }
14192
- const { saveWeixinRoutes } = await import("./route-Dr0SFD8A.js").then((n) => n.c);
14193
- const { findDirNameByCode } = await import("./profile-DnLSb7yv.js").then((n) => n.i);
14385
+ const { saveWeixinRoutes } = await import("./route-3OzOAjec.js").then((n) => n.l);
14386
+ const { findDirNameByCode } = await import("./profile-vHheCJiU.js").then((n) => n.a);
14194
14387
  for (const r of routes) if (findDirNameByCode(root, r.trajectory) === null) {
14195
14388
  sendJson$2(res, 400, { error: `unknown trajectory: ${r.trajectory} (no such trajectory code in ${root})` });
14196
14389
  return;
@@ -14201,7 +14394,7 @@ function registerStatusApi(ctx, opts = {}) {
14201
14394
  }
14202
14395
  if (body.action === "set-enabled") {
14203
14396
  const enabled = body.enabled === true;
14204
- const { readWeixinSettings, setWeixinEnabled } = await import("./route-Dr0SFD8A.js").then((n) => n.c);
14397
+ const { readWeixinSettings, setWeixinEnabled } = await import("./route-3OzOAjec.js").then((n) => n.l);
14205
14398
  const { syncCccBridge } = await Promise.resolve().then(() => bridge_exports);
14206
14399
  const settings = readWeixinSettings(root);
14207
14400
  if (enabled && (settings.accounts ?? []).length === 0) {
@@ -14224,7 +14417,7 @@ function registerStatusApi(ctx, opts = {}) {
14224
14417
  sendJson$2(res, 400, { error: "appId 与 appSecret 都必须填(只填一个等于没绑)" });
14225
14418
  return;
14226
14419
  }
14227
- const { readWeixinSettings, upsertWeixinAccount, writeFeishuCredential, nextFeishuAccountId } = await import("./route-Dr0SFD8A.js").then((n) => n.c);
14420
+ const { readWeixinSettings, upsertWeixinAccount, writeFeishuCredential, nextFeishuAccountId } = await import("./route-3OzOAjec.js").then((n) => n.l);
14228
14421
  const { syncCccBridge } = await Promise.resolve().then(() => bridge_exports);
14229
14422
  const before = readWeixinSettings(root);
14230
14423
  const accountId = (body.accountId ?? "").trim() !== "" ? body.accountId.trim() : nextFeishuAccountId(before);
@@ -14274,7 +14467,7 @@ function registerStatusApi(ctx, opts = {}) {
14274
14467
  return;
14275
14468
  }
14276
14469
  const { pollQRStatus } = await import("./ilink-BMk2w2KY.js").then((n) => n.a);
14277
- const { readWeixinSettings } = await import("./route-Dr0SFD8A.js").then((n) => n.c);
14470
+ const { readWeixinSettings } = await import("./route-3OzOAjec.js").then((n) => n.l);
14278
14471
  const settings = readWeixinSettings(login.root);
14279
14472
  const status = await pollQRStatus({
14280
14473
  baseUrl: void 0,
@@ -14289,7 +14482,7 @@ function registerStatusApi(ctx, opts = {}) {
14289
14482
  });
14290
14483
  return;
14291
14484
  }
14292
- const { upsertWeixinAccount, writeWeixinCredential, nextWeixinAccountId } = await import("./route-Dr0SFD8A.js").then((n) => n.c);
14485
+ const { upsertWeixinAccount, writeWeixinCredential, nextWeixinAccountId } = await import("./route-3OzOAjec.js").then((n) => n.l);
14293
14486
  const { syncCccBridge } = await Promise.resolve().then(() => bridge_exports);
14294
14487
  const accountId = nextWeixinAccountId(settings);
14295
14488
  upsertWeixinAccount(login.root, {
@@ -18825,12 +19018,15 @@ function appendHumanChannelAudit(event) {
18825
19018
  const WEIXIN_HOOK_TIMEOUT_MS = 15e3;
18826
19019
  /** hook stderr/stdout 日志截断(防脚本刷屏) */
18827
19020
  const HOOK_LOG_MAX = 500;
19021
+ /** 存量调用点未传 `channel` 时的缺省值(本字段存在之前唯一的渠道) */
19022
+ const DEFAULT_HOOK_CHANNEL = "weixin";
18828
19023
  /** 构造 incoming 事件对象(纯函数,可测) */
18829
19024
  function buildIncomingHookEvent(input) {
18830
19025
  return {
18831
19026
  event: "incoming",
18832
19027
  ts: Date.now(),
18833
19028
  cccRoot: input.cccRoot,
19029
+ channel: input.channel ?? DEFAULT_HOOK_CHANNEL,
18834
19030
  accountId: input.accountId,
18835
19031
  userId: input.userId,
18836
19032
  sessionId: input.sessionId,
@@ -18847,6 +19043,7 @@ function buildOutgoingHookEvent(input) {
18847
19043
  event: "outgoing",
18848
19044
  ts: Date.now(),
18849
19045
  cccRoot: input.cccRoot,
19046
+ channel: input.channel ?? DEFAULT_HOOK_CHANNEL,
18850
19047
  accountId: input.accountId,
18851
19048
  userId: input.userId,
18852
19049
  sessionId: input.sessionId,
@@ -19229,7 +19426,7 @@ const MEDIA_MAX_BYTES = 20971520;
19229
19426
  */
19230
19427
  async function resolveCccRootByAlias(ctx, alias) {
19231
19428
  try {
19232
- const { listCccs } = await import("./ccc-roots-VCJ3aYJA.js").then((n) => n.a);
19429
+ const { listCccs } = await import("./ccc-roots-BI_4UVcj.js").then((n) => n.a);
19233
19430
  const entries = await listCccs(ctx);
19234
19431
  const byName = entries.find((e) => e.name === alias);
19235
19432
  if (byName) return byName.root;
@@ -19297,7 +19494,7 @@ async function resolveInboundTarget(ctx, input) {
19297
19494
  });
19298
19495
  return null;
19299
19496
  }
19300
- const { findDirNameByCode } = await import("./profile-DnLSb7yv.js").then((n) => n.i);
19497
+ const { findDirNameByCode } = await import("./profile-vHheCJiU.js").then((n) => n.a);
19301
19498
  if (findDirNameByCode(root, target.role) === null) {
19302
19499
  const detail = `目标 CCC "${target.ccc}" 内没有编码为 "${target.role}" 的认知轨迹`;
19303
19500
  console.error(`[serenity-hooks] ✗ human-channel: ${detail}(account=${input.account} user=${input.user})—— 该消息**未投递**,请改订阅表里的目标编码`);
@@ -19526,6 +19723,7 @@ async function handleInboundEvent(ctx, root, accountId, io, event, preset) {
19526
19723
  const hookRel = settings.hook;
19527
19724
  if (hookRel) invokeWeixinHook(root, hookRel, buildIncomingHookEvent({
19528
19725
  cccRoot: root,
19726
+ channel,
19529
19727
  accountId,
19530
19728
  userId: fromUserId,
19531
19729
  sessionId,
@@ -19552,6 +19750,7 @@ async function handleInboundEvent(ctx, root, accountId, io, event, preset) {
19552
19750
  console.warn(`[serenity-hooks] ⚠ im 输出兜底:agent 本轮未发送(闸门打回用尽)→ 桥转发最终文本(session=${sessionId}, trajectory=${trajectory})`);
19553
19751
  if (hookRel) invokeWeixinHook(root, hookRel, buildOutgoingHookEvent({
19554
19752
  cccRoot: root,
19753
+ channel,
19555
19754
  accountId,
19556
19755
  userId: fromUserId,
19557
19756
  sessionId,
@@ -19572,6 +19771,7 @@ async function handleInboundEvent(ctx, root, accountId, io, event, preset) {
19572
19771
  });
19573
19772
  if (hookRel) invokeWeixinHook(root, hookRel, buildOutgoingHookEvent({
19574
19773
  cccRoot: root,
19774
+ channel,
19575
19775
  accountId,
19576
19776
  userId: fromUserId,
19577
19777
  sessionId,
@@ -20247,7 +20447,7 @@ function syncHumanChannel(ctx) {
20247
20447
  (async () => {
20248
20448
  try {
20249
20449
  stopMachineLoops();
20250
- const { listCccs } = await import("./ccc-roots-VCJ3aYJA.js").then((n) => n.a);
20450
+ const { listCccs } = await import("./ccc-roots-BI_4UVcj.js").then((n) => n.a);
20251
20451
  for (const entry of await listCccs(ctx)) syncCccBridge(ctx, entry.root);
20252
20452
  } catch {}
20253
20453
  })();
@@ -20344,6 +20544,7 @@ async function sendProactiveText(input) {
20344
20544
  }
20345
20545
  if (settings.hook) invokeWeixinHook(root, settings.hook, buildOutgoingHookEvent({
20346
20546
  cccRoot: root,
20547
+ channel: "weixin",
20347
20548
  accountId,
20348
20549
  userId: toUserId,
20349
20550
  sessionId,
@@ -20538,7 +20739,7 @@ function matchCcc(input, candidates) {
20538
20739
  }
20539
20740
  /** 组装候选列表(`ccc-roots.listCccs` 投影 + `.serenity` 名;动态 import 保持本模块静态依赖轻量) */
20540
20741
  async function collectCandidates(ctx) {
20541
- const { listCccs } = await import("./ccc-roots-VCJ3aYJA.js").then((n) => n.a);
20742
+ const { listCccs } = await import("./ccc-roots-BI_4UVcj.js").then((n) => n.a);
20542
20743
  const entries = await listCccs(ctx, { defaultRoot: process.cwd() });
20543
20744
  const seen = /* @__PURE__ */ new Set();
20544
20745
  const out = [];
@@ -20767,20 +20968,11 @@ function cleanupSessionState(sessionId) {
20767
20968
  forgetManualOutputSession(sessionId);
20768
20969
  } catch {}
20769
20970
  try {
20770
- forgetImBridgeVisibility(sessionId);
20771
- } catch {}
20772
- try {
20773
- forgetExclusiveToolsVisibility(sessionId);
20971
+ forgetToolVisibility(sessionId);
20774
20972
  } catch {}
20775
20973
  try {
20776
20974
  forgetNonCccToolVisibility(sessionId);
20777
20975
  } catch {}
20778
- try {
20779
- forgetHostScheduleToolHiding(sessionId);
20780
- } catch {}
20781
- try {
20782
- forgetTrajectoryConstraintToolVisibility(sessionId);
20783
- } catch {}
20784
20976
  try {
20785
20977
  forgetLogbookCompactionState(sessionId);
20786
20978
  } catch {}
@@ -21753,6 +21945,24 @@ function credentialValue$1(root, key) {
21753
21945
  function channelError$1(code, error, remediation) {
21754
21946
  return /* @__PURE__ */ new Error(`${code}: ${error}${remediation ? ` — ${remediation}` : ""}`);
21755
21947
  }
21948
+ /**
21949
+ * 路由目标轨迹**是否存在**(`status` 的自检列)。
21950
+ *
21951
+ * 🔴 复用既有判据 `findDirNameByCode`(它扫活跃区并按编码解析)—— ⛔ 不在这里另写一套目录扫描,
21952
+ * 两份判据会漂(一处认归档、另一处不认,就会出现"这里说有、投递时说没有")。
21953
+ *
21954
+ * ⚠️ 解析失败(`findDirNameByCode` 在**重码**时会抛)⇒ 返回 `false` 但**不吞掉原因**:
21955
+ * 重码本身也是"这个路由不可信"的一种形态,`status` 里会表现为 `targetExists:false`,
21956
+ * 配合 `cognitive_trajectory list` 可定位。不额外造一个错误码(键名已经够直白)。
21957
+ */
21958
+ function trajectoryDirExists(root, code) {
21959
+ if (typeof code !== "string" || code.trim() === "") return false;
21960
+ try {
21961
+ return findDirNameByCode(root, code.trim()) !== null;
21962
+ } catch {
21963
+ return false;
21964
+ }
21965
+ }
21756
21966
  const weixinChannel = {
21757
21967
  id: "weixin",
21758
21968
  isEnabled(root) {
@@ -21831,6 +22041,7 @@ const weixinChannel = {
21831
22041
  const trajectory = matchWeixinRoute(settings.routes ?? [], input.userId) ?? "";
21832
22042
  invokeWeixinHook(input.root, settings.hook, buildOutgoingHookEvent({
21833
22043
  cccRoot: input.root,
22044
+ channel: "weixin",
21834
22045
  accountId: account.accountId,
21835
22046
  userId: input.userId,
21836
22047
  sessionId: weixinSessionIdForTrajectory(trajectory !== "" ? trajectory : sanitizeFileName$1(input.userId) || "unknown"),
@@ -21862,10 +22073,14 @@ const weixinChannel = {
21862
22073
  lastPollAt: account.lastPollAt,
21863
22074
  ...account.lastError ? { lastError: account.lastError } : {}
21864
22075
  })));
21865
- const routes = (settings.routes ?? []).map((route) => ({
21866
- user: route.user,
21867
- role: route.trajectory
21868
- }));
22076
+ const routes = (settings.routes ?? []).map((route) => {
22077
+ const code = route.trajectory;
22078
+ return {
22079
+ user: route.user,
22080
+ role: code,
22081
+ targetExists: trajectoryDirExists(root, code)
22082
+ };
22083
+ });
21869
22084
  return {
21870
22085
  enabled: settings.enabled === true,
21871
22086
  autoReplyWithLastMessage: settings.autoReplyWithLastMessage !== false,
@@ -21906,12 +22121,17 @@ const weixinChannel = {
21906
22121
  * ⇒ 校验**值的形状**(飞书 open_id 以 `ou_` 开头):错配在**解析处**就失败,
21907
22122
  * 错误码是 `ALIAS_UNKNOWN` + 可行动提示。
21908
22123
  *
21909
- * ## 🔴 为什么不 fire 记录 hook
22124
+ * ## 🔴 记录 hook:曾经不 fire,2026-10-22 起 fire(A83 已裁)
21910
22125
  *
21911
- * `settings.hook` 的契约与消费方都是**微信的**:CCC 的 `scripts/weixin-message-hook.ts`
21912
- * 把事件追加进 `AGENT_SESSIONS/_weixin-logs/<日期>.jsonl`。飞书事件若流进去,
21913
- * 那个目录就**同时装两种渠道的数据而名字只说一种**(D151/D179 同族:名字开始撒谎)。
21914
- * ⇒ 本期**不落记录**,并把它登记为待裁(A83)。工具描述里按渠道写清这件事。
22126
+ * **当时的理由**:`settings.hook` 的契约与消费方都是**微信的**,CCC 的
22127
+ * `scripts/weixin-message-hook.ts` 把事件追加进 `AGENT_SESSIONS/_weixin-logs/<日期>.jsonl`。
22128
+ * 飞书事件若流进去,那个目录就**同时装两种渠道的数据而名字只说一种**(D151/D179 同族)。
22129
+ *
22130
+ * **为什么现在可以 fire**:同批给 hook 事件加了 **`channel` 字段**(owner 2026-10-22:
22131
+ * 「一个 CCC 绑定多个账户很常见,聊天记录落盘要完善」)⇒ CCC 侧 hook 按渠道分流到
22132
+ * `_im-logs/<channel>/`,**目录名不再需要撒谎**。
22133
+ * ⇒ 所以这不是"决定记录",而是"**让记录不再有害**之后才记录"。
22134
+ * ⚠️ 顺序有意义:**先有渠道字段,再有记录**。反过来做就会先造出一批分不清渠道的数据。
21915
22135
  */
21916
22136
  /**
21917
22137
  * 飞书 open_id 的形状前缀(官方形态:`ou_` + 一串)。
@@ -22066,6 +22286,18 @@ const feishuChannel = {
22066
22286
  const settings = readWeixinSettings(input.root);
22067
22287
  const trajectory = matchWeixinRoute(settings.routes ?? [], input.userId) ?? "";
22068
22288
  const sessionId = weixinSessionIdForTrajectory(trajectory !== "" ? trajectory : sanitizeFileName$1(input.userId) || "unknown");
22289
+ if (settings.hook) invokeWeixinHook(input.root, settings.hook, buildOutgoingHookEvent({
22290
+ cccRoot: input.root,
22291
+ channel: "feishu",
22292
+ accountId: account.accountId,
22293
+ userId: input.userId,
22294
+ sessionId,
22295
+ role: trajectory,
22296
+ reply: markdownToPlainText(input.text),
22297
+ source: "proactive"
22298
+ })).catch((err) => {
22299
+ console.log(`[serenity-hooks] im hook outgoing(feishu) error: ${err instanceof Error ? err.message : String(err)}`);
22300
+ });
22069
22301
  return {
22070
22302
  accountId: account.accountId,
22071
22303
  userId: input.userId,
@@ -22119,7 +22351,8 @@ const ACTIONS = [
22119
22351
  "send",
22120
22352
  "send-file",
22121
22353
  "users",
22122
- "status"
22354
+ "status",
22355
+ "contacts"
22123
22356
  ];
22124
22357
  function renderText(value) {
22125
22358
  return [{
@@ -22134,7 +22367,7 @@ function renderText(value) {
22134
22367
  function createImBridgeTool() {
22135
22368
  return defineTool({
22136
22369
  name: "im-bridge",
22137
- description: `Send messages to family members through an IM channel configured for THIS container. This tool only acts on the current container (no target container parameter). It is hidden entirely when this container has no IM channel configured. Channels: ${imChannelIds().join(", ") || "(none registered)"}. Actions: send (text) / send-file (a file inside this container) / users (list configured recipients) / status (channel health). Not every channel supports every action (e.g. send-file may return ACTION_UNSUPPORTED). user accepts a configured alias (e.g. user-a, user-b) or a raw channel user id. Failures return a stable code plus a remediation hint.`,
22370
+ description: `Send messages to family members through an IM channel configured for THIS container. This tool only acts on the current container (no target container parameter). It is hidden entirely when this container has no IM channel configured. Channels: ${imChannelIds().join(", ") || "(none registered)"}. Actions: send (text) / send-file (a file inside this container) / users (configured aliases) / status (channel health, accounts + current routes) / contacts (people this container has exchanged messages with — use this to find the \`user\` id when adding a route, since that id exists only in the chat logs). Not every channel supports every action (e.g. send-file may return ACTION_UNSUPPORTED). user accepts a configured alias or a raw channel user id. Docs: container_admin config docs im-bridge (how to add a route, where the other party's id comes from). Failures return a stable code plus a remediation hint.`,
22138
22371
  parameters: {
22139
22372
  channel: {
22140
22373
  type: "string",
@@ -22145,7 +22378,7 @@ function createImBridgeTool() {
22145
22378
  type: "string",
22146
22379
  enum: [...ACTIONS],
22147
22380
  required: true,
22148
- description: "send / send-file / users / status"
22381
+ description: "send / send-file / users / status / contacts"
22149
22382
  },
22150
22383
  user: {
22151
22384
  type: "string",