@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/dsh.plugin.json +3 -3
- package/lib/handyman-ops.d.ts +1 -1
- package/lib/host/contract.d.ts +40 -7
- package/lib/human-channel.d.ts +218 -0
- package/lib/index.d.ts +137 -2
- package/lib/index.js +785 -77
- package/lib/{settings-section-B-KOyOYn.js → settings-section-EmaYsI-e.js} +58 -2
- package/lib/settings-section.d.ts +91 -1
- package/lib/status.d.ts +29 -2
- package/lib/weixin-bridge.d.ts +70 -6
- package/package.json +16 -16
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-
|
|
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 |
|
|
1988
|
-
| Use when | you need
|
|
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 |
|
|
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 (
|
|
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 (
|
|
2029
|
-
- Independent subtasks can run in parallel
|
|
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) =
|
|
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
|
|
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 —
|
|
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
|
|
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
|
-
*
|
|
9842
|
-
*
|
|
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-
|
|
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
|
-
/**
|
|
10968
|
-
|
|
10969
|
-
|
|
10970
|
-
|
|
10971
|
-
|
|
10972
|
-
|
|
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
|
|
11133
|
+
* 宿主版本是否落在 dsp **被验证过的档**({@link VERIFIED_HOST_BANDS})之内。
|
|
10998
11134
|
* 无版本信息 → 视为未知(ok=false,detail 说明),不猜测。
|
|
10999
11135
|
*
|
|
11000
|
-
*
|
|
11001
|
-
*
|
|
11002
|
-
*
|
|
11003
|
-
*
|
|
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
|
-
|
|
11011
|
-
|
|
11012
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
16284
|
-
|
|
16285
|
-
|
|
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
|
|
16477
|
-
|
|
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(
|
|
16485
|
-
runAccountLoop(ctx, root,
|
|
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
|
-
*
|
|
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
|
-
|
|
16504
|
-
|
|
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
|
-
/**
|
|
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:
|
|
16594
|
-
|
|
16595
|
-
|
|
16596
|
-
|
|
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
|
|
16603
|
-
*
|
|
17296
|
+
* 装配(index.ts 调用):**按机器级配置决定模式**(P0-3 A:`syncHumanChannel`),
|
|
17297
|
+
* 并监听会话变化以重扫(新 CCC 出现即启动其桥;配置变化也可经此路径热重建)。
|
|
17298
|
+
* 事件驱动(对齐 autotrajectory v1.26.15)。
|
|
16604
17299
|
*/
|
|
16605
17300
|
function registerWeixinBridge(ctx) {
|
|
16606
|
-
|
|
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", () =>
|
|
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 };
|