@shgroup/dsh-serenity-hooks 1.49.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/lib/index.js CHANGED
@@ -6,7 +6,7 @@ import { A as localIdStamp, C as setActiveSessionInfo, D as isoLocal, E as useSe
6
6
  import { a as readWeixinCredential, c as weixinInboundDir, d as LOCALSTORE_SCOPES, f as checkLocalstoreGitCompliance, g as runLocalStore, h as readStore, i as matchWeixinRoute, l as weixinSessionIdFor, m as readGitTrack, n as extractWeixinText, o as readWeixinSettings, p as localstorePath, r as hasVoiceItem, s as sanitizeFileName$1, t as extractWeixinMedia } from "./weixin-route-CWMRa36W.js";
7
7
  import { a as unregisterSkiffSession$1, i as skiffSessionSnapshot$1, n as skiffRoleFor$1, o as ACC_VERSION, r as skiffSessionInfo$1, t as registerSkiffSession$1 } from "./skiff-registry-BjIX_KD1.js";
8
8
  import { a as readSkiffRoles, c as roleMsmWhitelist, d as systemPromptSource, f as trajectorySubset, i as isSkiffSessionId, l as roleToolWhitelist, n as buildSkiffBasePrompt, o as resolveRoleSystemPrompt, r as createRolePromptReader, s as resolveSkiffKind } from "./skiff-role-KqQA6pkr.js";
9
- import { a as FACE_PORTS, c as MECHANISM_PORTS, i as ACP_HTTP_PORT, l as SKIFF_DEBUG_PORT, n as registerSettingsSection, o as GATEWAY_PORT, s as MAIN_WEB_PORT, t as readSimpleSettings, u as WEIXIN_SEND_PORT } from "./settings-section-B-KOyOYn.js";
9
+ import { a as FACE_PORTS, c as MECHANISM_PORTS, i as ACP_HTTP_PORT, l as SKIFF_DEBUG_PORT, n as registerSettingsSection, o as GATEWAY_PORT, s as MAIN_WEB_PORT, t as readSimpleSettings, u as WEIXIN_SEND_PORT } from "./settings-section-EmaYsI-e.js";
10
10
  import { a as markdownToPlainText, c as sendTyping, i as getUpdates, l as sniffImageExt, n as downloadMedia, o as sendFileMessage, r as getConfig, s as sendTextMessage, t as TypingStatus } from "./weixin-api-CtFYm53S.js";
11
11
  import z from "@deepseek-ai/schemastery";
12
12
  import { defineTool } from "@deepseek-ai/dsh-tools";
@@ -18,7 +18,7 @@ import { promisify } from "node:util";
18
18
  import { platform as platform$1 } from "node:process";
19
19
  import { fileURLToPath } from "node:url";
20
20
  import { createUserMessage } from "@deepseek-ai/dsh-llm";
21
- import { createHmac, randomBytes, randomUUID, scryptSync, timingSafeEqual } from "node:crypto";
21
+ import { createHash, createHmac, randomBytes, randomUUID, scryptSync, timingSafeEqual } from "node:crypto";
22
22
  import { createServer, request } from "node:http";
23
23
  import * as zlib from "node:zlib";
24
24
  import { deriveEventMessage } from "@deepseek-ai/dsh-session";
@@ -1984,11 +1984,11 @@ const HANDYMAN_GUIDE = `# handyman — Scale-Up Usage Guide (guide)
1984
1984
  ## Two modes (v1.31.3; default = foreground)
1985
1985
  | | foreground (default) | background |
1986
1986
  |---|---|---|
1987
- | What | one serial child agent runs the task ONCE and returns its final text | loop-validated worker: hard while-loop + random completion code |
1988
- | Use when | you need exactly one result back now (per-case testers, single conversions) | long/unattended work needing anti-early-finish, resumability, or parallel jobs |
1987
+ | What | child agent(s) run the task ONCE and return their final text (jobs=[...] ⇒ parallel, cap 5) | loop-validated worker: hard while-loop + random completion code |
1988
+ | Use when | you need result(s) back now (per-case testers, single conversions, parallel fan-out) | long/unattended work needing anti-early-finish, resumability, or parallel jobs |
1989
1989
  | Loop / validation | none | stop-token is the ONLY completion condition; round cap (default 100); auto-restart (≤100) |
1990
1990
  | Progress file | none | AGENT_SESSIONS/handyman-<label>.md/.json (same label resumes) |
1991
- | Parallel jobs | no (call once per task) | yes (jobs=[...], handyman.maxParallel default 10) |
1991
+ | Parallel jobs | **yes** — jobs=[{task,label?,model?},...], **cap 5 (fixed)** | yes (jobs=[...], handyman.maxParallel default 10) |
1992
1992
  | Mechanism | host delegation service ctx.subagents.start("spawn") + agentOptions | ctx.agents.create + internal while-loop |
1993
1993
  | Model source | CCC handyman.models whitelist (same for both) | same |
1994
1994
 
@@ -1997,7 +1997,7 @@ Before calling handyman, load eap (acc-eap skill) and design the "scale-up handy
1997
1997
 
1998
1998
  ### 1. Task decomposition (E↑ Explicit)
1999
1999
  - Split large tasks into explicit subtasks: each subtask defines goal / input / boundaries (what to do, what not to do) / acceptance criteria
2000
- - Make dependencies explicit: dependent tasks run serially, independent ones can run in parallel (background mode)
2000
+ - Make dependencies explicit: dependent tasks run serially, independent ones can run in parallel (both modes)
2001
2001
 
2002
2002
  ### 2. Prompt design (handyman's task parameter)
2003
2003
  - task must be detailed, fixed, and EAP-compliant: clear goal, drawn boundaries, decidable acceptance criteria
@@ -2025,12 +2025,15 @@ Before calling handyman, load eap (acc-eap skill) and design the "scale-up handy
2025
2025
  (and "handyman.defaultModel") to a provider listed as available, or activate/register the provider on this
2026
2026
  machine. **Do not retry the same call unchanged** — it fails identically.
2027
2027
 
2028
- ### 4. Parallel strategy (background mode only)
2029
- - Independent subtasks can run in parallel via handyman(mode="background", jobs=[...]): each job gets its own label + task + stop token + progress file
2028
+ ### 4. Parallel strategy (both modes)
2029
+ - Independent subtasks can run in parallel:
2030
+ · **foreground** — handyman(jobs=[{task,label?,model?},...]) ⇒ fan-out of custom-model subagents, **cap 5**,
2031
+ results come back **in the order you passed them** (label is only a display name here; per-job "model" allowed)
2032
+ · **background** — handyman(mode="background", jobs=[...]): each job gets its own label + task + stop token + progress file
2030
2033
  - Concurrency safety guaranteed: unique sessionId (handyman-<label>-<uuid>), progress files isolated per label
2031
2034
  (AGENT_SESSIONS/handyman-<label>.json) — same label resumes, different labels never interfere
2032
- - Parallel cap: handyman.maxParallel (default 10 — cheap models are cheap)
2033
- - Aggregation: after each parallel job produces progress, the main agent merges (or spawns one aggregation handyman)
2035
+ - Parallel cap: **foreground 5 (fixed)** / background handyman.maxParallel (default 10 — cheap models are cheap)
2036
+ - Aggregation: foreground returns all outputs in one result; background: after each parallel job produces progress, the main agent merges (or spawns one aggregation handyman)
2034
2037
  - For programmable pipeline/phase orchestration at scale, use the platform's workflow tool instead
2035
2038
 
2036
2039
  ## Completion criteria
@@ -3130,7 +3133,7 @@ function toolsBlock() {
3130
3133
  " container_git— git operations (status/commit/push/log)",
3131
3134
  " msm — execute a registered CCC MSM: msm(name, args); partial name returns candidates; inspect=true shows usage",
3132
3135
  " praxis — actionable theory injection: praxis (index) / praxis eap / praxis neat / praxis cce",
3133
- " handyman — delegate work to a worker agent on a CCC-whitelisted model; mode=\"foreground\" (default) = one serial child returns its final text; mode=\"background\" = loop-validated worker (completion code + round cap + restart + progress file), jobs=[] orchestrates parallel work",
3136
+ " handyman — delegate work to a worker agent on a CCC-whitelisted model; mode=\"foreground\" (default) = child agent(s) run once and return their final text (jobs=[...] fans out in parallel, cap 5, per-job model); mode=\"background\" = loop-validated worker (completion code + round cap + restart + progress file), jobs=[] orchestrates parallel work",
3134
3137
  " localstore — ACC local credential/config storage (CCC-root localstore.json; git policy localstore.gitTrack default deny)",
3135
3138
  " container_admin — container administration (the maintenance bay): role (Skiff roles: guide/validate/apply/list) / msm (register/deregister/check/guide/catalog/ccc-config) / config (view)",
3136
3139
  "",
@@ -4455,6 +4458,33 @@ function parseJobs(raw) {
4455
4458
  }
4456
4459
  return jobs;
4457
4460
  }
4461
+ /**
4462
+ * 🔴 **foreground 并行上限 = 5**(owner 2026-09-27 具名令:「handyman foreground 能否允许并行,最大 5;
4463
+ * 就当是支持自定模型的 subagent 用了,急需」)。
4464
+ *
4465
+ * 为什么是**硬编码**而不是又一个配置键:owner 的原话就是"最大 5"(同 `REBUILD-TODO` 那次的裁决口径
4466
+ * —— 设定项能不加就不加,见 S142 的「可砍设定项」)。若将来真要调,再按具名令改成配置。
4467
+ *
4468
+ * 为什么 foreground 也要限流(而不是直接复用 `handyman.maxParallel` = 10):**foreground 会占住主
4469
+ * agent 的这一轮**(调用方在等结果),并发越高越容易把宿主的并发面与用量推上去 ⇒ 前台给一个更紧的闸。
4470
+ */
4471
+ const FOREGROUND_MAX_PARALLEL = 5;
4472
+ /** foreground 的 jobs 解析(与 background 的差别:**label 可选** —— 前台 label 只是显示名,不命名进度文件) */
4473
+ function parseForegroundJobs(raw) {
4474
+ if (!Array.isArray(raw) || raw.length === 0) return null;
4475
+ const jobs = [];
4476
+ for (const item of raw) {
4477
+ if (typeof item !== "object" || item === null) return null;
4478
+ const rec = item;
4479
+ if (typeof rec.task !== "string" || rec.task.trim() === "") return null;
4480
+ jobs.push({
4481
+ task: rec.task,
4482
+ ...typeof rec.label === "string" && rec.label !== "" ? { label: rec.label } : {},
4483
+ ...typeof rec.model === "string" && rec.model !== "" ? { model: rec.model } : {}
4484
+ });
4485
+ }
4486
+ return jobs;
4487
+ }
4458
4488
  /** 拼接 SubagentResult.output(ContentBlock[])中的文本块 */
4459
4489
  function subagentOutputText(output) {
4460
4490
  if (!Array.isArray(output)) return "";
@@ -4678,11 +4708,11 @@ async function runHandymanJob(ctx, opts) {
4678
4708
  function createHandymanTool(ctx) {
4679
4709
  return defineTool({
4680
4710
  name: "handyman",
4681
- description: "The handyman (杂工): delegate work to a worker agent on a CCC-configured model. Two modes (v1.31.3).\nMode \"foreground\" (DEFAULT): one serial child agent runs the task once and returns its final text — no loop, no completion-code validation, no progress file; use it for one-shot serial delegation (e.g. per-case testers that just gather evidence). Usage: handyman(task, [label], [model]).\nMode \"background\": the loop-validated worker — internal hard while-loop whose ONLY completion condition is the worker echoing this round's random completion code (stop token, prevents low-intelligence models from finishing early), round cap (default 100, resumable), automatic restart on abnormal stop (≤100), progress file AGENT_SESSIONS/handyman-<label>.md/.json (same label resumes), and parallel jobs (handyman.maxParallel, default 10). Usage: handyman(mode=\"background\", task, label, [model]) or handyman(mode=\"background\", jobs=[{task,label,model?},...]).\nChoosing: need exactly one result back now → foreground; need anti-early-finish guarantees, resumability or parallel jobs → background.\nModel: only models whitelisted in .opencode/serenity.json \"handyman.models\" (missing config = error); default reads handyman.defaultModel. Both modes share the same whitelist and default.\nProvider check: the whitelist lives in the CCC (it travels with the repo) while provider adapters are registered per machine by the host — so before spawning, the provider of the resolved model is verified against the adapters present on THIS machine. If it is missing you get the available providers plus the exact fix (foreground: in `diagnostic`; background: thrown), never an opaque failure.\nRecursion: a worker's tool set excludes handyman itself (orchestration belongs to the main agent).\nGuide: handyman(guide=true) prints the scale-up usage guide.",
4711
+ description: "The handyman (杂工): delegate work to a worker agent on a CCC-configured model. Two modes (v1.31.3).\nMode \"foreground\" (DEFAULT): one serial child agent runs the task once and returns its final text — no loop, no completion-code validation, no progress file; use it for one-shot serial delegation (e.g. per-case testers that just gather evidence). Usage: handyman(task, [label], [model]).\nMode \"background\": the loop-validated worker — internal hard while-loop whose ONLY completion condition is the worker echoing this round's random completion code (stop token, prevents low-intelligence models from finishing early), round cap (default 100, resumable), automatic restart on abnormal stop (≤100), progress file AGENT_SESSIONS/handyman-<label>.md/.json (same label resumes), and parallel jobs (handyman.maxParallel, default 10). Usage: handyman(mode=\"background\", task, label, [model]) or handyman(mode=\"background\", jobs=[{task,label,model?},...]).\nChoosing: need exactly one result back now → foreground; need anti-early-finish guarantees or resumability → background; need N results back now (parallel, per-job model) → foreground with jobs (cap 5).\nModel: only models whitelisted in .opencode/serenity.json \"handyman.models\" (missing config = error); default reads handyman.defaultModel. Both modes share the same whitelist and default.\nProvider check: the whitelist lives in the CCC (it travels with the repo) while provider adapters are registered per machine by the host — so before spawning, the provider of the resolved model is verified against the adapters present on THIS machine. If it is missing you get the available providers plus the exact fix (foreground: in `diagnostic`; background: thrown), never an opaque failure.\nRecursion: a worker's tool set excludes handyman itself (orchestration belongs to the main agent).\nGuide: handyman(guide=true) prints the scale-up usage guide.",
4682
4712
  parameters: {
4683
4713
  mode: {
4684
4714
  type: "string",
4685
- description: "Delegation mode: \"foreground\" (default — one serial child, returns its final text) or \"background\" (loop-validated worker with completion-code check, round cap, auto-restart, progress file, parallel jobs)"
4715
+ description: "Delegation mode: \"foreground\" (default — child agent(s) run once and return their final text; accepts `jobs` for parallel fan-out, cap 5) or \"background\" (loop-validated worker with completion-code check, round cap, auto-restart, progress file, parallel jobs)"
4686
4716
  },
4687
4717
  task: {
4688
4718
  type: "string",
@@ -4702,7 +4732,7 @@ function createHandymanTool(ctx) {
4702
4732
  },
4703
4733
  jobs: {
4704
4734
  type: "array",
4705
- description: "Multi-job orchestration (workflow capability): [{task, label, model?}, ...] — runs jobs in parallel (cap handyman.maxParallel, default 10)",
4735
+ description: "Multi-job orchestration (workflow capability): [{task, label?, model?}, ...] — runs jobs in parallel. Cap: foreground 5 (fixed) / background handyman.maxParallel (default 10). Results keep the order you passed them in.",
4706
4736
  items: {
4707
4737
  type: "object",
4708
4738
  properties: {
@@ -4732,8 +4762,31 @@ function createHandymanTool(ctx) {
4732
4762
  const mode = args.mode === "background" ? "background" : "foreground";
4733
4763
  if (args.mode !== void 0 && args.mode !== "background" && args.mode !== "foreground") throw new Error("handyman: mode must be \"foreground\" (default) or \"background\"");
4734
4764
  if (mode === "foreground") {
4765
+ if (args.jobs !== void 0) {
4766
+ const jobs = parseForegroundJobs(args.jobs);
4767
+ if (jobs === null) throw new Error("handyman foreground: jobs must be [{task, label?, model?}, ...] (every job requires a non-empty task)");
4768
+ if (jobs.length > FOREGROUND_MAX_PARALLEL) throw new Error(`handyman foreground: ${jobs.length} jobs exceed the foreground parallel cap ${FOREGROUND_MAX_PARALLEL} (split into batches, or use mode="background" whose cap is handyman.maxParallel)`);
4769
+ const results = await Promise.all(jobs.map((job) => runForegroundJob(ctx, {
4770
+ task: job.task,
4771
+ ...job.label === void 0 ? {} : { label: job.label },
4772
+ model: job.model ?? (typeof args.model === "string" && args.model !== "" ? args.model : hc.defaultModel),
4773
+ models: hc.models,
4774
+ parent: exec.agent,
4775
+ signal: exec.signal
4776
+ })));
4777
+ const done = results.every((r) => r.done);
4778
+ return {
4779
+ mode: "foreground",
4780
+ done,
4781
+ jobs: results,
4782
+ usage: {
4783
+ how: `foreground parallel mode starts ${results.length} child agents at once through the host delegation service (ctx.subagents.start "spawn"), awaits all of them, and returns each child's final text. No loop, no completion-code validation, no progress file. Cap = ${FOREGROUND_MAX_PARALLEL}.`,
4784
+ model: "restricted to the CCC whitelist handyman.models; per-job model overrides the call-level model; default reads handyman.defaultModel",
4785
+ next: done ? "All children completed; use each job's output directly (results are in the same order as the jobs you passed)" : "Some children did not complete — inspect each job's diagnostic/output (results keep the order you passed); for anti-early-finish guarantees use mode=\"background\""
4786
+ }
4787
+ };
4788
+ }
4735
4789
  if (typeof args.task !== "string" || args.task.trim() === "") throw new Error("handyman foreground: task is required (and must be self-contained — the child does not share this conversation)");
4736
- if (args.jobs !== void 0) throw new Error("handyman foreground: jobs is background-only — call foreground once per task, or use mode=\"background\" for parallel jobs");
4737
4790
  const model = typeof args.model === "string" && args.model !== "" ? args.model : hc.defaultModel;
4738
4791
  const res = await runForegroundJob(ctx, {
4739
4792
  task: args.task,
@@ -9836,12 +9889,64 @@ function registerCompactRetention(ctx, opts = {}) {
9836
9889
  * WebUI 停靠栏的数据源:ACC 版本 / CCC 根 / safe-mode 状态 / 黑名单 / keeper 阈值 / handyman 模型。
9837
9890
  * setSafeMode 直接读写 .serenity-safe-on 标记(守卫实时读取,写即生效)。
9838
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
+ }
9839
9937
  /**
9840
9938
  * 读取已安装 DSH CLI 版本;读不到返回 null。
9841
- * 跨平台(Windows 审计问题 13):Windows npm 全局装在 %APPDATA%\npm(非 ~/.npm-global)——
9842
- * 依次尝试 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
+ * 那时"本机装了哪个版本"仍需一个答案,只是证据等级更低。
9843
9946
  */
9844
9947
  function readDshVersion() {
9948
+ const fromEntry = dshVersionFromEntry(process.argv[1]);
9949
+ if (fromEntry !== null) return fromEntry;
9845
9950
  const candidates = [];
9846
9951
  if (process.env.npm_config_prefix) candidates.push(join(process.env.npm_config_prefix, "lib", "node_modules", "@deepseek-ai", "dsh"));
9847
9952
  if (process.env.APPDATA) candidates.push(join(process.env.APPDATA, "npm", "node_modules", "@deepseek-ai", "dsh"));
@@ -10249,7 +10354,7 @@ function registerStatusApi(ctx, opts = {}) {
10249
10354
  return;
10250
10355
  }
10251
10356
  const settings = readAdvancedSettings();
10252
- const { readSimpleSettings } = await import("./settings-section-B-KOyOYn.js").then((n) => n.r);
10357
+ const { readSimpleSettings } = await import("./settings-section-EmaYsI-e.js").then((n) => n.r);
10253
10358
  const simple = readSimpleSettings();
10254
10359
  const allowed = settings.publicAsk.allowed;
10255
10360
  const port = simple.acpHttpPort ?? ACP_HTTP_PORT;
@@ -10964,12 +11069,43 @@ const HOST_SERVICES = [
10964
11069
  required: false
10965
11070
  }
10966
11071
  ];
10967
- /** dsp 被验证过的宿主版本范围(与 package.json peerDependencies 同源,单一真相源) */
10968
- const REQUIRED_HOST_RANGE = "^0.1.7-rc.1";
10969
- /** 范围 floor:剥掉 caret/比较前缀(`^0.1.7-rc.1` → `0.1.7-rc.1`),供 {@link checkHostVersion} 使用 */
10970
- const REQUIRED_HOST_FLOOR = REQUIRED_HOST_RANGE.replace(/^[\^~>=<\s]+/, "");
10971
- /** 范围 ceiling:`^0.1.x` 的上界 = `<0.2.0`(显式常量,见 {@link checkHostVersion} 注释) */
10972
- 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(" || ");
10973
11109
  /** 最小 semver 比较(无依赖):返回 -1 / 0 / 1;解析失败返回 null */
10974
11110
  function compareSemver(a, b) {
10975
11111
  const parse = (v) => {
@@ -10994,37 +11130,54 @@ function compareSemver(a, b) {
10994
11130
  return ra < rb ? -1 : 1;
10995
11131
  }
10996
11132
  /**
10997
- * 宿主版本是否落在 dsp 被验证的范围(`REQUIRED_HOST_RANGE`)。
11133
+ * 宿主版本是否落在 dsp **被验证过的档**({@link VERIFIED_HOST_BANDS})之内。
10998
11134
  * 无版本信息 → 视为未知(ok=false,detail 说明),不猜测。
10999
11135
  *
11000
- * floor/ceiling **从 `REQUIRED_HOST_RANGE` 派生**(v1.31.6,0.1.5-rc.1 适配轮):
11001
- * 此前 floor 是独立硬编码字面量——改 `REQUIRED_HOST_RANGE` 而忘改 floor 会造出
11002
- * "常量说 0.1.5、判定用 0.1.2" 的双真相源。ceiling 无法从 caret 语义机械派生(手写比较器
11003
- * 不实现 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 语义**(本条只做"区间内/外"),这也是必须逐档判的原因。
11004
11141
  */
11005
11142
  function checkHostVersion(version) {
11006
11143
  if (version === null) return {
11007
11144
  ok: false,
11008
11145
  detail: `host version unknown (required ${REQUIRED_HOST_RANGE})`
11009
11146
  };
11010
- const floor = compareSemver(version, REQUIRED_HOST_FLOOR);
11011
- const ceiling = compareSemver(version, REQUIRED_HOST_CEILING);
11012
- 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 {
11013
11166
  ok: false,
11014
11167
  detail: `host version "${version}" not parseable (required ${REQUIRED_HOST_RANGE})`
11015
11168
  };
11016
- if (floor < 0) return {
11169
+ if (matched) return {
11170
+ ok: true,
11171
+ detail: `host ${version} within ${REQUIRED_HOST_RANGE}`
11172
+ };
11173
+ if (belowEveryFloor) return {
11017
11174
  ok: false,
11018
11175
  detail: `host ${version} is below the verified floor (required ${REQUIRED_HOST_RANGE})`
11019
11176
  };
11020
- if (ceiling >= 0) return {
11177
+ return {
11021
11178
  ok: false,
11022
11179
  detail: `host ${version} is outside the verified range (required ${REQUIRED_HOST_RANGE})`
11023
11180
  };
11024
- return {
11025
- ok: true,
11026
- detail: `host ${version} within ${REQUIRED_HOST_RANGE}`
11027
- };
11028
11181
  }
11029
11182
  /** 结构化读取宿主服务(lazy → ctx.get;injected → 直接属性,属性缺失再试 ctx.get) */
11030
11183
  function readService(ctx, name, access) {
@@ -15848,6 +16001,257 @@ qInput.focus()
15848
16001
  </body>
15849
16002
  </html>`;
15850
16003
  }
16004
+ //#endregion
16005
+ //#region src/human-channel.ts
16006
+ /**
16007
+ * human-channel.ts — 人机信道的**机制层**(机器级;2026-09-26 owner 定案 ⇒ 方案 §2.6)
16008
+ *
16009
+ * ## 为什么单独一个模块(方案 §5-12)
16010
+ * 今天"机制"与"渠道实现"混在 `weixin-bridge.ts` 里。P0-2 要立的这条不变量是**机器级**的 ——
16011
+ * 它**跨 CCC**、且与"微信"无关(飞书桥/邮件桥同样要守)⇒ 抽到这里;`weixin-bridge` 只做接线。
16012
+ *
16013
+ * ## 本模块唯一的不变量
16014
+ * 🔴 **一账号一 poller**:同一个**账号**(= 同一个 bot 身份)在**同一时刻只有一个轮询者**。
16015
+ * 今天的形态是"**按 CCC 起桥**"(`for entry of listCccs ⇒ syncCccBridge`)⇒ 若两个 CCC 各自
16016
+ * 配了**同一个**微信账号(同一份凭据),就会**两处轮询同一条消息** ⇒ 重复投递 + 重复落盘
16017
+ * (方案 §3.6 的"顺带解决"就是这一条)。
16018
+ *
16019
+ * ## 🔴 账号的"身份"是什么(本模块最容易写错的一处)
16020
+ * **不是 `accountId`** —— 它是**每 CCC 的本地标签**(今天两处都叫 `wechat-1` 完全可能是两个
16021
+ * 不同的微信号)。跨 CCC 能辨认同一个 bot 的**唯一**依据是**凭据内容**(token)。
16022
+ * ⇒ `accountIdentity()` 以 **token 的哈希**为身份;**没有 token** 时才退回
16023
+ * `ccc:<root>|<accountId>`(那时身份里带了 root ⇒ 跨 CCC 不可能撞车,也就不会误判成"同一账号")。
16024
+ *
16025
+ * ## 边界
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`),可独立单测。
16033
+ */
16034
+ /**
16035
+ * 算账号的**机器级身份**。
16036
+ *
16037
+ * - 有 token ⇒ `token:<sha256 前 16 位>`(**跨 CCC 可辨同一 bot**;不落原文,日志/状态面可安全展示)
16038
+ * - 无 token(未扫码绑定等)⇒ `ccc:<root>|<accountId>`(**退化为每 CCC 唯一** ⇒ 不会误判撞车)
16039
+ * @param root CCC 根
16040
+ * @param accountId 该 CCC 本地账号键
16041
+ * @param token 凭据 token(可缺省)
16042
+ */
16043
+ function accountIdentity(root, accountId, token) {
16044
+ const t = (token ?? "").trim();
16045
+ if (t !== "") return `token:${createHash("sha256").update(t).digest("hex").slice(0, 16)}`;
16046
+ return `ccc:${root}|${accountId}`;
16047
+ }
16048
+ /**
16049
+ * 归属判定(**纯函数**:不改任何状态)。
16050
+ *
16051
+ * 规则三条(**同批**与**跨批**刻意用不同判据,别合并——合并会放过"同一 CCC 里两个本地键绑同一个 bot"):
16052
+ * ① **同批**内同身份重复 ⇒ **一律跳过**(后者)—— 与是哪个 CCC 无关。实例:某 CCC 的账号表里
16053
+ * `wechat-1` 与 `wechat-2` 绑的是同一个 bot ⇒ 若豁免"自己",它就会为同一个 bot 起**两个**循环。
16054
+ * ② 身份已被**别的** CCC 占着 ⇒ 跳过(记 `ownedBy`)—— 这就是"一账号一 poller"的主判据。
16055
+ * ③ 身份被**自己**占着 ⇒ **照常 claim**(热重建语义:`syncCccBridge` 会先释放自己的旧归属再重新
16056
+ * claim;把"自己占着"也判成冲突会让**配置一变就再也起不来**)。
16057
+ * @param requests 本批请求(顺序即优先级:先到先得)
16058
+ * @param owners 当前归属表(`pollerOwners()`)
16059
+ */
16060
+ function planPollerClaims(requests, owners) {
16061
+ const claims = [];
16062
+ const skipped = [];
16063
+ /** 本批占位(判据 ①:不带"自己"豁免) */
16064
+ const batch = /* @__PURE__ */ new Map();
16065
+ for (const request of requests) {
16066
+ const inBatch = batch.get(request.identity);
16067
+ if (inBatch) {
16068
+ skipped.push({
16069
+ request,
16070
+ ownedBy: inBatch
16071
+ });
16072
+ continue;
16073
+ }
16074
+ const existing = owners.get(request.identity);
16075
+ if (existing && existing.root !== request.root) {
16076
+ skipped.push({
16077
+ request,
16078
+ ownedBy: existing
16079
+ });
16080
+ continue;
16081
+ }
16082
+ batch.set(request.identity, {
16083
+ root: request.root,
16084
+ accountId: request.accountId
16085
+ });
16086
+ claims.push(request);
16087
+ }
16088
+ return {
16089
+ claims,
16090
+ skipped
16091
+ };
16092
+ }
16093
+ const owners = /* @__PURE__ */ new Map();
16094
+ /** 当前归属表(只读视图;供判定与状态面用) */
16095
+ function pollerOwners() {
16096
+ return owners;
16097
+ }
16098
+ /** 落实一批 claim(调用方在**真正起了 poller 之后**调它) */
16099
+ function commitPollerClaims(plan) {
16100
+ for (const claim of plan.claims) owners.set(claim.identity, {
16101
+ root: claim.root,
16102
+ accountId: claim.accountId
16103
+ });
16104
+ }
16105
+ /**
16106
+ * 释放某 CCC 的**全部**归属(热重建/停止时先调它,再重新 claim)。
16107
+ *
16108
+ * 🔴 为什么"先全放再 claim"是安全的:`syncCccBridge` 内这一段是**同步**的(中间没有 await)
16109
+ * ⇒ 不存在"刚放开就被别人抢走"的窗口;且**别人**在这次调用里本来就抢不到(它的请求会被
16110
+ * 判成"被 <本 CCC> 占着"而跳过)⇒ 归属**粘在本 CCC**,不会在两个 CCC 之间来回漂。
16111
+ * @param root 要释放的 CCC 根
16112
+ * @returns 被释放的身份数(日志/判据用)
16113
+ */
16114
+ function releasePollersOf(root) {
16115
+ let released = 0;
16116
+ for (const [identity, owner] of [...owners]) if (owner.root === root) {
16117
+ owners.delete(identity);
16118
+ released++;
16119
+ }
16120
+ return released;
16121
+ }
16122
+ /** 清空归属表(插件 dispose) */
16123
+ function releaseAllPollers() {
16124
+ owners.clear();
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
+ }
15851
16255
  const manualSessions = /* @__PURE__ */ new Map();
15852
16256
  /**
15853
16257
  * 本轮是否已成功发送(**由桥在每轮开始时清空** `clearSentThisTurn`;闸门只置位不清空——
@@ -16136,6 +16540,7 @@ function invokeWeixinHook(root, hookRel, event) {
16136
16540
  //#region src/weixin-bridge.ts
16137
16541
  var weixin_bridge_exports = /* @__PURE__ */ __exportAll({
16138
16542
  handleIncoming: () => handleIncoming,
16543
+ handleMachineIncoming: () => handleMachineIncoming,
16139
16544
  manualOutputFallbackNeeded: () => manualOutputFallbackNeeded,
16140
16545
  registerWeixinBridge: () => registerWeixinBridge,
16141
16546
  resolveWeixinAccount: () => resolveWeixinAccount,
@@ -16143,9 +16548,21 @@ var weixin_bridge_exports = /* @__PURE__ */ __exportAll({
16143
16548
  stopAllBridges: () => stopAllBridges,
16144
16549
  stopCccBridge: () => stopCccBridge,
16145
16550
  syncCccBridge: () => syncCccBridge,
16551
+ syncHumanChannel: () => syncHumanChannel,
16146
16552
  weixinBridgeStatus: () => weixinBridgeStatus
16147
16553
  });
16148
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();
16149
16566
  /** 轮询间隔(getupdates 失败后重试延迟;成功 = 立即续轮询) */
16150
16567
  const POLL_RETRY_MS = 3e3;
16151
16568
  /** 媒体大小上限(M6:20MB,对齐 vlm-describe;超限降级告知不落盘) */
@@ -16199,7 +16616,7 @@ async function sendTypingStop(cred, accountId, fromUserId) {
16199
16616
  }
16200
16617
  }
16201
16618
  /** 单账号轮询循环:getupdates 长轮询 → 逐消息分发 → 回复回写 */
16202
- async function runAccountLoop(ctx, root, accountId, cred, loop) {
16619
+ async function runAccountLoop(ctx, root, accountId, cred, loop, machine = false) {
16203
16620
  let buf = "";
16204
16621
  while (!loop.stopped) {
16205
16622
  loop.lastPollAt = Date.now();
@@ -16214,7 +16631,8 @@ async function runAccountLoop(ctx, root, accountId, cred, loop) {
16214
16631
  for (const msg of resp.msgs ?? []) {
16215
16632
  if (loop.stopped) break;
16216
16633
  if (msg.message_type !== 1) continue;
16217
- 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);
16218
16636
  }
16219
16637
  } catch (err) {
16220
16638
  loop.lastError = err instanceof Error ? err.message : String(err);
@@ -16257,6 +16675,106 @@ function manualOutputFallbackNeeded(input) {
16257
16675
  return input.fallbackOnNoSend === true && input.guardActive && !input.sentThisTurn && input.answer !== "";
16258
16676
  }
16259
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
+ /**
16260
16778
  * 处理单条微信消息:路由 → skiff 会话(固定 id 创建/延续)→ 提问 → 回复回写。
16261
16779
  *
16262
16780
  * 会话语义:`weixinSessionIdFor(fromUserId)` 固定可重建——同用户长期同一会话;
@@ -16273,16 +16791,35 @@ function manualOutputFallbackNeeded(input) {
16273
16791
  * `_tmp/weixin-inbound/<userhash>/` → question 注入「存在性 + 路径」(ACC 层只保证
16274
16792
  * 可达性——"让会话知道文件的存在并可以拿到";识别/解析归角色 LLM 决策,不编排)。
16275
16793
  * 降级不静默:下载失败 / 超 20MB → 注入说明进对话;typing 窗口覆盖下载(M7)。
16794
+ *
16795
+ * 🔴 **P0-3(2026-09-27):路由改由机器级订阅表决定**(本函数的 `root` 入参退为
16796
+ * **回退目标**)。两档语义刻意分开(方案 §3.1):**未命中 ⇒ 不投递 + 留痕**(静默档);
16797
+ * **目标不存在(CCC / 角色)⇒ 响亮报错**(`console.error` + 机器级留痕)。两者都
16798
+ * **不是**"投给默认角色"。
16276
16799
  */
16277
- async function handleIncoming(ctx, root, accountId, cred, msg) {
16800
+ async function handleIncoming(ctx, root, accountId, cred, msg, preset) {
16278
16801
  const fromUserId = msg.from_user_id;
16279
16802
  if (!fromUserId) return;
16280
16803
  const text = extractWeixinText(msg);
16281
16804
  const mediaRefs = extractWeixinMedia(msg);
16282
16805
  const settings = readWeixinSettings(root);
16283
- if (!settings.enabled) return;
16284
- const roleName = matchWeixinRoute(settings.routes ?? [], fromUserId);
16285
- 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;
16286
16823
  if (!text && mediaRefs.length === 0) {
16287
16824
  if (hasVoiceItem(msg)) await sendTextMessage({
16288
16825
  baseUrl: cred.baseUrl,
@@ -16294,11 +16831,6 @@ async function handleIncoming(ctx, root, accountId, cred, msg) {
16294
16831
  return;
16295
16832
  }
16296
16833
  try {
16297
- const role = readSkiffRoles(root).get(roleName);
16298
- if (!role) {
16299
- console.log(`[serenity-hooks] weixin-bridge: 路由命中角色 "${roleName}" 但该 CCC 未定义(检查 skiff.roles)`);
16300
- return;
16301
- }
16302
16834
  const sessionId = weixinSessionIdFor(fromUserId);
16303
16835
  const existing = getSkiffAgent(sessionId);
16304
16836
  const hc = readHandymanConfig(root);
@@ -16458,8 +16990,38 @@ async function handleIncoming(ctx, root, accountId, cred, msg) {
16458
16990
  }
16459
16991
  }
16460
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
+ /**
16461
17018
  * 启动/重建某 CCC 的桥:读配置 → 对每个 enabled + 有凭据的账号启动轮询循环。
16462
17019
  * 已存在(配置变化热重建)→ 先停旧循环再启动新的。
17020
+ *
17021
+ * 🔴 **P0-2(2026-09-27,方案 §2.6):一账号一 poller** —— 起循环**之前**先过机器级归属判定
17022
+ * (`human-channel.ts`):身份被**别的** CCC 占着的账号**不起循环**,只留一条响亮日志。
17023
+ * 身份 = **凭据 token 的哈希**(不是 `accountId`:那是每 CCC 的本地标签,跨 CCC 无意义)。
17024
+ * 先 `releasePollersOf(root)` 再重新 claim —— 热重建语义与"归属粘住不漂"的论证见该函数注释。
16463
17025
  */
16464
17026
  function syncCccBridge(ctx, root) {
16465
17027
  const existing = bridges.get(root);
@@ -16467,41 +17029,164 @@ function syncCccBridge(ctx, root) {
16467
17029
  for (const loop of existing.loops.values()) loop.stopped = true;
16468
17030
  bridges.delete(root);
16469
17031
  }
17032
+ releasePollersOf(root);
16470
17033
  const settings = readWeixinSettings(root);
16471
17034
  if (!settings.enabled) return;
17035
+ const requests = [];
17036
+ const creds = /* @__PURE__ */ new Map();
17037
+ for (const account of settings.accounts ?? []) {
17038
+ if (account.enabled === false) continue;
17039
+ const cred = readWeixinCredential(root, account.accountId);
17040
+ if (!cred) continue;
17041
+ creds.set(account.accountId, cred);
17042
+ requests.push({
17043
+ root,
17044
+ accountId: account.accountId,
17045
+ identity: accountIdentity(root, account.accountId, cred.token)
17046
+ });
17047
+ }
17048
+ const plan = planPollerClaims(requests, pollerOwners());
17049
+ for (const { request, ownedBy } of plan.skipped) console.warn(`[serenity-hooks] ⚠ weixin-bridge: 账号 ${request.accountId}(ccc=${root})已被 ccc=${ownedBy.root} 的账号 ${ownedBy.accountId} 轮询 ⇒ 本处不重复起循环(一账号一 poller;方案 §2.6)`);
16472
17050
  const bridge = {
16473
17051
  root,
16474
17052
  loops: /* @__PURE__ */ new Map()
16475
17053
  };
16476
- for (const account of settings.accounts ?? []) {
16477
- if (account.enabled === false) continue;
16478
- const cred = readWeixinCredential(root, account.accountId);
17054
+ for (const claim of plan.claims) {
17055
+ const cred = creds.get(claim.accountId);
16479
17056
  if (!cred) continue;
16480
17057
  const loop = {
16481
17058
  stopped: false,
16482
17059
  lastPollAt: 0
16483
17060
  };
16484
- bridge.loops.set(account.accountId, loop);
16485
- runAccountLoop(ctx, root, account.accountId, cred, loop);
17061
+ bridge.loops.set(claim.accountId, loop);
17062
+ runAccountLoop(ctx, root, claim.accountId, cred, loop);
16486
17063
  }
17064
+ commitPollerClaims(plan);
16487
17065
  if (bridge.loops.size > 0) {
16488
17066
  bridges.set(root, bridge);
16489
17067
  console.log(`[serenity-hooks] ✓ weixin-bridge: ccc=${root} accounts=${[...bridge.loops.keys()].join(",")}`);
16490
17068
  }
16491
17069
  }
16492
- /** 停止某 CCC 的桥(移除账号/禁用时) */
17070
+ /** 停止某 CCC 的桥(移除账号/禁用时);同时释放它的账号归属 */
16493
17071
  function stopCccBridge(root) {
16494
17072
  const bridge = bridges.get(root);
17073
+ releasePollersOf(root);
16495
17074
  if (!bridge) return;
16496
17075
  for (const loop of bridge.loops.values()) loop.stopped = true;
16497
17076
  bridges.delete(root);
16498
17077
  }
16499
17078
  /**
16500
- * 停止全部桥(插件 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 级 + 机器级
16501
17093
  */
16502
17094
  function stopAllBridges() {
16503
- for (const bridge of bridges.values()) for (const loop of bridge.loops.values()) loop.stopped = true;
16504
- bridges.clear();
17095
+ stopCccBridges();
17096
+ stopMachineLoops();
17097
+ releaseAllPollers();
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
+ })();
16505
17190
  }
16506
17191
  /**
16507
17192
  * 解析该 CCC 的发送账号 + 凭据(v1.31.0:文本与文件发送的**单一账号选择真相源**)。
@@ -16585,35 +17270,37 @@ async function sendProactiveText(input) {
16585
17270
  role
16586
17271
  };
16587
17272
  }
16588
- /** 桥状态快照(面板数据源):每 CCC → 每账号 → 轮询健康 */
17273
+ /**
17274
+ * 桥状态快照(面板数据源):每 CCC → 每账号 → 轮询健康。
17275
+ * 🔴 **P0-3 A 起多一行**:机器级 poller 以 `ccc: '<machine>'`(`MACHINE_SCOPE`)出现 ——
17276
+ * 它**不属于任何 CCC**,故**不伪装成某个 CCC 的行**(面板按原样显示这个哨兵值即可)。
17277
+ */
16589
17278
  function weixinBridgeStatus() {
16590
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
+ }));
16591
17285
  for (const [root, bridge] of bridges) out.push({
16592
17286
  ccc: root,
16593
- accounts: [...bridge.loops.entries()].map(([accountId, loop]) => ({
16594
- accountId,
16595
- lastPollAt: loop.lastPollAt,
16596
- ...loop.lastError ? { lastError: loop.lastError } : {}
16597
- }))
17287
+ accounts: toAccounts(bridge.loops)
17288
+ });
17289
+ if (machineLoops.size > 0) out.push({
17290
+ ccc: MACHINE_SCOPE,
17291
+ accounts: toAccounts(machineLoops)
16598
17292
  });
16599
17293
  return out;
16600
17294
  }
16601
17295
  /**
16602
- * 装配(index.ts 调用):扫描 live CCC 启动桥 + 监听会话变化。
16603
- * 事件驱动(对齐 autotrajectory v1.26.15):live 会话出现 → 同步该 CCC 桥。
17296
+ * 装配(index.ts 调用):**按机器级配置决定模式**(P0-3 A:`syncHumanChannel`),
17297
+ * 并监听会话变化以重扫(新 CCC 出现即启动其桥;配置变化也可经此路径热重建)。
17298
+ * 事件驱动(对齐 autotrajectory v1.26.15)。
16604
17299
  */
16605
17300
  function registerWeixinBridge(ctx) {
16606
- const syncFromLive = () => {
16607
- (async () => {
16608
- try {
16609
- const { listCccs } = await import("./ccc-roots-Duk89a-h.js").then((n) => n.a);
16610
- for (const entry of await listCccs(ctx)) syncCccBridge(ctx, entry.root);
16611
- } catch {}
16612
- })();
16613
- };
16614
- syncFromLive();
17301
+ syncHumanChannel(ctx);
16615
17302
  try {
16616
- ctx.on("session/created", () => syncFromLive());
17303
+ ctx.on("session/created", () => syncHumanChannel(ctx));
16617
17304
  } catch {}
16618
17305
  }
16619
17306
  //#endregion
@@ -18755,6 +19442,21 @@ const inject = [
18755
19442
  "settings",
18756
19443
  "web"
18757
19444
  ];
19445
+ /** human-channel 账号行 schema(行形状 = `HumanChannelAccountConfig`) */
19446
+ const humanChannelAccountSchema = z.object({
19447
+ id: z.string(),
19448
+ channel: z.string(),
19449
+ enabled: z.boolean().default(true),
19450
+ token: z.string(),
19451
+ userId: z.string()
19452
+ });
19453
+ /** human-channel 订阅行 schema:`(账号, 用户) → (CCC, 角色)` */
19454
+ const humanChannelSubscriptionSchema = z.object({
19455
+ account: z.string(),
19456
+ user: z.string(),
19457
+ ccc: z.string(),
19458
+ role: z.string()
19459
+ });
18758
19460
  const Config = z.object({
18759
19461
  serenityConfigPaths: z.array(z.string()).default([...DEFAULT_SERENITY_CONFIG_PATHS]),
18760
19462
  tools: z.boolean().default(true),
@@ -18780,6 +19482,12 @@ const Config = z.object({
18780
19482
  webFetch: z.object({ enabled: z.boolean().default(true) }),
18781
19483
  opencodeProvider: z.object({ autoConfigure: z.boolean().default(true) }),
18782
19484
  visionPatch: z.object({ enabled: z.boolean().default(true) }),
19485
+ humanChannel: z.object({
19486
+ accounts: z.array(humanChannelAccountSchema).default([]),
19487
+ subscriptions: z.array(humanChannelSubscriptionSchema).default([]),
19488
+ relay: z.object({ ccc: z.string() }),
19489
+ sending: z.object({ allow: z.array(z.string()).default(["*"]) })
19490
+ }),
18783
19491
  gatewayEnabled: z.boolean().volatile(),
18784
19492
  rebuildThresholdK: z.number().min(50).max(4e3).volatile(),
18785
19493
  skiffEnabled: z.boolean().volatile(),
@@ -19014,4 +19722,4 @@ function registerAcp(ctx) {
19014
19722
  sync();
19015
19723
  }
19016
19724
  //#endregion
19017
- export { Config, apply, inject, name };
19725
+ export { Config, apply, humanChannelAccountSchema, humanChannelSubscriptionSchema, inject, name };