@shgroup/dsh-serenity-hooks 1.50.0 → 1.50.1

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/dsh.plugin.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "id": "dsh-serenity-hooks",
3
- "version": "1.50.0",
3
+ "version": "1.50.1",
4
4
  "main": "lib/index.js",
5
- "description": "宁静号 ACC harness(Native Cordis 插件):给 DSH 装一个「AI 工作区」——11 个工具(container_fs/container_trajectory/dashboard/container_git/msm/praxis/handyman/localstore/container_admin/im-bridge/acc-diag;后两个按配置条件出现)+ 机械约束(安全模式/工作区围墙/密钥守卫)+ 轨迹日志与原地重建 + 网页登录入口/微信桥/子角色/对外问答页/trajectory 唤醒注册表。适配 DSH 0.1.5-rc.2(deepseek-ai/deepseek-harness)。",
5
+ "description": "宁静号 ACC harness(Native Cordis 插件):给 DSH 装一个「AI 工作区」——11 个工具(container_fs/container_trajectory/dashboard/container_git/msm/praxis/handyman/localstore/container_admin/im-bridge/acc-diag;后两个按配置条件出现)+ 机械约束(安全模式/工作区围墙/密钥守卫)+ 轨迹日志与原地重建 + 网页登录入口/微信桥/子角色/对外问答页/trajectory 唤醒注册表。适配 DSH 0.1.7-rc.1(deepseek-ai/deepseek-harness)。",
6
6
  "engines": {
7
- "dsh": ">=0.1.5-rc.2"
7
+ "dsh": ">=0.1.7-rc.1"
8
8
  },
9
9
  "contributes": {
10
10
  "tools": [
@@ -61,8 +61,40 @@ type HostEventName = (typeof HOST_EVENT_NAMES)[number];
61
61
  * dsp 订阅的宿主事件清单(与 HOST_EVENT_NAMES 同源;impact/site 供 health 报告与文档)。
62
62
  */
63
63
  export declare const HOST_EVENTS: readonly HostEventContract[];
64
- /** dsp 被验证过的宿主版本范围(与 package.json peerDependencies 同源,单一真相源) */
65
- export declare const REQUIRED_HOST_RANGE = "^0.1.7-rc.1";
64
+ /**
65
+ * dsp **被验证过的宿主版本档**(单一真相源)—— 每档 = 一个半开区间 `[floor, ceiling)`。
66
+ *
67
+ * 🔴 为什么是"档"而不是一条 caret 范围(R↓:2026-09-28 0.2.0-rc.1 适配轮实测得出):
68
+ * - **宿主门的判据是真 semver**(`@deepseek-ai/dsh-app-boot:evaluatePluginCompatibility`
69
+ * = `semver.satisfies(runtime, range, { includePrerelease: true })`)⇒ **它支持 `||` 联合范围**;
70
+ * - 而 **dsp 这边是手写比较器**({@link compareSemver}),**不实现 semver 的 range 语义**:
71
+ * 旧写法"用正则从 `REQUIRED_HOST_RANGE` 剥前缀取 floor"在**联合范围上会取到垃圾**
72
+ * (`^0.1.7-rc.1 || ^0.2.0-rc.1` 的预发布捕获组 `(.*)` 会**吞掉 `||` 之后整段**)
73
+ * ⇒ 判定**变瞎**(既非"通过"也非"低于下限",而是不可解释的值)。
74
+ * ⇒ 故**档表是唯一被验证的真相**:`REQUIRED_HOST_RANGE`(给宿主门看的串)与
75
+ * `checkHostVersion`(给自己的手写比较器)**都从它派生**,两者不可能互相漂移。
76
+ *
77
+ * 🔴 两档的由来(**实测,非推断**;判据表见插件仓 `docs/dsh-0.2.0-rc.1-adaptation-plan.md` §7.4):
78
+ * - `^0.1.7-rc.1` 在 `0.2.0-rc.1` 上被宿主**整包拒绝 + 回滚**
79
+ * (逐字 `installation rejected: … incompatible with dsh 0.2.0-rc.1` + `restored package.json, …`);
80
+ * - 单档 `^0.2.0-rc.1` 会**同样硬拒本机正在跑的 `0.1.7-rc.2`**
81
+ * ⇒ 发布后 deploy 必须先升宿主,**而升级会打断本容器**;
82
+ * - ⇒ 双档是**唯一不把本机宿主堵死的写法**(两档在真 semver 下均 PASS)。
83
+ * 档上界取 `<0.2.0` / `<0.3.0`(即 `^0.2.0-rc.1` 的 caret 上界):第二档同时覆盖
84
+ * `0.2.0` 正式版与后续 `0.2.0-rc.N`。
85
+ */
86
+ export declare const VERIFIED_HOST_BANDS: readonly {
87
+ floor: string;
88
+ ceiling: string;
89
+ }[];
90
+ /**
91
+ * 与 `package.json` 的 `peerDependencies` **逐字同串**的宿主版本范围(由 {@link VERIFIED_HOST_BANDS} 派生)。
92
+ *
93
+ * 🔴 这个字符串**就是**宿主门(`evaluatePluginCompatibility`)拿去跑 `semver.satisfies` 的那个串
94
+ * ⇒ 它与档表**必须一致**("声明 union、判定单档"是一种自相矛盾)。
95
+ * 一致性由 `tests/host-contract.test.ts` 的**跨文件对账用例**钉住(读 package.json 逐字比对)。
96
+ */
97
+ export declare const REQUIRED_HOST_RANGE: string;
66
98
  interface HostContractIssue {
67
99
  id: string;
68
100
  kind: 'service' | 'member' | 'version';
@@ -80,13 +112,14 @@ export interface HostContractReport {
80
112
  /** 最小 semver 比较(无依赖):返回 -1 / 0 / 1;解析失败返回 null */
81
113
  export declare function compareSemver(a: string, b: string): number | null;
82
114
  /**
83
- * 宿主版本是否落在 dsp 被验证的范围(`REQUIRED_HOST_RANGE`)。
115
+ * 宿主版本是否落在 dsp **被验证过的档**({@link VERIFIED_HOST_BANDS})之内。
84
116
  * 无版本信息 → 视为未知(ok=false,detail 说明),不猜测。
85
117
  *
86
- * floor/ceiling **从 `REQUIRED_HOST_RANGE` 派生**(v1.31.6,0.1.5-rc.1 适配轮):
87
- * 此前 floor 是独立硬编码字面量——改 `REQUIRED_HOST_RANGE` 而忘改 floor 会造出
88
- * "常量说 0.1.5、判定用 0.1.2" 的双真相源。ceiling 无法从 caret 语义机械派生(手写比较器
89
- * 不实现 semver 的 caret 规则),故保留显式常量并在此声明其含义。
118
+ * 🔴 **逐档判**(v1.50.x,0.2.0-rc.1 适配轮):判据 = "∃ 档:`version >= floor ∧ version < ceiling`"。
119
+ * 曾经的做法是"从 `REQUIRED_HOST_RANGE` 剥前缀取一个 floor + 一个显式 ceiling"——
120
+ * **单档假设一旦不成立(改联合范围),那个正则就会取到垃圾**(详见 {@link VERIFIED_HOST_BANDS}
121
+ * 的注释)⇒ 故本函数**只读档表**,不再从范围串反推。
122
+ * ⚠️ 手写比较器**不实现 semver 的 range 语义**(本条只做"区间内/外"),这也是必须逐档判的原因。
90
123
  */
91
124
  export declare function checkHostVersion(version: string | null): {
92
125
  ok: boolean;
@@ -18,9 +18,15 @@
18
18
  * `ccc:<root>|<accountId>`(那时身份里带了 root ⇒ 跨 CCC 不可能撞车,也就不会误判成"同一账号")。
19
19
  *
20
20
  * ## 边界
21
- * 本模块**只做归属判定**(谁能起、谁被谁占着),**不碰**:轮询循环、凭据解析、路由投递。
22
- * 那些留在 `weixin-bridge.ts`(渠道实现)—— 归属是机制,实现是渠道。
21
+ * 本模块做三件事(**都属机制**,与"哪个渠道"无关):
22
+ * ① **归属判定**(P0-2:谁能起 poller、谁被谁占着)—— 见下 §归属判定
23
+ * ② **订阅表查询 + 入站路由决策**(P0-3:`(账号, 用户) → (CCC, 角色)`)—— 见 §订阅表
24
+ * ③ **机器级留痕**(P0-3:未投递 / 目标不存在 / 表残缺)—— 见 §留痕。
25
+ * 🔴 为什么这些必须机器级:它们**没有目标 CCC** ⇒ 落进任何 CCC 都是"编一个"目标。
26
+ * **不碰**:轮询循环、凭据解析、会话投递 —— 那些是**渠道实现**,留在 `weixin-bridge.ts`。
27
+ * ⇒ 本模块**零 DSH 依赖**(只有 `node:crypto` / `node:fs`),可独立单测。
23
28
  */
29
+ import type { HumanChannelSubscriptionSettings } from './settings-section.js';
24
30
  /** 一个"想为该账号起 poller"的请求(来自某 CCC) */
25
31
  export interface PollerRequest {
26
32
  /** 发起方 CCC 根(绝对路径) */
@@ -87,3 +93,126 @@ export declare function releasePollersOf(root: string): number;
87
93
  export declare function releaseAllPollers(): void;
88
94
  /** 测试专用:复位进程级归属表(生产零调用) */
89
95
  export declare function __resetPollersForTest(): void;
96
+ /** 订阅表里**完好**的一行(四个字段全部非空) */
97
+ export interface SubscriptionHit {
98
+ account: string;
99
+ user: string;
100
+ ccc: string;
101
+ role: string;
102
+ }
103
+ /**
104
+ * 订阅表里的**残缺行**(缺字段)。
105
+ * 🔴 它**不是**"未命中"(见上);且**与是否命中无关** —— 表里只要有残缺行就要出声,
106
+ * 否则"命中了一条完好的"会让人以为整张表是干净的。
107
+ */
108
+ export interface SubscriptionDefect {
109
+ /** 行序(0 基)—— 报错要指得出是**哪一行** */
110
+ index: number;
111
+ /** 缺哪些字段(`account` / `user` / `ccc` / `role`) */
112
+ missing: string[];
113
+ /** 行里**已有**的字段(残缺行的可见部分:让人认得出是哪一行) */
114
+ present: Record<string, string>;
115
+ }
116
+ export interface SubscriptionLookup {
117
+ /** 命中的行(未命中 ⇒ `null`) */
118
+ hit: SubscriptionHit | null;
119
+ /** 表里残缺的行(**与是否命中无关**,一律要出声) */
120
+ defects: SubscriptionDefect[];
121
+ /** 表里**完好**的行数(供调用方区分"表是空的"与"表里没有你") */
122
+ usable: number;
123
+ }
124
+ /**
125
+ * 订阅表查询(**纯函数**)。
126
+ *
127
+ * 匹配两段式(**刻意对齐**旧 `matchWeixinRoute` 的 exact → `*` 兜底):
128
+ * ① `account` **精确** ∧ `user` **精确**
129
+ * ② `account` **精确** ∧ `user === '*'`(通配兜底)
130
+ * 🔴 `account` **不设通配**:它是机器级账号的**显式身份**(表里写的就是账号 id);
131
+ * 给账号也开 `*` 会让"这条订阅到底覆盖哪个 bot"变得不可读。
132
+ * 🔴 `user` 保留 `*`:这是**有意的**——存量 CCC 级 `routes` 用的就是 `{user:'*'}`,
133
+ * 迁移(P0-4)要能**逐字平移**它,否则"迁移"会悄悄改变行为。
134
+ *
135
+ * @param subscriptions 机器级订阅表(`readSimpleSettings().humanChannel.subscriptions`)
136
+ * @param account 收到的**账号 id**(poller 身份;不是 token)
137
+ * @param user 发信人 id
138
+ */
139
+ export declare function lookupSubscription(subscriptions: readonly HumanChannelSubscriptionSettings[], account: string, user: string): SubscriptionLookup;
140
+ /** 入站投递目标**从哪来**(判据用;也是"这条消息为什么被投到这里"的可重建依据) */
141
+ export type InboundRouteSource = 'machine' | 'ccc-fallback';
142
+ /** 未投递的原因(两档,措辞刻意不同 —— 见 `planInboundRoute`) */
143
+ export type InboundSkipReason = 'no-subscription' | 'no-match';
144
+ export interface InboundRoutePlan {
145
+ /** 投递目标(`null` ⇒ **不投递**) */
146
+ target: {
147
+ ccc: string;
148
+ role: string;
149
+ } | null;
150
+ /** 目标出处(`null` ⇔ `target === null`) */
151
+ source: InboundRouteSource | null;
152
+ /** 未投递的原因(`target !== null` ⇒ `null`) */
153
+ skipReason: InboundSkipReason | null;
154
+ /** 订阅表里残缺的行(**与是否命中无关**,一律要出声) */
155
+ defects: SubscriptionDefect[];
156
+ }
157
+ /**
158
+ * 入站路由决策(**纯函数**)。
159
+ *
160
+ * 顺序:**机器级订阅表优先** → 未命中时用 CCC 级回退。
161
+ *
162
+ * 🔴 **回退是过渡期的"不打断在用的微信"**(方案 §11.3 **Q5**):机器级账号表为空时,
163
+ * 桥仍按 CCC 起、路由也仍认 CCC 级 `routes`。机器级一旦配起来,回退传 `null`
164
+ * ⇒ **未命中 = 不投递**(不是"投给默认角色"——那正是方案 §3.1 明令禁止的)。
165
+ * @param input.subscriptions 机器级订阅表
166
+ * @param input.account 收到的账号 id
167
+ * @param input.user 发信人 id
168
+ * @param input.fallback CCC 级回退目标(`{ccc, role}`);无 ⇒ `null` / 省略
169
+ */
170
+ export declare function planInboundRoute(input: {
171
+ subscriptions: readonly HumanChannelSubscriptionSettings[];
172
+ account: string;
173
+ user: string;
174
+ fallback?: {
175
+ ccc: string;
176
+ role: string;
177
+ } | null;
178
+ }): InboundRoutePlan;
179
+ /**
180
+ * 留痕落点:`$DSH_HOME/human-channel-audit.jsonl`(缺省 `~/.dsh/human-channel-audit.jsonl`)。
181
+ *
182
+ * 🔴 **为什么是机器级而不是某个 CCC 的 `_weixin-logs/`**:本文件记的事件(未投递 /
183
+ * 目标不存在 / 表残缺)**恰恰是"没有目标 CCC"的那些** —— 落进任何一个 CCC 都是**编一个**目标。
184
+ * (投递成功的入站事件仍落**被路由到的那个 CCC** 的 `_weixin-logs/`,方案 §3.6 不变。)
185
+ * env `SERENITY_HUMAN_CHANNEL_AUDIT` 覆盖(测试注入;与 `SERENITY_HOOKS_CONFIG` 同款推导)。
186
+ */
187
+ export declare function humanChannelAuditPath(): string;
188
+ /**
189
+ * 留痕事件。
190
+ * 🔴 **刻意不记消息正文**:方案 §3.1 要的是"**谁**发的、**为什么**没投" —— 正文既不必要,
191
+ * 又会把人说的话抄进一个机器级文件(本该只落在他自己 CCC 的日志里)。
192
+ */
193
+ export type HumanChannelAuditEvent = {
194
+ kind: 'inbound-unrouted';
195
+ account: string;
196
+ user: string;
197
+ reason: InboundSkipReason;
198
+ detail: string;
199
+ } | {
200
+ kind: 'inbound-target-missing';
201
+ account: string;
202
+ user: string;
203
+ ccc: string;
204
+ role: string;
205
+ detail: string;
206
+ } | {
207
+ kind: 'subscription-defect';
208
+ account: string;
209
+ index: number;
210
+ missing: string[];
211
+ present: Record<string, string>;
212
+ };
213
+ /**
214
+ * 追加一条留痕。**永不抛** ——
215
+ * 留痕失败绝不能反过来打断投递(它是**旁路**,与 `invokeWeixinHook` 的"旁路容忍"同款)。
216
+ * 🔴 **不静默的是它记的那件事**,不是它自己:所以调用方仍要**另行 `console` 出声**。
217
+ */
218
+ export declare function appendHumanChannelAudit(event: HumanChannelAuditEvent): void;
package/lib/index.js CHANGED
@@ -9889,12 +9889,64 @@ function registerCompactRetention(ctx, opts = {}) {
9889
9889
  * WebUI 停靠栏的数据源:ACC 版本 / CCC 根 / safe-mode 状态 / 黑名单 / keeper 阈值 / handyman 模型。
9890
9890
  * setSafeMode 直接读写 .serenity-safe-on 标记(守卫实时读取,写即生效)。
9891
9891
  */
9892
+ /** 宿主包名 —— 从运行入口反推包根时的**身份核对**用(见 {@link dshVersionFromEntry}) */
9893
+ const HOST_PACKAGE_NAME = "@deepseek-ai/dsh";
9894
+ /** 上溯层数上限:入口脚本在包内的深度是 `lib/`(1 层)或 `lib/x/y/`(≤3 层)⇒ 6 层足够,且**保证有界** */
9895
+ const MAX_ENTRY_ASCENT = 6;
9896
+ /**
9897
+ * 🔴 **布局无关档**:从**正在跑的那个入口脚本**反推宿主包根,读它的 `version`。
9898
+ *
9899
+ * 为什么必须补这一档(R↓:一处**静默失效**的实际缺陷;2026-09-28 docker bench 实测):
9900
+ * 下面那条静态候选链({@link readDshVersion} 的 ②)是**猜安装位置**——`npm_config_prefix` /
9901
+ * `APPDATA\npm` / `~/.npm-global`。一旦宿主装在**别处**(典型 = 容器镜像里的
9902
+ * `/usr/local/lib/node_modules/@deepseek-ai/dsh`),**三个候选全不中** ⇒ 返回 `null`
9903
+ * ⇒ `checkHostVersion(null)` 只给一条 **`required: false`** 的 issue ⇒ 报告 `ok` 仍为 `true`
9904
+ * ⇒ **版本门完全静默**(bench 逐字:`host contract degraded (1 optional): host version unknown`)。
9905
+ * ⚠️ 这类坏法**比"偶尔报警"更危险**:一条**永远为假**的判据看起来像"没报警 = 没问题"。
9906
+ *
9907
+ * 而"我们正跑在哪个宿主里"**本来就有直接证据**:`process.argv[1]`(= 启动本进程的那个脚本)。
9908
+ * 三条判据要点(每条都对应一个实测过的坑):
9909
+ * ① **先 `realpath`** —— 全局安装的 `dsh` 是**符号链接**(`/usr/local/bin/dsh` → `…/@deepseek-ai/dsh/lib/bin.js`),
9910
+ * 而 Node 的 `argv[1]` **保留调用路径、不解析符号链接** ⇒ 不 realpath 就永远上溯不到包根。
9911
+ * ② **核对包名**(`name === '@deepseek-ai/dsh'`)—— 否则在 vitest / npx 等入口下会误取**别人的**
9912
+ * `package.json`(这是本档**唯一**的假阳性来源)。
9913
+ * ③ **上溯有界**({@link MAX_ENTRY_ASCENT})—— 不设界的话,一个不在任何 dsh 包内的入口会一路爬到文件系统根。
9914
+ *
9915
+ * @param entry `process.argv[1]`(单测直接注入,无需替身)
9916
+ */
9917
+ function dshVersionFromEntry(entry) {
9918
+ if (typeof entry !== "string" || entry === "") return null;
9919
+ let dir;
9920
+ try {
9921
+ const real = realpathSync(entry);
9922
+ dir = statSync(real).isDirectory() ? real : dirname(real);
9923
+ } catch {
9924
+ return null;
9925
+ }
9926
+ for (let i = 0; i < MAX_ENTRY_ASCENT; i++) {
9927
+ try {
9928
+ const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf-8"));
9929
+ if (pkg.name === HOST_PACKAGE_NAME && typeof pkg.version === "string") return pkg.version;
9930
+ } catch {}
9931
+ const parent = dirname(dir);
9932
+ if (parent === dir) break;
9933
+ dir = parent;
9934
+ }
9935
+ return null;
9936
+ }
9892
9937
  /**
9893
9938
  * 读取已安装 DSH CLI 版本;读不到返回 null。
9894
- * 跨平台(Windows 审计问题 13):Windows npm 全局装在 %APPDATA%\npm(非 ~/.npm-global)——
9895
- * 依次尝试 npm_config_prefix / APPDATA\npm / ~/.npm-global。
9939
+ *
9940
+ * **两档,顺序有理由**:
9941
+ * ① 🔴 **布局无关档**({@link dshVersionFromEntry})= **正在跑的就是它** —— 直接证据优先于"猜安装位置"。
9942
+ * ② **静态候选链**(跨平台历史档,保留):`npm_config_prefix` / `APPDATA\npm` / `~/.npm-global`
9943
+ * (Windows npm 全局装在 `%APPDATA%\npm`,见审计问题 13)。
9944
+ * ⚠️ 保留它是因为**本进程不一定是 dsh CLI**(单测、嵌入式、将来别的宿主进程)——
9945
+ * 那时"本机装了哪个版本"仍需一个答案,只是证据等级更低。
9896
9946
  */
9897
9947
  function readDshVersion() {
9948
+ const fromEntry = dshVersionFromEntry(process.argv[1]);
9949
+ if (fromEntry !== null) return fromEntry;
9898
9950
  const candidates = [];
9899
9951
  if (process.env.npm_config_prefix) candidates.push(join(process.env.npm_config_prefix, "lib", "node_modules", "@deepseek-ai", "dsh"));
9900
9952
  if (process.env.APPDATA) candidates.push(join(process.env.APPDATA, "npm", "node_modules", "@deepseek-ai", "dsh"));
@@ -11017,12 +11069,43 @@ const HOST_SERVICES = [
11017
11069
  required: false
11018
11070
  }
11019
11071
  ];
11020
- /** dsp 被验证过的宿主版本范围(与 package.json peerDependencies 同源,单一真相源) */
11021
- const REQUIRED_HOST_RANGE = "^0.1.7-rc.1";
11022
- /** 范围 floor:剥掉 caret/比较前缀(`^0.1.7-rc.1` → `0.1.7-rc.1`),供 {@link checkHostVersion} 使用 */
11023
- const REQUIRED_HOST_FLOOR = REQUIRED_HOST_RANGE.replace(/^[\^~>=<\s]+/, "");
11024
- /** 范围 ceiling:`^0.1.x` 的上界 = `<0.2.0`(显式常量,见 {@link checkHostVersion} 注释) */
11025
- const REQUIRED_HOST_CEILING = "0.2.0";
11072
+ /**
11073
+ * dsp **被验证过的宿主版本档**(单一真相源)—— 每档 = 一个半开区间 `[floor, ceiling)`。
11074
+ *
11075
+ * 🔴 为什么是"档"而不是一条 caret 范围(R↓:2026-09-28 0.2.0-rc.1 适配轮实测得出):
11076
+ * - **宿主门的判据是真 semver**(`@deepseek-ai/dsh-app-boot:evaluatePluginCompatibility`
11077
+ * = `semver.satisfies(runtime, range, { includePrerelease: true })`)⇒ **它支持 `||` 联合范围**;
11078
+ * - 而 **dsp 这边是手写比较器**({@link compareSemver}),**不实现 semver 的 range 语义**:
11079
+ * 旧写法"用正则从 `REQUIRED_HOST_RANGE` 剥前缀取 floor"在**联合范围上会取到垃圾**
11080
+ * (`^0.1.7-rc.1 || ^0.2.0-rc.1` 的预发布捕获组 `(.*)` 会**吞掉 `||` 之后整段**)
11081
+ * ⇒ 判定**变瞎**(既非"通过"也非"低于下限",而是不可解释的值)。
11082
+ * ⇒ 故**档表是唯一被验证的真相**:`REQUIRED_HOST_RANGE`(给宿主门看的串)与
11083
+ * `checkHostVersion`(给自己的手写比较器)**都从它派生**,两者不可能互相漂移。
11084
+ *
11085
+ * 🔴 两档的由来(**实测,非推断**;判据表见插件仓 `docs/dsh-0.2.0-rc.1-adaptation-plan.md` §7.4):
11086
+ * - `^0.1.7-rc.1` 在 `0.2.0-rc.1` 上被宿主**整包拒绝 + 回滚**
11087
+ * (逐字 `installation rejected: … incompatible with dsh 0.2.0-rc.1` + `restored package.json, …`);
11088
+ * - 单档 `^0.2.0-rc.1` 会**同样硬拒本机正在跑的 `0.1.7-rc.2`**
11089
+ * ⇒ 发布后 deploy 必须先升宿主,**而升级会打断本容器**;
11090
+ * - ⇒ 双档是**唯一不把本机宿主堵死的写法**(两档在真 semver 下均 PASS)。
11091
+ * 档上界取 `<0.2.0` / `<0.3.0`(即 `^0.2.0-rc.1` 的 caret 上界):第二档同时覆盖
11092
+ * `0.2.0` 正式版与后续 `0.2.0-rc.N`。
11093
+ */
11094
+ const VERIFIED_HOST_BANDS = [{
11095
+ floor: "0.1.7-rc.1",
11096
+ ceiling: "0.2.0"
11097
+ }, {
11098
+ floor: "0.2.0-rc.1",
11099
+ ceiling: "0.3.0"
11100
+ }];
11101
+ /**
11102
+ * 与 `package.json` 的 `peerDependencies` **逐字同串**的宿主版本范围(由 {@link VERIFIED_HOST_BANDS} 派生)。
11103
+ *
11104
+ * 🔴 这个字符串**就是**宿主门(`evaluatePluginCompatibility`)拿去跑 `semver.satisfies` 的那个串
11105
+ * ⇒ 它与档表**必须一致**("声明 union、判定单档"是一种自相矛盾)。
11106
+ * 一致性由 `tests/host-contract.test.ts` 的**跨文件对账用例**钉住(读 package.json 逐字比对)。
11107
+ */
11108
+ const REQUIRED_HOST_RANGE = VERIFIED_HOST_BANDS.map((b) => `^${b.floor}`).join(" || ");
11026
11109
  /** 最小 semver 比较(无依赖):返回 -1 / 0 / 1;解析失败返回 null */
11027
11110
  function compareSemver(a, b) {
11028
11111
  const parse = (v) => {
@@ -11047,37 +11130,54 @@ function compareSemver(a, b) {
11047
11130
  return ra < rb ? -1 : 1;
11048
11131
  }
11049
11132
  /**
11050
- * 宿主版本是否落在 dsp 被验证的范围(`REQUIRED_HOST_RANGE`)。
11133
+ * 宿主版本是否落在 dsp **被验证过的档**({@link VERIFIED_HOST_BANDS})之内。
11051
11134
  * 无版本信息 → 视为未知(ok=false,detail 说明),不猜测。
11052
11135
  *
11053
- * floor/ceiling **从 `REQUIRED_HOST_RANGE` 派生**(v1.31.6,0.1.5-rc.1 适配轮):
11054
- * 此前 floor 是独立硬编码字面量——改 `REQUIRED_HOST_RANGE` 而忘改 floor 会造出
11055
- * "常量说 0.1.5、判定用 0.1.2" 的双真相源。ceiling 无法从 caret 语义机械派生(手写比较器
11056
- * 不实现 semver 的 caret 规则),故保留显式常量并在此声明其含义。
11136
+ * 🔴 **逐档判**(v1.50.x,0.2.0-rc.1 适配轮):判据 = "∃ 档:`version >= floor ∧ version < ceiling`"。
11137
+ * 曾经的做法是"从 `REQUIRED_HOST_RANGE` 剥前缀取一个 floor + 一个显式 ceiling"——
11138
+ * **单档假设一旦不成立(改联合范围),那个正则就会取到垃圾**(详见 {@link VERIFIED_HOST_BANDS}
11139
+ * 的注释)⇒ 故本函数**只读档表**,不再从范围串反推。
11140
+ * ⚠️ 手写比较器**不实现 semver 的 range 语义**(本条只做"区间内/外"),这也是必须逐档判的原因。
11057
11141
  */
11058
11142
  function checkHostVersion(version) {
11059
11143
  if (version === null) return {
11060
11144
  ok: false,
11061
11145
  detail: `host version unknown (required ${REQUIRED_HOST_RANGE})`
11062
11146
  };
11063
- const floor = compareSemver(version, REQUIRED_HOST_FLOOR);
11064
- const ceiling = compareSemver(version, REQUIRED_HOST_CEILING);
11065
- if (floor === null || ceiling === null) return {
11147
+ let matched = false;
11148
+ let belowEveryFloor = true;
11149
+ let unparseable = false;
11150
+ for (const band of VERIFIED_HOST_BANDS) {
11151
+ const floor = compareSemver(version, band.floor);
11152
+ const ceiling = compareSemver(version, band.ceiling);
11153
+ if (floor === null || ceiling === null) {
11154
+ unparseable = true;
11155
+ break;
11156
+ }
11157
+ if (floor >= 0) {
11158
+ belowEveryFloor = false;
11159
+ if (ceiling < 0) {
11160
+ matched = true;
11161
+ break;
11162
+ }
11163
+ }
11164
+ }
11165
+ if (unparseable) return {
11066
11166
  ok: false,
11067
11167
  detail: `host version "${version}" not parseable (required ${REQUIRED_HOST_RANGE})`
11068
11168
  };
11069
- if (floor < 0) return {
11169
+ if (matched) return {
11170
+ ok: true,
11171
+ detail: `host ${version} within ${REQUIRED_HOST_RANGE}`
11172
+ };
11173
+ if (belowEveryFloor) return {
11070
11174
  ok: false,
11071
11175
  detail: `host ${version} is below the verified floor (required ${REQUIRED_HOST_RANGE})`
11072
11176
  };
11073
- if (ceiling >= 0) return {
11177
+ return {
11074
11178
  ok: false,
11075
11179
  detail: `host ${version} is outside the verified range (required ${REQUIRED_HOST_RANGE})`
11076
11180
  };
11077
- return {
11078
- ok: true,
11079
- detail: `host ${version} within ${REQUIRED_HOST_RANGE}`
11080
- };
11081
11181
  }
11082
11182
  /** 结构化读取宿主服务(lazy → ctx.get;injected → 直接属性,属性缺失再试 ctx.get) */
11083
11183
  function readService(ctx, name, access) {
@@ -15923,8 +16023,13 @@ qInput.focus()
15923
16023
  * `ccc:<root>|<accountId>`(那时身份里带了 root ⇒ 跨 CCC 不可能撞车,也就不会误判成"同一账号")。
15924
16024
  *
15925
16025
  * ## 边界
15926
- * 本模块**只做归属判定**(谁能起、谁被谁占着),**不碰**:轮询循环、凭据解析、路由投递。
15927
- * 那些留在 `weixin-bridge.ts`(渠道实现)—— 归属是机制,实现是渠道。
16026
+ * 本模块做三件事(**都属机制**,与"哪个渠道"无关):
16027
+ * ① **归属判定**(P0-2:谁能起 poller、谁被谁占着)—— 见下 §归属判定
16028
+ * ② **订阅表查询 + 入站路由决策**(P0-3:`(账号, 用户) → (CCC, 角色)`)—— 见 §订阅表
16029
+ * ③ **机器级留痕**(P0-3:未投递 / 目标不存在 / 表残缺)—— 见 §留痕。
16030
+ * 🔴 为什么这些必须机器级:它们**没有目标 CCC** ⇒ 落进任何 CCC 都是"编一个"目标。
16031
+ * **不碰**:轮询循环、凭据解析、会话投递 —— 那些是**渠道实现**,留在 `weixin-bridge.ts`。
16032
+ * ⇒ 本模块**零 DSH 依赖**(只有 `node:crypto` / `node:fs`),可独立单测。
15928
16033
  */
15929
16034
  /**
15930
16035
  * 算账号的**机器级身份**。
@@ -16018,6 +16123,135 @@ function releasePollersOf(root) {
16018
16123
  function releaseAllPollers() {
16019
16124
  owners.clear();
16020
16125
  }
16126
+ /** 订阅行的四个字段(顺序即报错里的字段顺序) */
16127
+ const SUBSCRIPTION_FIELDS = [
16128
+ "account",
16129
+ "user",
16130
+ "ccc",
16131
+ "role"
16132
+ ];
16133
+ /**
16134
+ * 订阅表查询(**纯函数**)。
16135
+ *
16136
+ * 匹配两段式(**刻意对齐**旧 `matchWeixinRoute` 的 exact → `*` 兜底):
16137
+ * ① `account` **精确** ∧ `user` **精确**
16138
+ * ② `account` **精确** ∧ `user === '*'`(通配兜底)
16139
+ * 🔴 `account` **不设通配**:它是机器级账号的**显式身份**(表里写的就是账号 id);
16140
+ * 给账号也开 `*` 会让"这条订阅到底覆盖哪个 bot"变得不可读。
16141
+ * 🔴 `user` 保留 `*`:这是**有意的**——存量 CCC 级 `routes` 用的就是 `{user:'*'}`,
16142
+ * 迁移(P0-4)要能**逐字平移**它,否则"迁移"会悄悄改变行为。
16143
+ *
16144
+ * @param subscriptions 机器级订阅表(`readSimpleSettings().humanChannel.subscriptions`)
16145
+ * @param account 收到的**账号 id**(poller 身份;不是 token)
16146
+ * @param user 发信人 id
16147
+ */
16148
+ function lookupSubscription(subscriptions, account, user) {
16149
+ const defects = [];
16150
+ const usable = [];
16151
+ subscriptions.forEach((row, index) => {
16152
+ const raw = {
16153
+ account: row?.account,
16154
+ user: row?.user,
16155
+ ccc: row?.ccc,
16156
+ role: row?.role
16157
+ };
16158
+ const present = {};
16159
+ const missing = [];
16160
+ for (const field of SUBSCRIPTION_FIELDS) {
16161
+ const v = raw[field];
16162
+ if (typeof v === "string" && v.trim() !== "") present[field] = v.trim();
16163
+ else missing.push(field);
16164
+ }
16165
+ if (missing.length > 0) {
16166
+ defects.push({
16167
+ index,
16168
+ missing,
16169
+ present
16170
+ });
16171
+ return;
16172
+ }
16173
+ usable.push({
16174
+ account: present.account,
16175
+ user: present.user,
16176
+ ccc: present.ccc,
16177
+ role: present.role
16178
+ });
16179
+ });
16180
+ return {
16181
+ hit: usable.find((r) => r.account === account && r.user === user) ?? usable.find((r) => r.account === account && r.user === "*") ?? null,
16182
+ defects,
16183
+ usable: usable.length
16184
+ };
16185
+ }
16186
+ /**
16187
+ * 入站路由决策(**纯函数**)。
16188
+ *
16189
+ * 顺序:**机器级订阅表优先** → 未命中时用 CCC 级回退。
16190
+ *
16191
+ * 🔴 **回退是过渡期的"不打断在用的微信"**(方案 §11.3 **Q5**):机器级账号表为空时,
16192
+ * 桥仍按 CCC 起、路由也仍认 CCC 级 `routes`。机器级一旦配起来,回退传 `null`
16193
+ * ⇒ **未命中 = 不投递**(不是"投给默认角色"——那正是方案 §3.1 明令禁止的)。
16194
+ * @param input.subscriptions 机器级订阅表
16195
+ * @param input.account 收到的账号 id
16196
+ * @param input.user 发信人 id
16197
+ * @param input.fallback CCC 级回退目标(`{ccc, role}`);无 ⇒ `null` / 省略
16198
+ */
16199
+ function planInboundRoute(input) {
16200
+ const { hit, defects } = lookupSubscription(input.subscriptions, input.account, input.user);
16201
+ if (hit) return {
16202
+ target: {
16203
+ ccc: hit.ccc,
16204
+ role: hit.role
16205
+ },
16206
+ source: "machine",
16207
+ skipReason: null,
16208
+ defects
16209
+ };
16210
+ if (input.fallback) return {
16211
+ target: {
16212
+ ccc: input.fallback.ccc,
16213
+ role: input.fallback.role
16214
+ },
16215
+ source: "ccc-fallback",
16216
+ skipReason: null,
16217
+ defects
16218
+ };
16219
+ return {
16220
+ target: null,
16221
+ source: null,
16222
+ skipReason: input.subscriptions.length === 0 ? "no-subscription" : "no-match",
16223
+ defects
16224
+ };
16225
+ }
16226
+ /**
16227
+ * 留痕落点:`$DSH_HOME/human-channel-audit.jsonl`(缺省 `~/.dsh/human-channel-audit.jsonl`)。
16228
+ *
16229
+ * 🔴 **为什么是机器级而不是某个 CCC 的 `_weixin-logs/`**:本文件记的事件(未投递 /
16230
+ * 目标不存在 / 表残缺)**恰恰是"没有目标 CCC"的那些** —— 落进任何一个 CCC 都是**编一个**目标。
16231
+ * (投递成功的入站事件仍落**被路由到的那个 CCC** 的 `_weixin-logs/`,方案 §3.6 不变。)
16232
+ * env `SERENITY_HUMAN_CHANNEL_AUDIT` 覆盖(测试注入;与 `SERENITY_HOOKS_CONFIG` 同款推导)。
16233
+ */
16234
+ function humanChannelAuditPath() {
16235
+ const override = process.env.SERENITY_HUMAN_CHANNEL_AUDIT;
16236
+ if (override && override !== "") return override;
16237
+ const dshHome = process.env.DSH_HOME ?? join(process.env.HOME ?? "", ".dsh");
16238
+ return join(dshHome, "human-channel-audit.jsonl");
16239
+ }
16240
+ /**
16241
+ * 追加一条留痕。**永不抛** ——
16242
+ * 留痕失败绝不能反过来打断投递(它是**旁路**,与 `invokeWeixinHook` 的"旁路容忍"同款)。
16243
+ * 🔴 **不静默的是它记的那件事**,不是它自己:所以调用方仍要**另行 `console` 出声**。
16244
+ */
16245
+ function appendHumanChannelAudit(event) {
16246
+ try {
16247
+ const path = humanChannelAuditPath();
16248
+ mkdirSync(dirname(path), { recursive: true });
16249
+ appendFileSync(path, `${JSON.stringify({
16250
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
16251
+ ...event
16252
+ })}\n`, "utf-8");
16253
+ } catch {}
16254
+ }
16021
16255
  const manualSessions = /* @__PURE__ */ new Map();
16022
16256
  /**
16023
16257
  * 本轮是否已成功发送(**由桥在每轮开始时清空** `clearSentThisTurn`;闸门只置位不清空——
@@ -16306,6 +16540,7 @@ function invokeWeixinHook(root, hookRel, event) {
16306
16540
  //#region src/weixin-bridge.ts
16307
16541
  var weixin_bridge_exports = /* @__PURE__ */ __exportAll({
16308
16542
  handleIncoming: () => handleIncoming,
16543
+ handleMachineIncoming: () => handleMachineIncoming,
16309
16544
  manualOutputFallbackNeeded: () => manualOutputFallbackNeeded,
16310
16545
  registerWeixinBridge: () => registerWeixinBridge,
16311
16546
  resolveWeixinAccount: () => resolveWeixinAccount,
@@ -16313,9 +16548,21 @@ var weixin_bridge_exports = /* @__PURE__ */ __exportAll({
16313
16548
  stopAllBridges: () => stopAllBridges,
16314
16549
  stopCccBridge: () => stopCccBridge,
16315
16550
  syncCccBridge: () => syncCccBridge,
16551
+ syncHumanChannel: () => syncHumanChannel,
16316
16552
  weixinBridgeStatus: () => weixinBridgeStatus
16317
16553
  });
16318
16554
  const bridges = /* @__PURE__ */ new Map();
16555
+ /**
16556
+ * 🔴 **机器级 poller 的"根"哨兵**(P0-3 A)—— 它**不是一个目录**。
16557
+ *
16558
+ * 机器级 poller **不属于任何 CCC**(这正是"上移 ACC 层"的含义:谁轮询不再由 CCC 数量决定)。
16559
+ * 用途有二:① `runAccountLoop` 的 `root` 形参在机器级模式下不被使用,传它以示"无根";
16560
+ * ② 归属表(`human-channel.ts`)按 root 区分拥有者 ⇒ 机器级用这个哨兵值,
16561
+ * 与任何**真实** CCC 根都不会撞(真根一律是绝对路径)。
16562
+ */
16563
+ const MACHINE_SCOPE = "<machine>";
16564
+ /** 机器级 poller(账号 id → 循环)。与 `bridges` **互斥**:同一时刻只有一种模式在跑。 */
16565
+ const machineLoops = /* @__PURE__ */ new Map();
16319
16566
  /** 轮询间隔(getupdates 失败后重试延迟;成功 = 立即续轮询) */
16320
16567
  const POLL_RETRY_MS = 3e3;
16321
16568
  /** 媒体大小上限(M6:20MB,对齐 vlm-describe;超限降级告知不落盘) */
@@ -16369,7 +16616,7 @@ async function sendTypingStop(cred, accountId, fromUserId) {
16369
16616
  }
16370
16617
  }
16371
16618
  /** 单账号轮询循环:getupdates 长轮询 → 逐消息分发 → 回复回写 */
16372
- async function runAccountLoop(ctx, root, accountId, cred, loop) {
16619
+ async function runAccountLoop(ctx, root, accountId, cred, loop, machine = false) {
16373
16620
  let buf = "";
16374
16621
  while (!loop.stopped) {
16375
16622
  loop.lastPollAt = Date.now();
@@ -16384,7 +16631,8 @@ async function runAccountLoop(ctx, root, accountId, cred, loop) {
16384
16631
  for (const msg of resp.msgs ?? []) {
16385
16632
  if (loop.stopped) break;
16386
16633
  if (msg.message_type !== 1) continue;
16387
- await handleIncoming(ctx, root, accountId, cred, msg);
16634
+ if (machine) await handleMachineIncoming(ctx, accountId, cred, msg);
16635
+ else await handleIncoming(ctx, root, accountId, cred, msg);
16388
16636
  }
16389
16637
  } catch (err) {
16390
16638
  loop.lastError = err instanceof Error ? err.message : String(err);
@@ -16427,6 +16675,106 @@ function manualOutputFallbackNeeded(input) {
16427
16675
  return input.fallbackOnNoSend === true && input.guardActive && !input.sentThisTurn && input.answer !== "";
16428
16676
  }
16429
16677
  /**
16678
+ * 订阅表里的 `ccc` **别名 → CCC 根**(P0-3)。
16679
+ *
16680
+ * 别名取值与面板下拉**同源**(方案 §2.3:`listCccs({withRoles:true})` 的下拉)⇒
16681
+ * 先按 **`CccEntry.name`**(= CCC 根目录名,`basename(root)`)匹配;再退一步接受**绝对路径**
16682
+ * (手写配置时人会直接粘路径)。两条都试 ⇒ 两种写法都可用,且**不引入第二张名字表**。
16683
+ * @returns 命中的 CCC 根;枚举里没有 ⇒ `null`(调用方**响亮报错**,不静默丢弃)
16684
+ */
16685
+ async function resolveCccRootByAlias(ctx, alias) {
16686
+ try {
16687
+ const { listCccs } = await import("./ccc-roots-Duk89a-h.js").then((n) => n.a);
16688
+ const entries = await listCccs(ctx);
16689
+ const byName = entries.find((e) => e.name === alias);
16690
+ if (byName) return byName.root;
16691
+ const byRoot = entries.find((e) => e.root === alias);
16692
+ return byRoot ? byRoot.root : null;
16693
+ } catch {
16694
+ return null;
16695
+ }
16696
+ }
16697
+ /**
16698
+ * **入站路由的单一解析处**(P0-3):机器级订阅表优先 → CCC 级回退;三档语义都在这里落地。
16699
+ *
16700
+ * 🔴 为什么抽成一个函数而不是写两遍:**两种模式(CCC 起桥 / 机器级起 poller)走的是同一套
16701
+ * 判据**,差别只有一格 —— **有没有 CCC 级回退**(`cccFallback`)。写两遍必然分叉。
16702
+ *
16703
+ * 三档(方案 §3.1,**刻意分档**):
16704
+ * - **未命中** ⇒ 不投递 + **留痕**(`console.warn`,静默档:这是"表里没有你",不是错误)
16705
+ * - **表残缺** ⇒ **响亮出声**(`console.error`;且**独立于是否命中**)
16706
+ * - **目标不存在**(CCC / 角色)⇒ **响亮报错**(`console.error`;不静默丢弃)
16707
+ *
16708
+ * @param input.cccFallback 过渡期回退(方案 Q5);**机器级模式传 `null`** ⇒ 未命中即不投递
16709
+ */
16710
+ async function resolveInboundTarget(ctx, input) {
16711
+ const route = planInboundRoute({
16712
+ subscriptions: readSimpleSettings().humanChannel.subscriptions,
16713
+ account: input.account,
16714
+ user: input.user,
16715
+ fallback: input.cccFallback ? {
16716
+ ccc: input.cccFallback.root,
16717
+ role: input.cccFallback.role
16718
+ } : null
16719
+ });
16720
+ for (const defect of route.defects) {
16721
+ console.error(`[serenity-hooks] ✗ human-channel: 订阅表第 ${defect.index + 1} 行**残缺**(缺 ${defect.missing.join(" / ")})|已有字段 ${JSON.stringify(defect.present)}|该行**不生效** —— 请补全或删掉(机器级 humanChannel.subscriptions)`);
16722
+ appendHumanChannelAudit({
16723
+ kind: "subscription-defect",
16724
+ account: input.account,
16725
+ index: defect.index,
16726
+ missing: defect.missing,
16727
+ present: defect.present
16728
+ });
16729
+ }
16730
+ const target = route.target;
16731
+ if (!target) {
16732
+ const detail = route.skipReason === "no-subscription" ? "机器级订阅表为空(且无 CCC 级回退)" : "订阅表里没有匹配 (账号, 用户) 的行";
16733
+ console.warn(`[serenity-hooks] ⚠ human-channel: 入站未命中订阅 ⇒ **不投递**(account=${input.account} user=${input.user}):${detail}`);
16734
+ appendHumanChannelAudit({
16735
+ kind: "inbound-unrouted",
16736
+ account: input.account,
16737
+ user: input.user,
16738
+ reason: route.skipReason ?? "no-match",
16739
+ detail
16740
+ });
16741
+ return null;
16742
+ }
16743
+ const root = route.source === "ccc-fallback" ? target.ccc : await resolveCccRootByAlias(ctx, target.ccc);
16744
+ if (root === null) {
16745
+ const detail = `订阅表指向的 CCC "${target.ccc}" 在本机 CCC 枚举里不存在`;
16746
+ console.error(`[serenity-hooks] ✗ human-channel: ${detail}(account=${input.account} user=${input.user})—— 该消息**未投递**,请改订阅表`);
16747
+ appendHumanChannelAudit({
16748
+ kind: "inbound-target-missing",
16749
+ account: input.account,
16750
+ user: input.user,
16751
+ ccc: target.ccc,
16752
+ role: target.role,
16753
+ detail
16754
+ });
16755
+ return null;
16756
+ }
16757
+ const role = readSkiffRoles(root).get(target.role);
16758
+ if (!role) {
16759
+ const detail = `目标 CCC "${target.ccc}" 未定义 skiff 角色 "${target.role}"`;
16760
+ console.error(`[serenity-hooks] ✗ human-channel: ${detail}(account=${input.account} user=${input.user})—— 该消息**未投递**,请改订阅表或该 CCC 的 skiff.roles`);
16761
+ appendHumanChannelAudit({
16762
+ kind: "inbound-target-missing",
16763
+ account: input.account,
16764
+ user: input.user,
16765
+ ccc: target.ccc,
16766
+ role: target.role,
16767
+ detail
16768
+ });
16769
+ return null;
16770
+ }
16771
+ return {
16772
+ root,
16773
+ roleName: target.role,
16774
+ role
16775
+ };
16776
+ }
16777
+ /**
16430
16778
  * 处理单条微信消息:路由 → skiff 会话(固定 id 创建/延续)→ 提问 → 回复回写。
16431
16779
  *
16432
16780
  * 会话语义:`weixinSessionIdFor(fromUserId)` 固定可重建——同用户长期同一会话;
@@ -16443,16 +16791,35 @@ function manualOutputFallbackNeeded(input) {
16443
16791
  * `_tmp/weixin-inbound/<userhash>/` → question 注入「存在性 + 路径」(ACC 层只保证
16444
16792
  * 可达性——"让会话知道文件的存在并可以拿到";识别/解析归角色 LLM 决策,不编排)。
16445
16793
  * 降级不静默:下载失败 / 超 20MB → 注入说明进对话;typing 窗口覆盖下载(M7)。
16794
+ *
16795
+ * 🔴 **P0-3(2026-09-27):路由改由机器级订阅表决定**(本函数的 `root` 入参退为
16796
+ * **回退目标**)。两档语义刻意分开(方案 §3.1):**未命中 ⇒ 不投递 + 留痕**(静默档);
16797
+ * **目标不存在(CCC / 角色)⇒ 响亮报错**(`console.error` + 机器级留痕)。两者都
16798
+ * **不是**"投给默认角色"。
16446
16799
  */
16447
- async function handleIncoming(ctx, root, accountId, cred, msg) {
16800
+ async function handleIncoming(ctx, root, accountId, cred, msg, preset) {
16448
16801
  const fromUserId = msg.from_user_id;
16449
16802
  if (!fromUserId) return;
16450
16803
  const text = extractWeixinText(msg);
16451
16804
  const mediaRefs = extractWeixinMedia(msg);
16452
16805
  const settings = readWeixinSettings(root);
16453
- if (!settings.enabled) return;
16454
- const roleName = matchWeixinRoute(settings.routes ?? [], fromUserId);
16455
- if (!roleName) return;
16806
+ let resolved;
16807
+ if (preset) resolved = preset.target;
16808
+ else {
16809
+ if (!settings.enabled) return;
16810
+ const cccRole = matchWeixinRoute(settings.routes ?? [], fromUserId);
16811
+ resolved = await resolveInboundTarget(ctx, {
16812
+ account: accountId,
16813
+ user: fromUserId,
16814
+ cccFallback: cccRole ? {
16815
+ root,
16816
+ role: cccRole
16817
+ } : null
16818
+ });
16819
+ }
16820
+ if (!resolved) return;
16821
+ const { roleName, role } = resolved;
16822
+ root = resolved.root;
16456
16823
  if (!text && mediaRefs.length === 0) {
16457
16824
  if (hasVoiceItem(msg)) await sendTextMessage({
16458
16825
  baseUrl: cred.baseUrl,
@@ -16464,11 +16831,6 @@ async function handleIncoming(ctx, root, accountId, cred, msg) {
16464
16831
  return;
16465
16832
  }
16466
16833
  try {
16467
- const role = readSkiffRoles(root).get(roleName);
16468
- if (!role) {
16469
- console.log(`[serenity-hooks] weixin-bridge: 路由命中角色 "${roleName}" 但该 CCC 未定义(检查 skiff.roles)`);
16470
- return;
16471
- }
16472
16834
  const sessionId = weixinSessionIdFor(fromUserId);
16473
16835
  const existing = getSkiffAgent(sessionId);
16474
16836
  const hc = readHandymanConfig(root);
@@ -16628,6 +16990,31 @@ async function handleIncoming(ctx, root, accountId, cred, msg) {
16628
16990
  }
16629
16991
  }
16630
16992
  /**
16993
+ * **机器级 poller 的单条消息入口**(P0-3 A)。
16994
+ *
16995
+ * 与 `handleIncoming` 的唯一差别:**目标由机器级订阅表逐条解析,且没有 CCC 级回退**
16996
+ * (方案 §3.1:机器级模式下**未命中 = 不投递**,不是"投给默认角色")。
16997
+ * 解析出来后把**目标根**当 `root` 交给 `handleIncoming`(并带上 `preset` ⇒ 它不再自行路由、
16998
+ * 也不看目标 CCC 的 `weixin.enabled`)。
16999
+ *
17000
+ * 🔴 为什么"起 poller"与"路由"必须同批(本件即 P0-2 的后半):机器级 poller 收到消息时
17001
+ * **不知道它属于哪个 CCC** —— 那个答案是订阅表给的。没有订阅表 ⇒ **能收不能投**。
17002
+ *
17003
+ * 🔵 **导出供测试直接驱动**(与 `handleIncoming` 同款:它俩都是"单条消息的入口",
17004
+ * 生产路径 `runAccountLoop` 只是循环壳)。
17005
+ */
17006
+ async function handleMachineIncoming(ctx, accountId, cred, msg) {
17007
+ const fromUserId = msg.from_user_id;
17008
+ if (!fromUserId) return;
17009
+ const target = await resolveInboundTarget(ctx, {
17010
+ account: accountId,
17011
+ user: fromUserId,
17012
+ cccFallback: null
17013
+ });
17014
+ if (!target) return;
17015
+ await handleIncoming(ctx, target.root, accountId, cred, msg, { target });
17016
+ }
17017
+ /**
16631
17018
  * 启动/重建某 CCC 的桥:读配置 → 对每个 enabled + 有凭据的账号启动轮询循环。
16632
17019
  * 已存在(配置变化热重建)→ 先停旧循环再启动新的。
16633
17020
  *
@@ -16689,13 +17076,118 @@ function stopCccBridge(root) {
16689
17076
  bridges.delete(root);
16690
17077
  }
16691
17078
  /**
16692
- * 停止全部桥(插件 dispose)
17079
+ * 停掉**全部 CCC 级桥**(不碰机器级 poller)+ 释放它们各自的账号归属。
17080
+ * 🔴 模式切换时必须先调它:否则同一个 bot 会被**两处**轮询(方案 §2.6 要消灭的正是这个)。
17081
+ */
17082
+ function stopCccBridges() {
17083
+ for (const root of [...bridges.keys()]) stopCccBridge(root);
17084
+ }
17085
+ /** 停掉**机器级 poller**(不碰 CCC 级桥)+ 释放机器级归属 */
17086
+ function stopMachineLoops() {
17087
+ for (const loop of machineLoops.values()) loop.stopped = true;
17088
+ machineLoops.clear();
17089
+ releasePollersOf(MACHINE_SCOPE);
17090
+ }
17091
+ /**
17092
+ * 停止全部桥(插件 dispose):CCC 级 + 机器级
16693
17093
  */
16694
17094
  function stopAllBridges() {
16695
- for (const bridge of bridges.values()) for (const loop of bridge.loops.values()) loop.stopped = true;
16696
- bridges.clear();
17095
+ stopCccBridges();
17096
+ stopMachineLoops();
16697
17097
  releaseAllPollers();
16698
17098
  }
17099
+ /** 已告警过的"未实现渠道"(避免每次 session/created 都刷一行) */
17100
+ const warnedChannels = /* @__PURE__ */ new Set();
17101
+ /**
17102
+ * **机器级模式**:按**账号**起 poller(方案 §2.6「一账号一 poller」的正面形态)。
17103
+ *
17104
+ * 与 CCC 模式的差别:poller **不属于任何 CCC** —— 消息投到哪由**订阅表逐条决定**
17105
+ * (见 `handleMachineIncoming`)。凭据来自机器级配置本身(方案 Q3:凭据落插件 Config)。
17106
+ *
17107
+ * 🔴 进入本函数即**先停掉全部 CCC 级桥**:两种模式**互斥**(同时跑 = 同一 bot 两处轮询)。
17108
+ */
17109
+ function syncMachinePollers(ctx, hc) {
17110
+ stopCccBridges();
17111
+ const requests = [];
17112
+ const creds = /* @__PURE__ */ new Map();
17113
+ for (const account of hc.accounts) {
17114
+ const id = account.id;
17115
+ if (account.enabled === false) continue;
17116
+ if (!id) {
17117
+ console.warn("[serenity-hooks] ⚠ human-channel: 机器级账号表有一行**缺 `id`** ⇒ 跳过(该行无法被订阅表引用)");
17118
+ continue;
17119
+ }
17120
+ if (account.channel !== "weixin") {
17121
+ if (!warnedChannels.has(`${id}:${account.channel ?? ""}`)) {
17122
+ warnedChannels.add(`${id}:${account.channel ?? ""}`);
17123
+ console.warn(`[serenity-hooks] ⚠ human-channel: 账号 ${id} 的渠道 "${account.channel ?? "(未填)"}" **本机未实现** ⇒ 不起 poller(当前只有 weixin)`);
17124
+ }
17125
+ continue;
17126
+ }
17127
+ const token = (account.token ?? "").trim();
17128
+ if (token === "") {
17129
+ console.warn(`[serenity-hooks] ⚠ human-channel: 账号 ${id} **无 token**(未绑定)⇒ 不起 poller`);
17130
+ continue;
17131
+ }
17132
+ creds.set(id, {
17133
+ token,
17134
+ baseUrl: "",
17135
+ userId: account.userId
17136
+ });
17137
+ requests.push({
17138
+ root: MACHINE_SCOPE,
17139
+ accountId: id,
17140
+ identity: accountIdentity(MACHINE_SCOPE, id, token)
17141
+ });
17142
+ }
17143
+ const plan = planPollerClaims(requests, pollerOwners());
17144
+ for (const { request, ownedBy } of plan.skipped) console.warn(`[serenity-hooks] ⚠ human-channel: 机器级账号 ${request.accountId} 与 ${ownedBy.accountId} **绑的是同一个 bot** ⇒ 本处不重复起循环(一账号一 poller;方案 §2.6)`);
17145
+ stopMachineLoops();
17146
+ for (const claim of plan.claims) {
17147
+ const cred = creds.get(claim.accountId);
17148
+ if (!cred) continue;
17149
+ const loop = {
17150
+ stopped: false,
17151
+ lastPollAt: 0
17152
+ };
17153
+ machineLoops.set(claim.accountId, loop);
17154
+ runAccountLoop(ctx, MACHINE_SCOPE, claim.accountId, cred, loop, true);
17155
+ }
17156
+ commitPollerClaims(plan);
17157
+ if (machineLoops.size > 0) console.log(`[serenity-hooks] ✓ human-channel: 机器级 poller accounts=${[...machineLoops.keys()].join(",")}(与 CCC 无关)`);
17158
+ }
17159
+ /**
17160
+ * 人机信道的**装配入口**(P0-3 A)—— 决定用哪种模式起 poller。
17161
+ *
17162
+ * 🔴 **模式由机器级账号表决定**(方案 §11.3 **Q5** 的过渡期语义):
17163
+ * - 账号表**非空** ⇒ **机器级模式**:按账号起 poller;路由**只认订阅表**
17164
+ * - 账号表**为空** ⇒ **回退模式**(今天的形态):按 CCC 起桥;路由**先查订阅表,未命中回退
17165
+ * 到 CCC 级 routes** ⇒ **不打断在用的微信**
17166
+ * ⚠️ 两种模式**互斥**(各自进入时先停掉另一种),否则同一个 bot 会被两处轮询。
17167
+ */
17168
+ function syncHumanChannel(ctx) {
17169
+ let hc;
17170
+ try {
17171
+ hc = readSimpleSettings().humanChannel;
17172
+ } catch (err) {
17173
+ console.warn(`[serenity-hooks] ✗ human-channel: 机器级配置读取失败 ⇒ 按"未配置"处理(回退按 CCC 起桥): ${String(err?.message ?? err)}`);
17174
+ }
17175
+ if (hc && hc.accounts.length > 0) {
17176
+ try {
17177
+ syncMachinePollers(ctx, hc);
17178
+ } catch (err) {
17179
+ console.warn(`[serenity-hooks] ✗ human-channel: 机器级 poller 装配失败: ${String(err?.message ?? err)}`);
17180
+ }
17181
+ return;
17182
+ }
17183
+ (async () => {
17184
+ try {
17185
+ stopMachineLoops();
17186
+ const { listCccs } = await import("./ccc-roots-Duk89a-h.js").then((n) => n.a);
17187
+ for (const entry of await listCccs(ctx)) syncCccBridge(ctx, entry.root);
17188
+ } catch {}
17189
+ })();
17190
+ }
16699
17191
  /**
16700
17192
  * 解析该 CCC 的发送账号 + 凭据(v1.31.0:文本与文件发送的**单一账号选择真相源**)。
16701
17193
  * 顺序:桥启用 → 有启用账号 → 指定账号存在 → 凭据已绑定(扫码)。
@@ -16778,35 +17270,37 @@ async function sendProactiveText(input) {
16778
17270
  role
16779
17271
  };
16780
17272
  }
16781
- /** 桥状态快照(面板数据源):每 CCC → 每账号 → 轮询健康 */
17273
+ /**
17274
+ * 桥状态快照(面板数据源):每 CCC → 每账号 → 轮询健康。
17275
+ * 🔴 **P0-3 A 起多一行**:机器级 poller 以 `ccc: '<machine>'`(`MACHINE_SCOPE`)出现 ——
17276
+ * 它**不属于任何 CCC**,故**不伪装成某个 CCC 的行**(面板按原样显示这个哨兵值即可)。
17277
+ */
16782
17278
  function weixinBridgeStatus() {
16783
17279
  const out = [];
17280
+ const toAccounts = (loops) => [...loops.entries()].map(([accountId, loop]) => ({
17281
+ accountId,
17282
+ lastPollAt: loop.lastPollAt,
17283
+ ...loop.lastError ? { lastError: loop.lastError } : {}
17284
+ }));
16784
17285
  for (const [root, bridge] of bridges) out.push({
16785
17286
  ccc: root,
16786
- accounts: [...bridge.loops.entries()].map(([accountId, loop]) => ({
16787
- accountId,
16788
- lastPollAt: loop.lastPollAt,
16789
- ...loop.lastError ? { lastError: loop.lastError } : {}
16790
- }))
17287
+ accounts: toAccounts(bridge.loops)
17288
+ });
17289
+ if (machineLoops.size > 0) out.push({
17290
+ ccc: MACHINE_SCOPE,
17291
+ accounts: toAccounts(machineLoops)
16791
17292
  });
16792
17293
  return out;
16793
17294
  }
16794
17295
  /**
16795
- * 装配(index.ts 调用):扫描 live CCC 启动桥 + 监听会话变化。
16796
- * 事件驱动(对齐 autotrajectory v1.26.15):live 会话出现 → 同步该 CCC 桥。
17296
+ * 装配(index.ts 调用):**按机器级配置决定模式**(P0-3 A:`syncHumanChannel`),
17297
+ * 并监听会话变化以重扫(新 CCC 出现即启动其桥;配置变化也可经此路径热重建)。
17298
+ * 事件驱动(对齐 autotrajectory v1.26.15)。
16797
17299
  */
16798
17300
  function registerWeixinBridge(ctx) {
16799
- const syncFromLive = () => {
16800
- (async () => {
16801
- try {
16802
- const { listCccs } = await import("./ccc-roots-Duk89a-h.js").then((n) => n.a);
16803
- for (const entry of await listCccs(ctx)) syncCccBridge(ctx, entry.root);
16804
- } catch {}
16805
- })();
16806
- };
16807
- syncFromLive();
17301
+ syncHumanChannel(ctx);
16808
17302
  try {
16809
- ctx.on("session/created", () => syncFromLive());
17303
+ ctx.on("session/created", () => syncHumanChannel(ctx));
16810
17304
  } catch {}
16811
17305
  }
16812
17306
  //#endregion
package/lib/status.d.ts CHANGED
@@ -4,10 +4,37 @@
4
4
  * WebUI 停靠栏的数据源:ACC 版本 / CCC 根 / safe-mode 状态 / 黑名单 / keeper 阈值 / handyman 模型。
5
5
  * setSafeMode 直接读写 .serenity-safe-on 标记(守卫实时读取,写即生效)。
6
6
  */
7
+ /**
8
+ * 🔴 **布局无关档**:从**正在跑的那个入口脚本**反推宿主包根,读它的 `version`。
9
+ *
10
+ * 为什么必须补这一档(R↓:一处**静默失效**的实际缺陷;2026-09-28 docker bench 实测):
11
+ * 下面那条静态候选链({@link readDshVersion} 的 ②)是**猜安装位置**——`npm_config_prefix` /
12
+ * `APPDATA\npm` / `~/.npm-global`。一旦宿主装在**别处**(典型 = 容器镜像里的
13
+ * `/usr/local/lib/node_modules/@deepseek-ai/dsh`),**三个候选全不中** ⇒ 返回 `null`
14
+ * ⇒ `checkHostVersion(null)` 只给一条 **`required: false`** 的 issue ⇒ 报告 `ok` 仍为 `true`
15
+ * ⇒ **版本门完全静默**(bench 逐字:`host contract degraded (1 optional): host version unknown`)。
16
+ * ⚠️ 这类坏法**比"偶尔报警"更危险**:一条**永远为假**的判据看起来像"没报警 = 没问题"。
17
+ *
18
+ * 而"我们正跑在哪个宿主里"**本来就有直接证据**:`process.argv[1]`(= 启动本进程的那个脚本)。
19
+ * 三条判据要点(每条都对应一个实测过的坑):
20
+ * ① **先 `realpath`** —— 全局安装的 `dsh` 是**符号链接**(`/usr/local/bin/dsh` → `…/@deepseek-ai/dsh/lib/bin.js`),
21
+ * 而 Node 的 `argv[1]` **保留调用路径、不解析符号链接** ⇒ 不 realpath 就永远上溯不到包根。
22
+ * ② **核对包名**(`name === '@deepseek-ai/dsh'`)—— 否则在 vitest / npx 等入口下会误取**别人的**
23
+ * `package.json`(这是本档**唯一**的假阳性来源)。
24
+ * ③ **上溯有界**({@link MAX_ENTRY_ASCENT})—— 不设界的话,一个不在任何 dsh 包内的入口会一路爬到文件系统根。
25
+ *
26
+ * @param entry `process.argv[1]`(单测直接注入,无需替身)
27
+ */
28
+ export declare function dshVersionFromEntry(entry: string | undefined): string | null;
7
29
  /**
8
30
  * 读取已安装 DSH CLI 版本;读不到返回 null。
9
- * 跨平台(Windows 审计问题 13):Windows npm 全局装在 %APPDATA%\npm(非 ~/.npm-global)——
10
- * 依次尝试 npm_config_prefix / APPDATA\npm / ~/.npm-global。
31
+ *
32
+ * **两档,顺序有理由**:
33
+ * ① 🔴 **布局无关档**({@link dshVersionFromEntry})= **正在跑的就是它** —— 直接证据优先于"猜安装位置"。
34
+ * ② **静态候选链**(跨平台历史档,保留):`npm_config_prefix` / `APPDATA\npm` / `~/.npm-global`
35
+ * (Windows npm 全局装在 `%APPDATA%\npm`,见审计问题 13)。
36
+ * ⚠️ 保留它是因为**本进程不一定是 dsh CLI**(单测、嵌入式、将来别的宿主进程)——
37
+ * 那时"本机装了哪个版本"仍需一个答案,只是证据等级更低。
11
38
  */
12
39
  export declare function readDshVersion(): string | null;
13
40
  interface SerenityStatus {
@@ -19,6 +19,7 @@
19
19
  import type { Context } from 'cordis';
20
20
  import { type WeixinAccountCredential } from './weixin-route.js';
21
21
  import { type WeixinMessage } from './weixin-api.js';
22
+ import { type SkiffRoleConfig } from './skiff-role.js';
22
23
  /** 测试辅助:清空 typing_ticket 缓存(生产零调用) */
23
24
  export declare function resetWeixinTypingCache(): void;
24
25
  /**
@@ -36,6 +37,15 @@ export declare function manualOutputFallbackNeeded(input: {
36
37
  sentThisTurn: boolean;
37
38
  answer: string;
38
39
  }): boolean;
40
+ /**
41
+ * 路由解析结果:目标根 + 角色名 + **角色配置**(三者一起给,避免调用方再读一次 skiff.roles)。
42
+ * `null` 从本函数出去 ⇒ **不投递**(未命中已留痕 / 目标不存在已响亮出声)。
43
+ */
44
+ interface ResolvedInboundTarget {
45
+ root: string;
46
+ roleName: string;
47
+ role: SkiffRoleConfig;
48
+ }
39
49
  /**
40
50
  * 处理单条微信消息:路由 → skiff 会话(固定 id 创建/延续)→ 提问 → 回复回写。
41
51
  *
@@ -53,8 +63,38 @@ export declare function manualOutputFallbackNeeded(input: {
53
63
  * `_tmp/weixin-inbound/<userhash>/` → question 注入「存在性 + 路径」(ACC 层只保证
54
64
  * 可达性——"让会话知道文件的存在并可以拿到";识别/解析归角色 LLM 决策,不编排)。
55
65
  * 降级不静默:下载失败 / 超 20MB → 注入说明进对话;typing 窗口覆盖下载(M7)。
66
+ *
67
+ * 🔴 **P0-3(2026-09-27):路由改由机器级订阅表决定**(本函数的 `root` 入参退为
68
+ * **回退目标**)。两档语义刻意分开(方案 §3.1):**未命中 ⇒ 不投递 + 留痕**(静默档);
69
+ * **目标不存在(CCC / 角色)⇒ 响亮报错**(`console.error` + 机器级留痕)。两者都
70
+ * **不是**"投给默认角色"。
56
71
  */
57
- export declare function handleIncoming(ctx: Context, root: string, accountId: string, cred: WeixinAccountCredential, msg: Pick<WeixinMessage, 'from_user_id' | 'context_token' | 'item_list'>): Promise<void>;
72
+ export declare function handleIncoming(ctx: Context, root: string, accountId: string, cred: WeixinAccountCredential, msg: Pick<WeixinMessage, 'from_user_id' | 'context_token' | 'item_list'>,
73
+ /**
74
+ * **机器级模式**(P0-3 A):目标已由 `handleMachineIncoming` 解析好(`root` 入参即该目标根)。
75
+ * 给了它 ⇒ 本函数**不再自行路由**,且**不看本 CCC 的 `weixin.enabled`** ——
76
+ * 起 poller 与路由都已上移机器级(目标 CCC 没配 weixin 段也照样收得到)。
77
+ * ⚠️ **行为开关仍归目标 CCC**(`hook` / `autoReplyWithLastMessage` / `fallbackOnNoSend`)
78
+ * —— 机器级只管"**谁收**"与"**投到哪**",不管"**怎么答**"。
79
+ */
80
+ preset?: {
81
+ target: ResolvedInboundTarget;
82
+ }): Promise<void>;
83
+ /**
84
+ * **机器级 poller 的单条消息入口**(P0-3 A)。
85
+ *
86
+ * 与 `handleIncoming` 的唯一差别:**目标由机器级订阅表逐条解析,且没有 CCC 级回退**
87
+ * (方案 §3.1:机器级模式下**未命中 = 不投递**,不是"投给默认角色")。
88
+ * 解析出来后把**目标根**当 `root` 交给 `handleIncoming`(并带上 `preset` ⇒ 它不再自行路由、
89
+ * 也不看目标 CCC 的 `weixin.enabled`)。
90
+ *
91
+ * 🔴 为什么"起 poller"与"路由"必须同批(本件即 P0-2 的后半):机器级 poller 收到消息时
92
+ * **不知道它属于哪个 CCC** —— 那个答案是订阅表给的。没有订阅表 ⇒ **能收不能投**。
93
+ *
94
+ * 🔵 **导出供测试直接驱动**(与 `handleIncoming` 同款:它俩都是"单条消息的入口",
95
+ * 生产路径 `runAccountLoop` 只是循环壳)。
96
+ */
97
+ export declare function handleMachineIncoming(ctx: Context, accountId: string, cred: WeixinAccountCredential, msg: Pick<WeixinMessage, 'from_user_id' | 'context_token' | 'item_list'>): Promise<void>;
58
98
  /**
59
99
  * 启动/重建某 CCC 的桥:读配置 → 对每个 enabled + 有凭据的账号启动轮询循环。
60
100
  * 已存在(配置变化热重建)→ 先停旧循环再启动新的。
@@ -68,9 +108,19 @@ export declare function syncCccBridge(ctx: Context, root: string): void;
68
108
  /** 停止某 CCC 的桥(移除账号/禁用时);同时释放它的账号归属 */
69
109
  export declare function stopCccBridge(root: string): void;
70
110
  /**
71
- * 停止全部桥(插件 dispose)
111
+ * 停止全部桥(插件 dispose):CCC 级 + 机器级
72
112
  */
73
113
  export declare function stopAllBridges(): void;
114
+ /**
115
+ * 人机信道的**装配入口**(P0-3 A)—— 决定用哪种模式起 poller。
116
+ *
117
+ * 🔴 **模式由机器级账号表决定**(方案 §11.3 **Q5** 的过渡期语义):
118
+ * - 账号表**非空** ⇒ **机器级模式**:按账号起 poller;路由**只认订阅表**
119
+ * - 账号表**为空** ⇒ **回退模式**(今天的形态):按 CCC 起桥;路由**先查订阅表,未命中回退
120
+ * 到 CCC 级 routes** ⇒ **不打断在用的微信**
121
+ * ⚠️ 两种模式**互斥**(各自进入时先停掉另一种),否则同一个 bot 会被两处轮询。
122
+ */
123
+ export declare function syncHumanChannel(ctx: Context): void;
74
124
  /** 主动发送失败原因(稳定 code——入口层翻译成 HTTP 状态 + 可行动提示) */
75
125
  type ProactiveSendErrorCode = 'BRIDGE_DISABLED' | 'NO_ACCOUNT' | 'ACCOUNT_NOT_FOUND' | 'ACCOUNT_NOT_BOUND' | 'SEND_FAILED';
76
126
  interface ProactiveSendInput {
@@ -129,7 +179,11 @@ type WeixinAccountResolution = {
129
179
  */
130
180
  export declare function resolveWeixinAccount(root: string, accountId?: string): WeixinAccountResolution;
131
181
  export declare function sendProactiveText(input: ProactiveSendInput): Promise<ProactiveSendResult>;
132
- /** 桥状态快照(面板数据源):每 CCC → 每账号 → 轮询健康 */
182
+ /**
183
+ * 桥状态快照(面板数据源):每 CCC → 每账号 → 轮询健康。
184
+ * 🔴 **P0-3 A 起多一行**:机器级 poller 以 `ccc: '<machine>'`(`MACHINE_SCOPE`)出现 ——
185
+ * 它**不属于任何 CCC**,故**不伪装成某个 CCC 的行**(面板按原样显示这个哨兵值即可)。
186
+ */
133
187
  export declare function weixinBridgeStatus(): Array<{
134
188
  ccc: string;
135
189
  accounts: Array<{
@@ -139,8 +193,9 @@ export declare function weixinBridgeStatus(): Array<{
139
193
  }>;
140
194
  }>;
141
195
  /**
142
- * 装配(index.ts 调用):扫描 live CCC 启动桥 + 监听会话变化。
143
- * 事件驱动(对齐 autotrajectory v1.26.15):live 会话出现 → 同步该 CCC 桥。
196
+ * 装配(index.ts 调用):**按机器级配置决定模式**(P0-3 A:`syncHumanChannel`),
197
+ * 并监听会话变化以重扫(新 CCC 出现即启动其桥;配置变化也可经此路径热重建)。
198
+ * 事件驱动(对齐 autotrajectory v1.26.15)。
144
199
  */
145
200
  export declare function registerWeixinBridge(ctx: Context): void;
146
201
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shgroup/dsh-serenity-hooks",
3
- "version": "1.50.0",
3
+ "version": "1.50.1",
4
4
  "description": "宁静号 ACC harness(Native Cordis 插件)——给 DeepSeek Harness 装一个「AI 工作区」:11 个工具(container_fs/container_trajectory/dashboard/container_git/msm/praxis/handyman/localstore/container_admin/im-bridge/acc-diag)+ 机械约束(安全模式/工作区围墙/密钥守卫/对外输出守卫)+ 工作日志与原地重建 + 网页登录入口/微信桥/子角色/对外问答页/trajectory 唤醒注册表。适配 DSH 0.1.7-rc.1。",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -54,21 +54,21 @@
54
54
  },
55
55
  "peerDependencies": {
56
56
  "@deepseek-ai/cordis": "^4.0.4",
57
- "@deepseek-ai/dsh-agent": "^0.1.7-rc.1",
58
- "@deepseek-ai/dsh-agent-loop": "^0.1.7-rc.1",
59
- "@deepseek-ai/dsh-agent-preset-registry": "^0.1.7-rc.1",
60
- "@deepseek-ai/dsh-compaction": "^0.1.7-rc.1",
61
- "@deepseek-ai/dsh-host-webserver": "^0.1.7-rc.1",
62
- "@deepseek-ai/dsh-llm": "^0.1.7-rc.1",
63
- "@deepseek-ai/dsh-session": "^0.1.7-rc.1",
64
- "@deepseek-ai/dsh-session-title": "^0.1.7-rc.1",
65
- "@deepseek-ai/dsh-shell": "^0.1.7-rc.1",
66
- "@deepseek-ai/dsh-shell-env": "^0.1.7-rc.1",
67
- "@deepseek-ai/dsh-skill": "^0.1.7-rc.1",
68
- "@deepseek-ai/dsh-system-prompt": "^0.1.7-rc.1",
69
- "@deepseek-ai/dsh-tools": "^0.1.7-rc.1",
70
- "@deepseek-ai/dsh-web": "^0.1.7-rc.1",
71
- "@deepseek-ai/dsh-web-fetch-http": "^0.1.7-rc.1",
57
+ "@deepseek-ai/dsh-agent": "^0.1.7-rc.1 || ^0.2.0-rc.1",
58
+ "@deepseek-ai/dsh-agent-loop": "^0.1.7-rc.1 || ^0.2.0-rc.1",
59
+ "@deepseek-ai/dsh-agent-preset-registry": "^0.1.7-rc.1 || ^0.2.0-rc.1",
60
+ "@deepseek-ai/dsh-compaction": "^0.1.7-rc.1 || ^0.2.0-rc.1",
61
+ "@deepseek-ai/dsh-host-webserver": "^0.1.7-rc.1 || ^0.2.0-rc.1",
62
+ "@deepseek-ai/dsh-llm": "^0.1.7-rc.1 || ^0.2.0-rc.1",
63
+ "@deepseek-ai/dsh-session": "^0.1.7-rc.1 || ^0.2.0-rc.1",
64
+ "@deepseek-ai/dsh-session-title": "^0.1.7-rc.1 || ^0.2.0-rc.1",
65
+ "@deepseek-ai/dsh-shell": "^0.1.7-rc.1 || ^0.2.0-rc.1",
66
+ "@deepseek-ai/dsh-shell-env": "^0.1.7-rc.1 || ^0.2.0-rc.1",
67
+ "@deepseek-ai/dsh-skill": "^0.1.7-rc.1 || ^0.2.0-rc.1",
68
+ "@deepseek-ai/dsh-system-prompt": "^0.1.7-rc.1 || ^0.2.0-rc.1",
69
+ "@deepseek-ai/dsh-tools": "^0.1.7-rc.1 || ^0.2.0-rc.1",
70
+ "@deepseek-ai/dsh-web": "^0.1.7-rc.1 || ^0.2.0-rc.1",
71
+ "@deepseek-ai/dsh-web-fetch-http": "^0.1.7-rc.1 || ^0.2.0-rc.1",
72
72
  "@deepseek-ai/schemastery": "^3.18.4",
73
73
  "cordis": "^4.0.0-rc.7"
74
74
  },