@shgroup/dsh-serenity-hooks 1.68.0 → 1.69.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
@@ -2,11 +2,10 @@ import { t as __exportAll } from "./rolldown-runtime-8H4AJuhK.js";
2
2
  import { c as hostInjected, d as hostSessions, f as hostSettings, h as hostWebServer, i as cccRootForExec, l as hostLlm, m as hostWeb, n as agentCwdFor, o as listCccs, p as hostSubagents, r as cccRootForCwd, s as hostAgents, t as NO_CCC_FROM_AGENT_CWD, u as hostService } from "./ccc-roots-RMzGU-CJ.js";
3
3
  import { _ as resolveInside, a as findSerenityRoot$1, c as matchBlacklist, d as readCccName$1, f as readCheapWorkerConfig, h as readSafeModeHiddenTools, i as findGitRoot, l as pathInside, m as readExclusiveTools, n as SAFE_MODE_MARKER, o as isSafeModeOn, p as readCognitiveTrajectorySkills, r as classifyPath, s as loadSerenityConfig, t as DEFAULT_SERENITY_CONFIG_PATHS, u as readBlacklist } from "./ccc-C33QWoWZ.js";
4
4
  import { A as summarize, C as readTrajectoryUid, D as setActiveSessionInfo, E as sessionsRoot, F as localIdStamp, M as isoLocal, N as localFileStamp, O as setTrajectoryUid, P as localHuman, S as readActiveSessionMd, T as sessionEvents$1, _ as getActiveSessionInfo, a as pruneMissingBindings, b as namingTitleFor, c as setBindingStore, d as UID_FILENAME, f as archiveSession, g as findSession, h as extractSessionMdPathFromText, i as listBoundSessionIds, j as useSession, k as showSession, l as COGNITIVE_TRAJECTORY_ACTIONS, m as createSession, n as PLUGIN_SOURCE, o as readLastBound, p as clearActiveSessionInfo, r as appendBound, s as resolveSessionCognitiveTrajectoryLabel, u as DEFAULT_SESSION_SCOPE, v as listActiveTrajectories, w as resolveSessionByTitle, x as parseSessionContextFromEvents, y as listSessions } from "./message-source-Catc7q1z.js";
5
- import { a as readGitTrack, c as unsetEntry, i as localstorePath, l as writeEntry, n as checkLocalstoreGitCompliance, o as readStore, r as getEntry, s as runLocalStore, t as LOCALSTORE_SCOPES } from "./localstore-ops-ylQoPz1Q.js";
6
- import { a as FACE_PORTS, c as MECHANISM_PORTS, d as registerSkiffSession$1, f as skiffRoleFor$1, h as ACC_VERSION, i as ACP_PORT, l as SKIFF_DEBUG_PORT, m as unregisterSkiffSession$1, n as readSimpleSettings, o as GATEWAY_PORT, p as skiffSessionInfo$1, r as registerSettingsSection, s as MAIN_WEB_PORT, u as WEIXIN_SEND_PORT } from "./settings-section-B1YzjFz6.js";
5
+ import { a as readWeixinCredential, c as weixinInboundDir, d as LOCALSTORE_SCOPES, f as checkLocalstoreGitCompliance, g as runLocalStore, h as readStore, i as matchWeixinRoute, l as weixinSessionIdFor, m as readGitTrack, n as extractWeixinText, o as readWeixinSettings, p as localstorePath, r as hasVoiceItem, s as sanitizeFileName$1, t as extractWeixinMedia } from "./weixin-route-D_DvgPqy.js";
6
+ import { a as GATEWAY_PORT, c as SKIFF_DEBUG_PORT, d as skiffRoleFor$1, f as skiffSessionInfo$1, i as FACE_PORTS, l as WEIXIN_SEND_PORT, m as ACC_VERSION, n as readSimpleSettings, o as MAIN_WEB_PORT, p as unregisterSkiffSession$1, r as registerSettingsSection, s as MECHANISM_PORTS, u as registerSkiffSession$1 } from "./settings-section-DcZPcWPv.js";
7
7
  import { a as isSkiffSessionId, c as resolveSkiffKind, f as systemPromptSource, i as createRolePromptReader, l as roleMsmWhitelist, n as buildSkiffBasePrompt, o as readSkiffRoles, r as cognitiveTrajectorySubset, s as resolveRoleSystemPrompt, u as roleToolWhitelist } from "./skiff-role-Boc4SKct.js";
8
8
  import { REGISTERED_TOOLS } from "./invariant.js";
9
- import { a as readWeixinCredential, c as weixinInboundDir, i as matchWeixinRoute, l as weixinSessionIdFor, n as extractWeixinText, o as readWeixinSettings, r as hasVoiceItem, s as sanitizeFileName$1, t as extractWeixinMedia } from "./weixin-route-BoSC-uPp.js";
10
9
  import { a as markdownToPlainText, c as sendTyping, i as getUpdates, l as sniffImageExt, n as downloadMedia, o as sendFileMessage, r as getConfig, s as sendTextMessage, t as TypingStatus } from "./weixin-api-BrRcPiQs.js";
11
10
  import { createRequire } from "node:module";
12
11
  import z from "@deepseek-ai/schemastery";
@@ -1085,8 +1084,8 @@ Directory of ACC capabilities — each area lists where to go for details (guide
1085
1084
  cheap-worker(杂工 agent 编排——guide 子命令:cheap-worker guide)
1086
1085
  专属工具(**默认对所有 CCC 隐藏**,只有在自己 exclusiveTools 里点名的 CCC 可见)→ acc-diag(运行态诊断)
1087
1086
  ④ 角色与对外面 → container_admin role(Skiff 认知子集角色:guide/validate/apply/list——skiff_admin 已并入)
1088
- 3099 Skiff 调试问答页(人工调试面)。🔴 2026-10-15:自造 ACP 面已退役
1089
- ⇒ 外部程序化接入改走官方 dsh --profile acp + 现成 ACP 客户端
1087
+ 3099 Skiff 调试问答页(人工调试面)。外部程序化接入用官方 dsh --profile acp
1088
+ + 现成 ACP 客户端;本插件不提供 ACP 面
1090
1089
  ⑤ 自主与接入 → cognitive_trajectory send-now(跨轨迹的**即时**递送,**不排队**:live 目标在跑就当场注入当前轮、空闲则立即起轮;非 live 冷载入——等效于直接 wake;**有同步回执**)
1091
1090
  cognitive_trajectory send-later(跨轨迹的"未来时刻 + 一条 message";fire-and-forget,无回执无回收)
1092
1091
  weixin 微信桥(配置/扫码/路由/消息 hook——CCC 侧 weixin-doctor MSM:msm("weixin-doctor", ["guide"]))
@@ -14990,8 +14989,6 @@ const FILE_UPLOAD_PATH = "/serenity/file-upload";
14990
14989
  const CONFIG_PATH = "/serenity/config";
14991
14990
  const CCCS_PATH = "/serenity/cccs";
14992
14991
  const WEIXIN_PATH = "/serenity/weixin";
14993
- /** 🔴 v1.59.1:ACP 账户台账(面板数据面;形状照 `/serenity/weixin`) */
14994
- const ACP_PATH = "/serenity/acp";
14995
14992
  /** 图片落盘目录(CCC 根相对;S142 图片自动识别基础设施——粘贴图片落盘供 agent 经 CCC vlm MSM 自主处理) */
14996
14993
  const IMAGE_UPLOAD_DIR = "_tmp/images_from_user";
14997
14994
  const IMAGE_MEDIA_TYPES = /* @__PURE__ */ new Set([
@@ -15117,17 +15114,42 @@ function requireWebUi(req, res, message) {
15117
15114
  return false;
15118
15115
  }
15119
15116
  /**
15120
- * 从请求参数解析 workspace:优先 sessionId → 会话 header.cwd;其次 workspace 参数;
15121
- * 无参 → 遍历 live sessions 找第一个 CCC 会话的 cwd(远程访问时 sessionId 可能缺失,
15122
- * 回退 process.cwd()=$HOME 会错误显示"未激活"——v1.22.2 修复);最后进程 cwd。
15123
- * 纯逻辑(可单测):resolveWorkspaceCore(sessionCwd, workspaceParam, listCwds)。
15117
+ * 从请求参数解析 workspace:sessionId → 会话 header.cwd;其次 workspace 参数;
15118
+ * 再次**唯一**的 CCC 会话 cwd;最后进程 cwd。
15119
+ *
15120
+ * 🔴🔴 **2026-10-18 修缺陷(owner 报「在别的 CCC 点开显示的还是 home-serenity 的信息」)**:
15121
+ * 旧实现第 ③ 步是「**遍历 live sessions 找第一个 CCC 会话的 cwd**」—— 那是**跨工作区猜测**:
15122
+ * 只要 sessionId 缺失或那条会话查不到(ACP/skiff 会话、尚未 materialize 的会话、远程访问),
15123
+ * 它就会**静默返回另一个 CCC 的路径**,于是面板把**别人的轨迹/技能/安全模式**当成你的显示出来。
15124
+ * 这比"显示未激活"危险得多 —— 用户会据此做判断(以为自己的 safe-mode 开着、以为那是自己的轨迹)。
15125
+ *
15126
+ * 修法与既有先例一致(`rebuild.ts` 对"跨轨迹猜测"的裁决:**响亮失败优于静默接错**):
15127
+ * **多个候选 ⇒ 不猜**。只有当 live sessions 里的 CCC 根**唯一**时才采用它
15128
+ * (那正是 v1.22.2 要救的场景:单 CCC 部署 + 远程访问缺 sessionId);
15129
+ * 一旦出现**两个及以上**不同 CCC 根,就退回进程 cwd(→ 面板显示"未激活"),绝不挑一个顶替。
15130
+ *
15131
+ * @param sessionCwd sessionId 查到的会话 cwd(最权威)
15132
+ * @param workspaceParam 显式 workspace 参数
15133
+ * @param listCwds 枚举 live sessions 的 cwd(用于"唯一 CCC"兜底)
15134
+ * @param fallback 最后兜底(进程 cwd)
15124
15135
  */
15125
15136
  function resolveWorkspaceCore(sessionCwd, workspaceParam, listCwds, fallback) {
15126
15137
  if (sessionCwd) return sessionCwd;
15127
15138
  if (workspaceParam) return workspaceParam;
15128
- for (const cwd of listCwds()) if (cwd && cccRootForCwd(cwd)) return cwd;
15139
+ const roots = /* @__PURE__ */ new Set();
15140
+ for (const cwd of listCwds()) {
15141
+ if (!cwd) continue;
15142
+ const r = cccRootForCwd(cwd);
15143
+ if (r) roots.add(r);
15144
+ }
15145
+ if (roots.size === 1) return [...roots][0];
15129
15146
  return fallback;
15130
15147
  }
15148
+ /**
15149
+ * 解析本次请求所属的 workspace。
15150
+ * 🔴 2026-10-18:导出给 `registerVoyageApi` 复用(航行页词表按当前 CCC 注入)——
15151
+ * **单一真相源**,⛔ 不让调用方另写一份"找 CCC"的逻辑。
15152
+ */
15131
15153
  function resolveWorkspace(ctx, params) {
15132
15154
  let sessionCwd;
15133
15155
  if (params.sessionId) sessionCwd = (hostSessions(ctx)?.get?.(params.sessionId))?.header?.cwd;
@@ -15311,11 +15333,11 @@ function registerStatusApi(ctx, opts = {}) {
15311
15333
  sendJson$3(res, 400, { error: chk.error });
15312
15334
  return;
15313
15335
  }
15314
- const { readWeixinSettings } = await import("./weixin-route-BoSC-uPp.js").then((n) => n.u);
15336
+ const { readWeixinSettings } = await import("./weixin-route-D_DvgPqy.js").then((n) => n.u);
15315
15337
  const { weixinBridgeStatus } = await Promise.resolve().then(() => weixin_bridge_exports);
15316
15338
  const settings = readWeixinSettings(chk.root);
15317
15339
  const bridge = weixinBridgeStatus().find((b) => b.ccc === chk.root);
15318
- const { readWeixinCredential } = await import("./weixin-route-BoSC-uPp.js").then((n) => n.u);
15340
+ const { readWeixinCredential } = await import("./weixin-route-D_DvgPqy.js").then((n) => n.u);
15319
15341
  const accounts = (settings.accounts ?? []).map((a) => ({
15320
15342
  accountId: a.accountId,
15321
15343
  name: a.name ?? void 0,
@@ -15347,7 +15369,7 @@ function registerStatusApi(ctx, opts = {}) {
15347
15369
  const { fetchQRCode } = await import("./weixin-api-BrRcPiQs.js").then((n) => n.u);
15348
15370
  const { randomUUID } = await import("node:crypto");
15349
15371
  for (const [key, v] of weixinLogins) if (Date.now() - v.startedAt > WEIXIN_LOGIN_TTL_MS) weixinLogins.delete(key);
15350
- const { readWeixinSettings } = await import("./weixin-route-BoSC-uPp.js").then((n) => n.u);
15372
+ const { readWeixinSettings } = await import("./weixin-route-D_DvgPqy.js").then((n) => n.u);
15351
15373
  const qr = await fetchQRCode({ botType: readWeixinSettings(root).botType ?? void 0 });
15352
15374
  const loginKey = randomUUID();
15353
15375
  weixinLogins.set(loginKey, {
@@ -15368,7 +15390,7 @@ function registerStatusApi(ctx, opts = {}) {
15368
15390
  sendJson$3(res, 400, { error: "missing accountId" });
15369
15391
  return;
15370
15392
  }
15371
- const { removeWeixinAccount } = await import("./weixin-route-BoSC-uPp.js").then((n) => n.u);
15393
+ const { removeWeixinAccount } = await import("./weixin-route-D_DvgPqy.js").then((n) => n.u);
15372
15394
  const { syncCccBridge } = await Promise.resolve().then(() => weixin_bridge_exports);
15373
15395
  removeWeixinAccount(root, body.accountId);
15374
15396
  syncCccBridge(ctx, root);
@@ -15381,7 +15403,7 @@ function registerStatusApi(ctx, opts = {}) {
15381
15403
  sendJson$3(res, 400, { error: "invalid routes (expected [{user, role}, ...])" });
15382
15404
  return;
15383
15405
  }
15384
- const { saveWeixinRoutes } = await import("./weixin-route-BoSC-uPp.js").then((n) => n.u);
15406
+ const { saveWeixinRoutes } = await import("./weixin-route-D_DvgPqy.js").then((n) => n.u);
15385
15407
  const { readSkiffRoles } = await import("./skiff-role-Boc4SKct.js").then((n) => n.d);
15386
15408
  const roles = readSkiffRoles(root);
15387
15409
  for (const r of routes) if (!roles.has(r.role)) {
@@ -15394,7 +15416,7 @@ function registerStatusApi(ctx, opts = {}) {
15394
15416
  }
15395
15417
  if (body.action === "set-enabled") {
15396
15418
  const enabled = body.enabled === true;
15397
- const { readWeixinSettings, setWeixinEnabled } = await import("./weixin-route-BoSC-uPp.js").then((n) => n.u);
15419
+ const { readWeixinSettings, setWeixinEnabled } = await import("./weixin-route-D_DvgPqy.js").then((n) => n.u);
15398
15420
  const { syncCccBridge } = await Promise.resolve().then(() => weixin_bridge_exports);
15399
15421
  const settings = readWeixinSettings(root);
15400
15422
  if (enabled && (settings.accounts ?? []).length === 0) {
@@ -15438,7 +15460,7 @@ function registerStatusApi(ctx, opts = {}) {
15438
15460
  return;
15439
15461
  }
15440
15462
  const { pollQRStatus } = await import("./weixin-api-BrRcPiQs.js").then((n) => n.u);
15441
- const { readWeixinSettings } = await import("./weixin-route-BoSC-uPp.js").then((n) => n.u);
15463
+ const { readWeixinSettings } = await import("./weixin-route-D_DvgPqy.js").then((n) => n.u);
15442
15464
  const settings = readWeixinSettings(login.root);
15443
15465
  const status = await pollQRStatus({
15444
15466
  baseUrl: void 0,
@@ -15453,7 +15475,7 @@ function registerStatusApi(ctx, opts = {}) {
15453
15475
  });
15454
15476
  return;
15455
15477
  }
15456
- const { upsertWeixinAccount, writeWeixinCredential, nextWeixinAccountId } = await import("./weixin-route-BoSC-uPp.js").then((n) => n.u);
15478
+ const { upsertWeixinAccount, writeWeixinCredential, nextWeixinAccountId } = await import("./weixin-route-D_DvgPqy.js").then((n) => n.u);
15457
15479
  const { syncCccBridge } = await Promise.resolve().then(() => weixin_bridge_exports);
15458
15480
  const accountId = nextWeixinAccountId(settings);
15459
15481
  upsertWeixinAccount(login.root, {
@@ -15537,7 +15559,6 @@ function registerStatusApi(ctx, opts = {}) {
15537
15559
  }
15538
15560
  }
15539
15561
  });
15540
- registerAcpAccountsApi(ctx);
15541
15562
  }
15542
15563
  /** 进行中的扫码登录(loginKey → 状态;进程内,5min 有效) */
15543
15564
  const weixinLogins = /* @__PURE__ */ new Map();
@@ -15558,98 +15579,125 @@ function requireCcc(root) {
15558
15579
  root: serenityRoot
15559
15580
  };
15560
15581
  }
15561
- /** ACP 账户台账端点(面板专用;形状照 `/serenity/weixin`) */
15562
- function registerAcpAccountsApi(ctx) {
15563
- ctx.webServer.register({
15564
- kind: "exact",
15565
- path: ACP_PATH,
15566
- handler: async (req, res) => {
15567
- try {
15568
- if (!requireWebUi(req, res, "ACP 账户配置仅限 WebUI(client 专用)")) return;
15569
- const url = new URL(req.url ?? "/", "http://127.0.0.1");
15570
- if (req.method === "GET") {
15571
- const chk = requireCcc(url.searchParams.get("ccc") ?? void 0);
15572
- if (!chk.ok) {
15573
- sendJson$3(res, 400, { error: chk.error });
15574
- return;
15575
- }
15576
- const { acpChannelStatus, listAcpRoots } = await Promise.resolve().then(() => acp_auth_exports);
15577
- const { listCccs } = await import("./ccc-roots-RMzGU-CJ.js").then((n) => n.a);
15578
- const st = acpChannelStatus(chk.root);
15579
- const all = (await listCccs(ctx)).map((e) => e.root);
15580
- sendJson$3(res, 200, {
15581
- channel: st.channel,
15582
- enabled: st.enabled,
15583
- accounts: st.accounts,
15584
- roots: listAcpRoots(all)
15585
- });
15586
- return;
15587
- }
15588
- if (req.method !== "POST") {
15589
- sendJson$3(res, 405, { error: "method not allowed" });
15590
- return;
15591
- }
15592
- const raw = await readBody$3(req, 65536);
15593
- const body = JSON.parse(raw);
15594
- const chk = requireCcc(body.ccc);
15595
- if (!chk.ok) {
15596
- sendJson$3(res, 400, { error: chk.error });
15597
- return;
15598
- }
15599
- const root = chk.root;
15600
- const acp = await Promise.resolve().then(() => acp_auth_exports);
15601
- if (body.action === "upsert") {
15602
- const account = acp.normalizeAcpAccount(body.account);
15603
- if (!account) {
15604
- sendJson$3(res, 400, { error: "invalid account (accountId 与 role 均必填)" });
15605
- return;
15606
- }
15607
- const { readSkiffRoles } = await import("./skiff-role-Boc4SKct.js").then((n) => n.d);
15608
- const roles = readSkiffRoles(root);
15609
- if (!roles.has(account.role)) {
15610
- sendJson$3(res, 400, { error: `role "${account.role}" 不在该 CCC 的 skiff.roles 里(现有:${[...roles.keys()].join(", ") || "无"})` });
15611
- return;
15612
- }
15613
- acp.upsertAcpAccount(root, account);
15614
- const created = acp.ensureAcpChannelTokens(root).includes(account.accountId);
15615
- sendJson$3(res, 200, {
15616
- saved: true,
15617
- accounts: acp.acpChannelStatus(root).accounts,
15618
- token: created ? acp.readAcpToken(root, account.accountId) : void 0
15619
- });
15620
- return;
15621
- }
15622
- if (body.action === "remove") {
15623
- const accountId = typeof body.accountId === "string" ? body.accountId.trim() : "";
15624
- if (accountId === "") {
15625
- sendJson$3(res, 400, { error: "missing accountId" });
15626
- return;
15627
- }
15628
- acp.removeAcpAccount(root, accountId);
15629
- sendJson$3(res, 200, {
15630
- removed: accountId,
15631
- accounts: acp.acpChannelStatus(root).accounts
15632
- });
15633
- return;
15634
- }
15635
- if (body.action === "rotate") {
15636
- const accountId = typeof body.accountId === "string" ? body.accountId.trim() : "";
15637
- if (accountId === "") {
15638
- sendJson$3(res, 400, { error: "missing accountId" });
15639
- return;
15640
- }
15641
- sendJson$3(res, 200, {
15642
- rotated: accountId,
15643
- token: acp.resetAcpToken(root, accountId)
15644
- });
15645
- return;
15646
- }
15647
- sendJson$3(res, 400, { error: `unknown action: ${String(body.action)}` });
15648
- } catch (err) {
15649
- sendJson$3(res, 400, { error: err?.message ?? String(err) });
15650
- }
15582
+ //#endregion
15583
+ //#region src/voyage-vocab.ts
15584
+ /**
15585
+ * voyage-vocab.ts — 航行页词表改为**按当前 CCC 运行时注入**(纯逻辑,零 DSH 依赖)
15586
+ *
15587
+ * ## 为什么需要它(owner 2026-10-18 报的缺陷)
15588
+ * 资产 `assets/serenity-voyage.html` 的词表是**构建期扫描容器写死的常量**:
15589
+ * `var VOCAB = {"skills":[...],"msms":[...],"zh":[...],"onto":[...],"counts":{...}}`
15590
+ * v2 改版把"写死名单"改成"构建期扫描"是为防名单漂移——方向对,但代价是:
15591
+ * **一份静态资产只能属于一个 CCC**。于是任何别的 CCC 打开动画,看到的都是
15592
+ * **构建它的那个 CCC** 的技能/工具/本体名字与计数。owner 原话:
15593
+ * 「为什么我在别的 CCC 点开显示的还是我们 home-serenity 的信息」「动画也有这个毛病」。
15594
+ *
15595
+ * ## 修法(owner 裁决:运行时按当前 CCC 注入)
15596
+ * 资产**保持静态**(一份文件服务所有 CCC,永不漂);由它的**服务端路由**在返回前
15597
+ * 把 `var VOCAB = {...};` 那一行替换成从该请求所属 CCC 实况读出的词表。
15598
+ *
15599
+ * 三条判据(R↓):
15600
+ * ① **只替换那一行**(锚 = 行首 `var VOCAB = ` 到行尾 `;`)—— 资产里其余 3000+ 行
15601
+ * three.js / 渲染代码**一个字节都不动**。
15602
+ * ② **读不出来就走原样**:锚认不出、或原 VOCAB 解析失败 ⇒ **返回入参本身**,不报错。
15603
+ * 航行页是**装饰**,不该因为某个 CCC 缺目录、或资产结构变了就打不开(旁路容忍铁律)。
15604
+ * ③ **`zh` 不来自容器**:中文隐喻词是作品的语汇装饰,属资产自身 ⇒ 注入时**保留原值**。
15605
+ * 实现上不"重建"整个对象,而是**在原对象上覆盖**四个实况键 —— 未列出的键自动保留。
15606
+ */
15607
+ /** 资产里那一行的锚 —— `var VOCAB = {...};`(`.*?` 非贪婪 + 行尾分号) */
15608
+ const VOCAB_LINE_RE = /^var VOCAB = (\{.*\});[ \t]*$/m;
15609
+ /**
15610
+ * 扫描一个 CCC 的实况词表。**每一项独立容错**:任一来源缺失/读坏 ⇒ 该项为空,其余照常。
15611
+ *
15612
+ * | 字段 | 来源 | 为什么是它 |
15613
+ * |---|---|---|
15614
+ * | `skills` | `.opencode/skills/` 下含 `SKILL.md` 的目录名 | 与状态面板同一条判据 |
15615
+ * | `msms` | `mech-registry.json` 的注册名 | **MSM 的唯一真相源** |
15616
+ * | `onto` | `.opencode/skills/` 下含 `skill.ttl` 的目录名 | 增强型(建模过)的 skill |
15617
+ * | `counts` | 上述长度 + 轨迹数 | 计数与词表同源,否则 HUD 与星场对不上 |
15618
+ *
15619
+ * @param root CCC 根
15620
+ */
15621
+ function scanVoyageLiveVocab(root) {
15622
+ const skills = scanSkillDirs(root, "SKILL.md");
15623
+ const onto = scanSkillDirs(root, "skill.ttl");
15624
+ const msms = scanMsmNames(root);
15625
+ return {
15626
+ skills,
15627
+ msms,
15628
+ onto,
15629
+ counts: {
15630
+ skill: skills.length,
15631
+ msm: msms.length,
15632
+ onto: onto.length,
15633
+ session: scanTrajectoryCount(root)
15651
15634
  }
15652
- });
15635
+ };
15636
+ }
15637
+ /** 技能名:`.opencode/skills/<name>/<marker>` 存在的目录名(marker = `SKILL.md` 或 `skill.ttl`) */
15638
+ function scanSkillDirs(root, marker) {
15639
+ const dir = join(root, ".opencode", "skills");
15640
+ try {
15641
+ if (!existsSync(dir)) return [];
15642
+ return readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory()).filter((e) => existsSync(join(dir, e.name, marker))).map((e) => e.name).sort();
15643
+ } catch {
15644
+ return [];
15645
+ }
15646
+ }
15647
+ /**
15648
+ * MSM 名:读 `mech-registry.json` 的 `msms[].name`。
15649
+ *
15650
+ * 🔴 **为什么不扫 `scripts/`**:注册表是 MSM 的**唯一真相源**;扫目录会把共享库
15651
+ * (`dev-kit/`、`lib/*`)也算成 MSM —— 本仓对"入口判据"已翻车多次。
15652
+ * ⚠️ 注册表若换了位置 ⇒ **静默降级为空**(装饰页不该因此打不开)。
15653
+ */
15654
+ function scanMsmNames(root) {
15655
+ const candidates = [join(root, ".opencode", "skills", "home-serenity", "references", "mech-registry.json"), join(root, ".opencode", "references", "mech-registry.json")];
15656
+ for (const p of candidates) try {
15657
+ if (!existsSync(p)) continue;
15658
+ const names = (JSON.parse(readFileSync(p, "utf-8")).msms ?? []).map((m) => typeof m?.name === "string" ? m.name : "").filter((n) => n !== "");
15659
+ if (names.length > 0) return [...new Set(names)].sort();
15660
+ } catch {}
15661
+ return [];
15662
+ }
15663
+ /** 轨迹数:`AGENT_SESSIONS/` 下非 `_` / `.` 前缀的目录(与轨迹层"活跃"判据一致) */
15664
+ function scanTrajectoryCount(root) {
15665
+ const dir = join(root, "AGENT_SESSIONS");
15666
+ try {
15667
+ if (!existsSync(dir)) return 0;
15668
+ return readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory()).filter((e) => !e.name.startsWith("_") && !e.name.startsWith(".")).length;
15669
+ } catch {
15670
+ return 0;
15671
+ }
15672
+ }
15673
+ /**
15674
+ * 把实况词表注入资产 HTML。
15675
+ *
15676
+ * 做法 = **在原 VOCAB 对象上覆盖**四个实况键(不重建整个对象)⇒ `zh` 等装饰键自动保留。
15677
+ * 任一环节不成立就**原样返回**(见文件头判据②),因此:
15678
+ * @returns 注入后的 HTML;未注入时**与入参逐字节相同**
15679
+ */
15680
+ function injectVoyageVocab(html, live) {
15681
+ if (live === null) return html;
15682
+ const m = VOCAB_LINE_RE.exec(html);
15683
+ if (!m) return html;
15684
+ let base;
15685
+ try {
15686
+ base = JSON.parse(m[1]);
15687
+ } catch {
15688
+ return html;
15689
+ }
15690
+ const merged = {
15691
+ ...base,
15692
+ skills: live.skills,
15693
+ msms: live.msms,
15694
+ onto: live.onto,
15695
+ counts: {
15696
+ ...typeof base.counts === "object" && base.counts !== null ? base.counts : {},
15697
+ ...live.counts
15698
+ }
15699
+ };
15700
+ return html.replace(VOCAB_LINE_RE, () => `var VOCAB = ${JSON.stringify(merged)};`);
15653
15701
  }
15654
15702
  //#endregion
15655
15703
  //#region src/voyage-page.ts
@@ -15688,6 +15736,9 @@ function sendJson$2(res, status, body) {
15688
15736
  /**
15689
15737
  * 注册 `/serenity/voyage`(GET)。
15690
15738
  * @param opts.assetPath 覆盖资产路径(测试用;生产不传)
15739
+ * @param opts.resolveRoot 解析本次请求所属 CCC 根(生产由 api.ts 的 resolveWorkspace 提供)。
15740
+ * 🔴 **2026-10-18 新增**:词表按请求所属 CCC **运行时注入**(owner 报「在别的 CCC 点开
15741
+ * 显示的还是 home-serenity 的信息」)。不传 ⇒ 不注入(保持资产原样 = 旧行为)。
15691
15742
  */
15692
15743
  function registerVoyageApi(ctx, opts = {}) {
15693
15744
  ctx.webServer.register({
@@ -15705,9 +15756,18 @@ function registerVoyageApi(ctx, opts = {}) {
15705
15756
  sendJson$2(res, 500, { error: `voyage asset unavailable: ${err?.message ?? String(err)}` });
15706
15757
  return;
15707
15758
  }
15759
+ let root = null;
15760
+ try {
15761
+ root = opts.resolveRoot?.(req) ?? null;
15762
+ } catch {
15763
+ root = null;
15764
+ }
15765
+ if (root !== null) try {
15766
+ html = injectVoyageVocab(html, scanVoyageLiveVocab(root));
15767
+ } catch {}
15708
15768
  res.statusCode = 200;
15709
15769
  res.setHeader("content-type", "text/html; charset=utf-8");
15710
- res.setHeader("cache-control", "private, max-age=86400");
15770
+ res.setHeader("cache-control", root !== null ? "no-cache" : "private, max-age=86400");
15711
15771
  res.setHeader("content-length", String(Buffer.byteLength(html)));
15712
15772
  res.end(html);
15713
15773
  }
@@ -21648,1120 +21708,6 @@ function registerWeixinSendApi(ctx) {
21648
21708
  sync();
21649
21709
  }
21650
21710
  //#endregion
21651
- //#region src/acp-auth.ts
21652
- /**
21653
- * acp-auth.ts — ACP 面的**认证 + 身份**层(v1.59.0,owner 2026-10-17 令)
21654
- *
21655
- * ## 它解决什么
21656
- *
21657
- * owner 令(逐字):「将微信桥升级为 IM 桥,每个 CCC 可以选择微信实现或 ACP 实现,
21658
- * 微信实现保留不变向前兼容,**ACP 实现就可以暴露端口并绑定 skiff,并且要随即生成认证用的 token**」。
21659
- *
21660
- * 面 E(`acp-face.ts`)原本**无认证、无 CCC 归属、无角色** —— 本模块补上这三件:
21661
- * ① **token**:每 CCC 一枚,落**该 CCC 的 localstore**(credential scope)。
21662
- * ② **CCC 归属**:连接建立时由 token 反查 ⇒ **服务端权威**(不拿客户端自述的 `cwd` 当身份)。
21663
- * ③ **角色**:该 CCC 的 `im.acp.role` ⇒ 供装配层把 ACP 会话套上 skiff 角色。
21664
- *
21665
- * ## 🔴 架构决策:一面多 token,不是每 CCC 一个端口
21666
- *
21667
- * | | 甲:每 CCC 一个端口 | **乙:一面多 token** ← 选它 |
21668
- * |---|---|---|
21669
- * | 端口 | 随 CCC 数增长 | **固定 3200** |
21670
- * | 身份来源 | 端口号 | **token(一一对应 CCC)** |
21671
- *
21672
- * **判据**:`token → CCC` 是一一对应,而 `cwd` 是**客户端可填的**。
21673
- * ⇒ 用 token 定 CCC 比用 cwd 定 CCC 强:前者**服务端权威**,后者**客户端自述**。
21674
- * ⇒ 连接一建立就知道"这是哪个 CCC",**该连接的所有会话都归它**,角色绑定无需猜。
21675
- * ⚠️ `cwd` 仍要校验(见 `cwdInsideRoot`):防一枚 token 越权到别的 CCC。
21676
- *
21677
- * ## 🔴 为什么认证放在 WS upgrade 层,而不在 ACP 协议里
21678
- *
21679
- * `dsh-acp` 的 `authenticate()` **恒成功**且 `authMethods: []`(读其 `lib/index.js` 逐字),
21680
- * 且它**不导出**可替换的认证缝 ⇒ 改协议层要重写它的接线(P0′ 已实测此路不通)。
21681
- * 而**我们自己持有 HTTP server**(自持面)⇒ 在 `upgrade` **升级之前**校验,
21682
- * 失败即 `401 + destroy` ⇒ **连接根本进不来**。
21683
- *
21684
- * ## 边界
21685
- *
21686
- * 本模块**零 DSH 依赖**(只 `node:crypto` / `node:fs` / `node:path` 与本仓纯逻辑)⇒ 可独立单测。
21687
- */
21688
- var acp_auth_exports = /* @__PURE__ */ __exportAll({
21689
- acpChannelEnabled: () => acpChannelEnabled,
21690
- acpChannelStatus: () => acpChannelStatus,
21691
- acpSessionKeyFor: () => acpSessionKeyFor,
21692
- acpTokenKeyFor: () => acpTokenKeyFor,
21693
- cwdInsideRoot: () => cwdInsideRoot,
21694
- ensureAcpChannelTokens: () => ensureAcpChannelTokens,
21695
- ensureAcpToken: () => ensureAcpToken,
21696
- expandAcpAccounts: () => expandAcpAccounts,
21697
- extractToken: () => extractToken,
21698
- listAcpRoots: () => listAcpRoots,
21699
- normalizeAcpAccount: () => normalizeAcpAccount,
21700
- readAcpMainSession: () => readAcpMainSession,
21701
- readAcpToken: () => readAcpToken,
21702
- readImSettings: () => readImSettings,
21703
- removeAcpAccount: () => removeAcpAccount,
21704
- resetAcpToken: () => resetAcpToken,
21705
- resolveTokenAccount: () => resolveTokenAccount,
21706
- safeEqual: () => safeEqual,
21707
- saveAcpAccounts: () => saveAcpAccounts,
21708
- tokenFingerprint: () => tokenFingerprint,
21709
- upsertAcpAccount: () => upsertAcpAccount,
21710
- writeAcpMainSession: () => writeAcpMainSession
21711
- });
21712
- /** 多账户凭据键前缀(`IM_ACP_TOKEN_<ACCOUNT_ID>`;与微信桥 `WEIXIN_<ID>_<FIELD>` 同构) */
21713
- const ACP_TOKEN_PREFIX = "IM_ACP_TOKEN_";
21714
- /**
21715
- * `accountId` → UPPER_SNAKE 片段(`agmente-phone` → `AGMENTE_PHONE`)。
21716
- *
21717
- * 🔴 **与 `weixin-route.ts` 的 `accountKeyPart` 逐字同规则** —— 刻意复制而非共享:
21718
- * 两者属不同子域(微信 vs ACP),共享会让"改微信键规则"意外改掉 ACP 的既有凭据键,
21719
- * 而凭据键**改一次就等于全体客户端掉线**。⇒ 用"同规则的两份"换"互不牵连"。
21720
- */
21721
- function accountKeyPart(accountId) {
21722
- return accountId.toUpperCase().replace(/[^A-Z0-9]+/g, "_");
21723
- }
21724
- /** 该账户的凭据键(`IM_ACP_TOKEN_<ACCOUNT_ID>`);`accountId` 必填 */
21725
- function acpTokenKeyFor(accountId) {
21726
- return `${ACP_TOKEN_PREFIX}${accountKeyPart(accountId)}`;
21727
- }
21728
- /** token 字节数(32 B = 256 bit;hex 后 64 字符) */
21729
- const TOKEN_BYTES = 32;
21730
- /**
21731
- * 该**账户**的 ACP token(**读**;不存在返回 null)。
21732
- *
21733
- * 🔴 **不自动生成** —— 生成是**写操作**,只在显式要求时做(`ensureAcpToken`)。
21734
- * 读路径永远无副作用:否则每次判定都会改 localstore。
21735
- *
21736
- * @param accountId 账户键(必填 —— 凭据按账户分离,没有"CCC 默认 token"这回事)
21737
- */
21738
- function readAcpToken(root, accountId) {
21739
- try {
21740
- const value = getEntry(root, "credential", acpTokenKeyFor(accountId));
21741
- return typeof value === "string" && value.trim() !== "" ? value.trim() : null;
21742
- } catch {
21743
- return null;
21744
- }
21745
- }
21746
- /**
21747
- * 生成(或返回既有)ACP token 并落该 CCC 的 localstore。
21748
- *
21749
- * ⚠️ **幂等**:已有 token 时**原样返回**(不轮换)—— 轮换会让在用的客户端立刻断线,
21750
- * 必须是一次显式动作(`resetAcpToken`)。
21751
- *
21752
- * @param accountId 多账户时指明哪个账户;省略 = 单账户简写键
21753
- */
21754
- function ensureAcpToken(root, accountId) {
21755
- const existing = readAcpToken(root, accountId);
21756
- if (existing) return existing;
21757
- const token = randomBytes(TOKEN_BYTES).toString("hex");
21758
- writeEntry(root, "credential", acpTokenKeyFor(accountId), token);
21759
- return token;
21760
- }
21761
- /**
21762
- * 轮换 token(显式动作;返回新值)。旧 token 立即失效。
21763
- * @param accountId 账户键
21764
- */
21765
- function resetAcpToken(root, accountId) {
21766
- const token = randomBytes(TOKEN_BYTES).toString("hex");
21767
- writeEntry(root, "credential", acpTokenKeyFor(accountId), token);
21768
- return token;
21769
- }
21770
- /**
21771
- * 常时比较(防时序侧信道)。
21772
- *
21773
- * 🔴 两个长度不等时 `timingSafeEqual` **会抛** ⇒ 先比长度并立刻返回 false。
21774
- * ⚠️ 那一步本身**泄漏长度**(可接受:token 长度固定且公开)。
21775
- */
21776
- function safeEqual(a, b) {
21777
- const bufA = Buffer.from(a, "utf8");
21778
- const bufB = Buffer.from(b, "utf8");
21779
- if (bufA.length !== bufB.length) return false;
21780
- return timingSafeEqual(bufA, bufB);
21781
- }
21782
- /** token 指纹(诊断面用;不可逆) */
21783
- function tokenFingerprint(token) {
21784
- return createHash("sha256").update(token).digest("hex").slice(0, 12);
21785
- }
21786
- /** 主会话键前缀(`IM_ACP_SESSION_<ACCOUNT_ID>`;与 `IM_ACP_TOKEN_<ACCOUNT_ID>` 同族同规则) */
21787
- const ACP_SESSION_PREFIX = "IM_ACP_SESSION_";
21788
- /**
21789
- * 该账户的**主会话**键(`IM_ACP_SESSION_<ACCOUNT_ID>`)。
21790
- *
21791
- * 🔴 为什么放在 **credential** 节而不是 config 节:它跟 token 一样是**每账户一值的键控数据**
21792
- * (键由 `accountId` 派生、随账户生命周期存亡)。放 config 节要另造一层 `section.key`
21793
- * 结构,而这一层的唯一读者就是本模块 ⇒ 多一层结构 = 多一处会漂的形状。
21794
- *
21795
- * ⚠️ 它**不是凭据**(会话 id 不是秘密)—— 共享 credential 节只是为了复用它既有的
21796
- * 「按 accountId 派生键 + 删账户时一并清理」这套语义,不引入第二套键规则。
21797
- */
21798
- function acpSessionKeyFor(accountId) {
21799
- return `${ACP_SESSION_PREFIX}${accountKeyPart(accountId)}`;
21800
- }
21801
- /**
21802
- * 读该账户的主会话 id(不存在 ⇒ `null`)。
21803
- *
21804
- * 🔴 **读路径无副作用**(与 `readAcpToken` 同纪律):不存在的含义是「这个账户还没有主会话」,
21805
- * 而那时的正确行为是**让 App 去建**(`session/list` 回空数组),不是替它编一个。
21806
- */
21807
- function readAcpMainSession(root, accountId) {
21808
- try {
21809
- const value = getEntry(root, "credential", acpSessionKeyFor(accountId));
21810
- return typeof value === "string" && value.trim() !== "" ? value.trim() : null;
21811
- } catch {
21812
- return null;
21813
- }
21814
- }
21815
- /**
21816
- * 记下该账户的主会话 id(**写操作**;由 `session/new` 的出站响应触发)。
21817
- *
21818
- * 🔴 **后写覆盖先写**(last-wins):owner 要的是"一条长期对话",而"再建一条"是用户
21819
- * 在 App 里显式点「新对话」的结果 ⇒ 那时他要的就是**新的那条**。
21820
- * ⛔ **被否的两种做法**:① 已有主会话就拒建(用户会看到"点了没反应");
21821
- * ② 把 `session/new` 改写成 `session/resume`(响应形状不同,要让客户端认一条
21822
- * **不存在于协议里**的响应 —— 那是拿协议兼容性换一个我们本来就有的语义)。
21823
- */
21824
- function writeAcpMainSession(root, accountId, sessionId) {
21825
- const id = String(sessionId ?? "").trim();
21826
- if (id === "") return;
21827
- writeEntry(root, "credential", acpSessionKeyFor(accountId), id);
21828
- }
21829
- /**
21830
- * 从一次 HTTP upgrade 请求里取出候选 token。
21831
- *
21832
- * 两种携带方式都认(客户端差异;Agmente 等用具名头,脚本常用查询串):
21833
- * ① `Authorization: Bearer <t>`
21834
- * ② `?token=<t>`(查询串)
21835
- *
21836
- * @returns 取到的 token;两者都无 → null
21837
- */
21838
- function extractToken(headers, url) {
21839
- const auth = headers["authorization"];
21840
- if (typeof auth === "string") {
21841
- const m = /^Bearer\s+(.+)$/i.exec(auth.trim());
21842
- if (m?.[1]) return m[1].trim();
21843
- }
21844
- if (typeof url === "string" && url !== "") try {
21845
- const q = new URL(url, "http://placeholder.invalid").searchParams.get("token");
21846
- if (q && q.trim() !== "") return q.trim();
21847
- } catch {}
21848
- return null;
21849
- }
21850
- /**
21851
- * 把该 CCC 的配置**展开成账户列表**(读时现算,**无快照**)。
21852
- *
21853
- * 🔴 **`im.acp.accounts` 是必填项** —— 没有它就没有账户,**一个都认证不了**。
21854
- * 刻意**不提供**"单账户简写"退回路径(v1.59.1):那会多出一条永不生效的分支,
21855
- * 而它的唯一作用是把"配置写错了"伪装成"用了简写"。
21856
- *
21857
- * 判据(三条都由调用方以 fail-closed 消费):
21858
- * - `enabled !== false`(缺省 true)
21859
- * - `role` 非空(空 ⇒ 该账户**不出现**在列表里 ⇒ 必然登录失败)
21860
- * - token 存在(不存在 ⇒ 同样不出现)
21861
- *
21862
- * ⚠️ 本函数**纯读,绝不生成 token** —— 生成是写操作,归 `ensureAcpChannelTokens`。
21863
- * 这样 upgrade 路径可以随时现算而不冒"每次连接都写 localstore"的风险。
21864
- */
21865
- function expandAcpAccounts(root, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
21866
- const im = readImSettings(root, paths);
21867
- if (im.channel !== "acp" || im.acp.enabled !== true) return [];
21868
- const declared = Array.isArray(im.acp.accounts) ? im.acp.accounts : [];
21869
- const out = [];
21870
- for (const a of declared) {
21871
- if (typeof a !== "object" || a === null) continue;
21872
- if (a.enabled === false) continue;
21873
- const accountId = typeof a.accountId === "string" ? a.accountId.trim() : "";
21874
- if (accountId === "") continue;
21875
- const role = typeof a.role === "string" ? a.role.trim() : "";
21876
- if (role === "") continue;
21877
- const token = readAcpToken(root, accountId);
21878
- if (!token) continue;
21879
- out.push({
21880
- root,
21881
- accountId,
21882
- tokenKey: acpTokenKeyFor(accountId),
21883
- token,
21884
- role
21885
- });
21886
- }
21887
- return out;
21888
- }
21889
- /**
21890
- * **让配置里声明的账户都拥有 token**(写操作;幂等;由装配层与配置变更时调用)。
21891
- *
21892
- * 🔴 owner 令「**随即生成**认证用的 token」的落地点。与 v1.59.0 的差别:
21893
- * 那时只在**启动**时跑一次 ⇒ 加一台设备要重启;现在**任何一次判定前都可以跑**,
21894
- * 且撤账户后不会被"复活"(只对**当前声明里**的账户生成)。
21895
- *
21896
- * @returns 本次**新生成**的账户键列表(已存在的原样返回,不轮换)
21897
- */
21898
- function ensureAcpChannelTokens(root, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
21899
- const im = readImSettings(root, paths);
21900
- if (im.channel !== "acp" || im.acp.enabled !== true) return [];
21901
- const declared = Array.isArray(im.acp.accounts) ? im.acp.accounts : [];
21902
- const created = [];
21903
- for (const a of declared) {
21904
- if (typeof a !== "object" || a === null) continue;
21905
- if (a.enabled === false) continue;
21906
- const accountId = typeof a.accountId === "string" ? a.accountId.trim() : "";
21907
- if (accountId === "") continue;
21908
- try {
21909
- if (!readAcpToken(root, accountId)) {
21910
- ensureAcpToken(root, accountId);
21911
- created.push(accountId);
21912
- }
21913
- } catch {}
21914
- }
21915
- return created;
21916
- }
21917
- /**
21918
- * 校验一枚候选 token,返回它归属的**账户**(未命中返回 null)。
21919
- *
21920
- * @param candidate 客户端出示的 token
21921
- * @param roots 候选 CCC 根集合(由调用方枚举——本模块不认识"CCC 在哪")
21922
- */
21923
- function resolveTokenAccount(candidate, roots, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
21924
- if (!candidate) return null;
21925
- for (const root of roots) for (const acct of expandAcpAccounts(root, paths)) if (safeEqual(candidate, acct.token)) return acct;
21926
- return null;
21927
- }
21928
- /**
21929
- * 枚举**有可用 ACP 账户**的 CCC(供准入名单用)。
21930
- *
21931
- * 判据:`im.channel === 'acp'` ∧ `im.acp.enabled === true` ∧ **至少一个账户有 token 且配了角色**。
21932
- * ⚠️ 缺一即不算(fail-closed:宁可连不上,不可放行一个半配置的通道)。
21933
- */
21934
- function listAcpRoots(roots, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
21935
- const out = [];
21936
- for (const root of roots) if (expandAcpAccounts(root, paths).length > 0) out.push(root);
21937
- return out;
21938
- }
21939
- /** 读该 CCC 的 `im` 段(未配置 ⇒ 归一缺省;永不 undefined) */
21940
- function readImSettings(root, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
21941
- try {
21942
- const im = loadSerenityConfig(root, paths).im;
21943
- if (!im || typeof im !== "object") return {
21944
- channel: "weixin",
21945
- acp: {}
21946
- };
21947
- return {
21948
- channel: im.channel === "acp" ? "acp" : "weixin",
21949
- acp: im.acp && typeof im.acp === "object" ? im.acp : {}
21950
- };
21951
- } catch {
21952
- return {
21953
- channel: "weixin",
21954
- acp: {}
21955
- };
21956
- }
21957
- }
21958
- /**
21959
- * 该 CCC 是否启用 ACP 入站通道。
21960
- *
21961
- * 🔴 **两个条件都真才算**:`im.channel === 'acp'` **且** `im.acp.enabled === true`。
21962
- * 为什么双键:`channel` 表达"选了哪条路",`enabled` 表达"现在开不开" ⇒
21963
- * 让 owner 能**选好 ACP 但暂时关着**(不用删配置),与既有各面的 `enabled` 范式一致。
21964
- */
21965
- function acpChannelEnabled(root, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
21966
- const im = readImSettings(root, paths);
21967
- return im.channel === "acp" && im.acp.enabled === true;
21968
- }
21969
- /**
21970
- * 客户端 `session/new` 传来的 `cwd` 是否落在该 token 归属的 CCC 内。
21971
- *
21972
- * 🔴 **为什么必须校验**:token 定的是"哪个 CCC",而 `cwd` 由**客户端**指定。
21973
- * 不校验 ⇒ 一枚 CCC A 的 token 可以把会话开到 CCC B(越权读到另一个 CCC 的上下文)。
21974
- * 这与 CCC 自身的路径围墙(P3)是同一条纪律,只是发生在**接入**这一层。
21975
- *
21976
- * 判据用 `relative()` 而非 `startsWith`(后者会把 `/a/bc` 误判为在 `/a/b` 内)。
21977
- */
21978
- function cwdInsideRoot(cwd, root) {
21979
- if (!isAbsolute(cwd)) return false;
21980
- const rel = relative(resolve(root), resolve(cwd));
21981
- return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
21982
- }
21983
- /** 该 CCC 的 ACP 通道对外**可见形态**(诊断面用;**绝不含 token 明文**) */
21984
- function acpChannelStatus(root, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
21985
- const im = readImSettings(root, paths);
21986
- const declared = Array.isArray(im.acp.accounts) ? im.acp.accounts : [];
21987
- const usable = new Set(expandAcpAccounts(root, paths).map((a) => a.accountId));
21988
- const accounts = [];
21989
- for (const a of declared) {
21990
- if (typeof a !== "object" || a === null) continue;
21991
- const accountId = typeof a.accountId === "string" ? a.accountId.trim() : "";
21992
- if (accountId === "") continue;
21993
- const role = typeof a.role === "string" && a.role.trim() !== "" ? a.role.trim() : null;
21994
- const token = readAcpToken(root, accountId);
21995
- const enabled = a.enabled !== false;
21996
- let blockedBy = null;
21997
- if (!enabled) blockedBy = "该账户 enabled=false";
21998
- else if (!role) blockedBy = "未配 role";
21999
- else if (!token) blockedBy = "尚无 token(保存配置后自动生成)";
22000
- accounts.push({
22001
- accountId,
22002
- name: typeof a.name === "string" && a.name.trim() !== "" ? a.name.trim() : null,
22003
- role,
22004
- enabled,
22005
- hasToken: token !== null,
22006
- tokenFingerprint: token ? tokenFingerprint(token) : null,
22007
- mainSession: readAcpMainSession(root, accountId),
22008
- usable: usable.has(accountId),
22009
- blockedBy
22010
- });
22011
- }
22012
- return {
22013
- channel: im.channel,
22014
- enabled: acpChannelEnabled(root, paths),
22015
- accounts
22016
- };
22017
- }
22018
- /**
22019
- * 读取 serenity.json 原文(不存在 → 空对象)。
22020
- *
22021
- * ⚠️ 与 `weixin-route.ts` 的同名函数**刻意各写一份**:两者写的是**不同段**
22022
- * (`weixin` vs `im.acp.accounts`),共用一个会在"某天只想改其中一段"时
22023
- * 意外把另一段也重写一遍。同规则两份 > 一个会互相牵连的共用件。
22024
- */
22025
- function readRawConfig(root, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
22026
- for (const candidate of paths) {
22027
- const p = resolve(root, candidate);
22028
- if (existsSync(p)) try {
22029
- const v = JSON.parse(readFileSync(p, "utf-8").replace(/^\uFEFF/, ""));
22030
- if (v && typeof v === "object" && !Array.isArray(v)) return v;
22031
- } catch {
22032
- return {};
22033
- }
22034
- }
22035
- return {};
22036
- }
22037
- /**
22038
- * 写回 `im.acp.accounts`(保留文件里其余所有字段)。
22039
- *
22040
- * 🔴 **只碰这一个键**:`im.channel` / `im.acp.enabled` 归面板总开关与人工配置,
22041
- * 本函数不越权改它们(否则"加一台设备"会顺手把通道开关也改了)。
22042
- */
22043
- function saveAcpAccounts(root, accounts, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
22044
- const target = resolve(root, paths[0] ?? ".opencode/serenity.json");
22045
- const raw = readRawConfig(root, paths);
22046
- const im = raw.im && typeof raw.im === "object" ? raw.im : {};
22047
- const acp = im.acp && typeof im.acp === "object" ? im.acp : {};
22048
- acp.accounts = accounts;
22049
- im.acp = acp;
22050
- raw.im = im;
22051
- const dir = resolve(target, "..");
22052
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
22053
- writeFileSync(target, JSON.stringify(raw, null, 2) + "\n", "utf-8");
22054
- return accounts;
22055
- }
22056
- /**
22057
- * 规范化一条账户声明(面板传入的任意形状 → 合法声明;非法 ⇒ null)。
22058
- *
22059
- * 🔴 返回类型里 `role` 是**必填**(不是 `ImAcpAccount` 的可选形态)——
22060
- * 本函数的契约就是"要么给一个角色齐备的账户,要么给 null"。
22061
- * 这样调用方**不必再判一次** `role` 有没有(那正是会漂的第二处判据)。
22062
- */
22063
- function normalizeAcpAccount(input) {
22064
- if (typeof input !== "object" || input === null) return null;
22065
- const a = input;
22066
- const accountId = typeof a.accountId === "string" ? a.accountId.trim() : "";
22067
- if (accountId === "") return null;
22068
- const role = typeof a.role === "string" ? a.role.trim() : "";
22069
- if (role === "") return null;
22070
- const out = {
22071
- accountId,
22072
- role
22073
- };
22074
- if (typeof a.name === "string" && a.name.trim() !== "") out.name = a.name.trim();
22075
- if (a.enabled === false) out.enabled = false;
22076
- return out;
22077
- }
22078
- /** 增/改一条账户(按 accountId 定位;写入后返回全表) */
22079
- function upsertAcpAccount(root, account, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
22080
- const list = [...readImSettings(root, paths).acp.accounts ?? []];
22081
- const idx = list.findIndex((a) => a.accountId === account.accountId);
22082
- if (idx >= 0) list[idx] = {
22083
- ...list[idx],
22084
- ...account
22085
- };
22086
- else list.push(account);
22087
- return saveAcpAccounts(root, list, paths);
22088
- }
22089
- /**
22090
- * 删一条账户(**同时清掉它的 token + 主会话**,避免留死键)。
22091
- *
22092
- * 🔴 主会话键**必须一起清**:否则日后用同一个 `accountId` 重建账户时,它会**静默继承**
22093
- * 那条旧会话 —— 而那条会话的 cwd/角色都是上一代账户的。这个失效**没有任何信号**
22094
- * (列表上它看着就是一条正常会话)。
22095
- */
22096
- function removeAcpAccount(root, accountId, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
22097
- const next = saveAcpAccounts(root, (readImSettings(root, paths).acp.accounts ?? []).filter((a) => a.accountId !== accountId), paths);
22098
- for (const key of [acpTokenKeyFor(accountId), acpSessionKeyFor(accountId)]) try {
22099
- unsetEntry(root, "credential", key);
22100
- } catch {}
22101
- return next;
22102
- }
22103
- //#endregion
22104
- //#region src/acp-face.ts
22105
- /** 面名(面宿主幂等键;与 FACE_PORTS 的 id 同名,便于对账) */
22106
- const FACE_ACP = "acp";
22107
- /**
22108
- * 校验一次 upgrade 请求(**只读**:不改任何状态)。
22109
- *
22110
- * 判据链(任一环失败即拒,fail-closed):候选 token 缺失 ⇒ 不匹配任何 CCC ⇒
22111
- * 该 CCC 未启用 ACP 通道 ⇒ 该 CCC 未配角色。最后一环尤其重要:**没有角色的 ACP 会话
22112
- * 等于一个无身份、无约束的裸 agent** ⇒ 宁可连不上。
22113
- */
22114
- function authorizeUpgrade(req) {
22115
- const candidate = extractToken(req.headers, req.url);
22116
- if (!candidate) return {
22117
- ok: false,
22118
- reason: "未携带 token(用 Authorization: Bearer <t> 或 ?token=<t>)"
22119
- };
22120
- const roots = knownAcpRoots();
22121
- if (roots.length === 0) return {
22122
- ok: false,
22123
- reason: "本机没有任何启用了 ACP 通道的 CCC"
22124
- };
22125
- const acct = resolveTokenAccount(candidate, roots);
22126
- if (!acct) return {
22127
- ok: false,
22128
- reason: "token 不匹配任何已声明账户的 ACP 通道"
22129
- };
22130
- const role = acct.role;
22131
- if (!role) return {
22132
- ok: false,
22133
- reason: `CCC(${acct.root})账户 ${acct.accountId} 未配 im.acp.accounts[].role`
22134
- };
22135
- return {
22136
- ok: true,
22137
- root: acct.root,
22138
- role,
22139
- accountId: acct.accountId
22140
- };
22141
- }
22142
- /**
22143
- * CCC 根候选的来源(**只要根,不判配置**)。
22144
- *
22145
- * 🔴 v1.59.1 分层变更:**这份名单是"本机有哪些 CCC",不是"谁能连 ACP"**。
22146
- * "谁能连"由 `expandAcpAccounts` 现算(读各 CCC 的 serenity.json + localstore)。
22147
- * ⇒ 缓存失效的代价从"配错权限"降为"多看/少看一个候选根",**不再是安全问题**。
22148
- */
22149
- let knownRoots = [];
22150
- /**
22151
- * 准入名单的现算入口(v1.59.1)。
22152
- *
22153
- * 顺序:**先用已知根**(免 async)→ 逐个 `expandAcpAccounts` 现读配置。
22154
- * 未知根时退回上次枚举的结果(可能稍旧,但只影响"会不会去问它",不影响是否放行)。
22155
- *
22156
- * ⚠️ 这里是**同步**的(upgrade 回调用)⇒ 不 await、不扫盘。
22157
- */
22158
- function knownAcpRoots() {
22159
- return listAcpRoots(knownRoots);
22160
- }
22161
- /**
22162
- * 刷新"本机有哪些 CCC"(async,枚举宿主三来源)。
22163
- *
22164
- * 🔴 **失败必须响亮**:v1.59.0 这里是 `void refresh(...)` 且没有 catch ——
22165
- * 枚举失败会让名单清空 ⇒ **所有连接一律 401**,而现场只有一行 stdout 看不到的日志。
22166
- * 这正是本轨记过的「调用没报错 ≠ 已验证」。
22167
- *
22168
- * @returns 本次枚举到的根数(0 = 一个都没发现,日志会说为什么)
22169
- */
22170
- async function refreshAcpRoots(ctx) {
22171
- try {
22172
- knownRoots = (await listCccs(ctx)).map((e) => e.root);
22173
- let created = 0;
22174
- for (const root of knownRoots) try {
22175
- created += ensureAcpChannelTokens(root).length;
22176
- } catch (err) {
22177
- console.warn(`[serenity-hooks] 面 E:为 CCC(${root})生成 token 失败: ${String(err?.message ?? err)}`);
22178
- }
22179
- if (created > 0) console.log(`[serenity-hooks] ✓ 面 E:已为 ${created} 个 ACP 账户生成 token`);
22180
- return knownRoots.length;
22181
- } catch (err) {
22182
- knownRoots = [];
22183
- const msg = `面 E:CCC 枚举失败(准入名单将为空,所有连接会被拒): ${String(err?.message ?? err)}`;
22184
- console.warn(`[serenity-hooks] ${msg}`);
22185
- state.lastErrors.push({
22186
- at: Date.now(),
22187
- applied: false,
22188
- error: msg
22189
- });
22190
- if (state.lastErrors.length > MAX_ERR_KEEP) state.lastErrors.shift();
22191
- return 0;
22192
- }
22193
- }
22194
- /**
22195
- * 把**本连接归属的 CCC 角色**套到该连接创建的 agent 上(v1.59.0)。
22196
- *
22197
- * ## 🔴 为什么是"认领"而不是"直接挂"
22198
- *
22199
- * 理想形态是:在本连接的 scoped fiber 里监听 `session/created`,**只**收到本连接建的会话。
22200
- * 但那条路依赖一个**我没有实测过的**引擎行为 —— cordis 的 `EventsService.dispatch()` 只在
22201
- * **派发方设了 `Context.filter`** 时才按 ctx 过滤(读 `@deepseek-ai/cordis/src/events.ts`
22202
- * 的 `.filter(hook => hook.global || !filter || filter.call(thisArg, hook.ctx))`)。
22203
- * ⇒ 若宿主派发 `session/created` 时**没设**过滤器,本监听器会**收到全进程**的会话
22204
- * (网页端、别的轨迹、别的连接),**套错角色**。
22205
- *
22206
- * 🔴 **D119 纪律**:不许照源码的"意图"断言引擎行为 ⇒ 本实现**不赌**那条语义,
22207
- * 改用**认领制**(两条过滤都可以自证,不依赖 filter):
22208
- * ① **cwd 必须落在本连接归属的 CCC 内**(token 已定 CCC ⇒ 越界会话直接跳过);
22209
- * ② **同一会话只被认领一次**(`claimed` 集合;先到先得 ⇒ 不会被另一条连接的监听器抢走)。
22210
- *
22211
- * ⚠️ **残余风险(如实标注)**:若**同一 CCC** 同时有网页端会话与 ACP 会话在创建,
22212
- * 本函数可能把网页端那个会话也认领了(两者 cwd 都在该 CCC 内)。⇒ 判据是"cwd + 先到先得",
22213
- * **不是"确属本连接"**。⚠️ **这一格必须由探针实测**(见 REBUILD-TODO 的待办);
22214
- * 未实测前**不得**声称"只套本连接的会话"。
22215
- *
22216
- * 失败语义:全程不抛(本面是可选面)——角色挂不上只记日志,不阻断 ACP 接线。
22217
- */
22218
- function bindRoleToConnScopedAgent(scoped, root, roleName) {
22219
- try {
22220
- const role = readSkiffRoles(root).get(roleName);
22221
- if (!role) {
22222
- console.warn(`[serenity-hooks] 面 E:CCC(${root})未定义 skiff 角色 "${roleName}" ⇒ 本连接不注入角色`);
22223
- return;
22224
- }
22225
- const rolePrompt = createRolePromptReader(root, role);
22226
- const claimed = /* @__PURE__ */ new Set();
22227
- const onCreated = (session) => {
22228
- try {
22229
- const id = session?.header?.id;
22230
- const cwd = session?.header?.cwd;
22231
- if (!id || !cwd) return;
22232
- if (claimed.has(id)) return;
22233
- if (!cwdInsideRoot(cwd, root)) return;
22234
- const target = resolveAgentById(scoped, id);
22235
- if (!target) return;
22236
- target.ctx.systemPrompt.section({
22237
- name: "serenity-skiff",
22238
- order: -60,
22239
- text: () => [buildSkiffBasePrompt(roleName, role), rolePrompt()].filter(Boolean).join("\n")
22240
- });
22241
- claimed.add(id);
22242
- console.log(`[serenity-hooks] ✓ 面 E:会话 ${id} 已绑定角色 "${roleName}"(CCC=${root})`);
22243
- } catch (err) {
22244
- console.warn(`[serenity-hooks] 面 E:角色注入失败(不影响会话): ${String(err?.message ?? err)}`);
22245
- }
22246
- };
22247
- for (const eventName of ["session/created", "agent/created"]) try {
22248
- scoped.on(eventName, onCreated);
22249
- } catch {}
22250
- } catch (err) {
22251
- console.warn(`[serenity-hooks] 面 E:角色绑定装配失败: ${String(err?.message ?? err)}`);
22252
- }
22253
- }
22254
- /** 按会话 id 取 live agent(只读;取不到返回 undefined) */
22255
- function resolveAgentById(scoped, sessionId) {
22256
- try {
22257
- const agent = hostInjected(scoped, "agents")?.get?.(sessionId);
22258
- if (!agent?.ctx?.systemPrompt || typeof agent.ctx.systemPrompt.section !== "function") return void 0;
22259
- return agent;
22260
- } catch {
22261
- return;
22262
- }
22263
- }
22264
- /** 本面绑定的地址(取自集中端口表 —— **单一真相源**,不在本文件硬写) */
22265
- const ACP_HOST = FACE_PORTS.acp.host ?? "127.0.0.1";
22266
- /**
22267
- * `dsh-acp` 需要的服务(**替它声明**,见文件头坑 ①)。
22268
- *
22269
- * 逐字出处 = `dsh-acp/lib/index.js` 的 `const inject = ["agents","llm","sessionPersistence","sessions"]`。
22270
- * ⚠️ **少一个就在它内部抛错**(实测那条是 `sessionPersistence`)⇒ 这里是**照抄它的声明**,
22271
- * 不是"我们觉得它需要什么"。⇒ 它升版改了 `inject`,这里要跟着改(列为**跨版本注意点**)。
22272
- */
22273
- const ACP_REQUIRED_SERVICES = [
22274
- "agents",
22275
- "llm",
22276
- "sessionPersistence",
22277
- "sessions"
22278
- ];
22279
- function acpFaceActive() {
22280
- return faceActive(FACE_ACP);
22281
- }
22282
- /**
22283
- * 面是否在监听(意图读取)。
22284
- * ⚠️ 与 `startFace` 的关系同其余各面:**启动本身不读它**(`startFace` 是机械入口),
22285
- * 由装配层用它决定"起还是停"。
22286
- */
22287
- function acpFaceEnabled() {
22288
- return readSimpleSettings().acpFaceEnabled;
22289
- }
22290
- /**
22291
- * 把客户端给的 `cwd` 归一化成宿主要求的**绝对路径**(v1.59.1)。
22292
- *
22293
- * ## 为什么需要
22294
- * 宿主 `dsh-acp` 在每次 `session/new` 都有这一行(`lib/index.js` 逐字):
22295
- * ```js
22296
- * if (!isAbsolute(params.cwd)) throw invalidParams(`cwd must be an absolute path: ${params.cwd}`)
22297
- * ```
22298
- * 而**连接归属哪个 CCC 已由 token 决定**(服务端权威)⇒ 让用户再手打一遍绝对路径
22299
- * 是**同一个信息说两遍**,且那第二遍正是最容易打错的一遍。
22300
- *
22301
- * ## 规则(唯一一条)
22302
- * **非绝对路径 ⇒ 一律解释为「本连接归属 CCC 的根」**。
22303
- *
22304
- * | 客户端填 | 送宿主 |
22305
- * |---|---|
22306
- * | `serenity` / `a` / `.` / 空 | `/…/home-serenity`(该 token 的 CCC 根) |
22307
- * | `/…/home-serenity`(已绝对且在根内) | 原样 |
22308
- * | `/…/home-serenity/sub` | 原样(仍在根内) |
22309
- * | `/etc`(绝对但在根外) | **原样透传,交给 `cwdInsideRoot` 拒** |
22310
- *
22311
- * 🔴 **最后一行为什么不在这里直接拒**:越权判据必须**只有一处**(`cwdInsideRoot`,
22312
- * 它也管着角色认领),在这里再判一次就是**两份会漂的实现**。归一化只负责"把非绝对变成绝对"。
22313
- *
22314
- * @param raw 客户端原样给的 `cwd`(可能是 `undefined`)
22315
- * @param cccRoot 本连接归属的 CCC 根
22316
- */
22317
- function normalizeClientCwd(raw, cccRoot) {
22318
- const value = typeof raw === "string" ? raw.trim() : "";
22319
- if (value === "" || !isAbsolute(value)) return cccRoot;
22320
- return value;
22321
- }
22322
- /**
22323
- * 把一条裸 WS socket 适配成 ACP `Stream`。
22324
- *
22325
- * 🔴 与官方 `ndJsonStream` **同语义**:我们自己做「消息 ↔ 文本帧」的分帧(按行)。
22326
- * 实测注意(P0′):`dsh-acp` 写出的每条消息**自带换行**,客户端同样 ⇒ 按行切分即正确。
22327
- * ⚠️ **空行要丢**;**非 JSON 帧要容忍**(桥/客户端可能插入控制帧)。
22328
- *
22329
- * 🔴 v1.59.1:**入站消息在这里做一次 `cwd` 归一化**(见 {@link normalizeClientCwd})。
22330
- * 为什么是这里:`session/new` 与 `session/resume` 都带 `cwd`,而这是**唯一**能同时
22331
- * 拦到两者、且属于我们自己的缝(再往下就进 `dsh-acp` 了,那里改了就是改宿主)。
22332
- *
22333
- * 🔴 v1.59.2:这条缝同时承担 **owner「每账户固定一条长期对话」** 的双向改写 ——
22334
- * 入站 `recordAcpPending`(记 id→method)+ 出站 `rewriteAcpResponse`(收窄 list / 记 new)。
22335
- * ⚠️ 两件事都必须在**同一个** `ws` 上做:`pending` 是**每条连接一份**的,
22336
- * 拆到两处会让它失去"这条连接上请求与响应配对"这个唯一用途。
22337
- *
22338
- * @param cccRoot 本连接归属的 CCC 根(token 反查所得)
22339
- * @param accountId 本连接归属的账户键(主会话键由它派生)
22340
- */
22341
- function wsToAcpStream(ws, onWarn, cccRoot, accountId) {
22342
- const pin = createAcpSessionPin(cccRoot, accountId);
22343
- return {
22344
- readable: new ReadableStream({ start(controller) {
22345
- let buf = "";
22346
- ws.on("message", (data) => {
22347
- buf += typeof data === "string" ? data : String(data);
22348
- let i;
22349
- while ((i = buf.indexOf("\n")) >= 0) {
22350
- const line = buf.slice(0, i);
22351
- buf = buf.slice(i + 1);
22352
- if (!line.trim()) continue;
22353
- let msg;
22354
- try {
22355
- msg = JSON.parse(line);
22356
- } catch {
22357
- onWarn(line.slice(0, 120));
22358
- continue;
22359
- }
22360
- recordAcpPending(pin, msg);
22361
- controller.enqueue(normalizeInboundCwd(msg, cccRoot));
22362
- }
22363
- });
22364
- ws.on("close", () => {
22365
- try {
22366
- controller.close();
22367
- } catch {}
22368
- });
22369
- ws.on("error", () => {
22370
- try {
22371
- controller.close();
22372
- } catch {}
22373
- });
22374
- } }),
22375
- writable: new WritableStream({ write(msg) {
22376
- ws.send(`${JSON.stringify(rewriteAcpResponse(pin, msg))}\n`);
22377
- } })
22378
- };
22379
- }
22380
- /**
22381
- * 需要把 `cwd` 钉到本连接 CCC 根的方法(**必须逐个列出**)。
22382
- *
22383
- * 🔴 **v1.59.1 的第一版判据错了**:只判「params 里有没有 `cwd`」⇒ 漏掉 `session/list`。
22384
- * 宿主 `listSessions` 的过滤是
22385
- * `if (params.cwd !== void 0 && !sameDirectory(header.cwd, params.cwd)) return`
22386
- * —— **不给 cwd 就返回全机器所有会话**。实测后果:手机 App 列表里冒出
22387
- * 23 条别的 CCC 的会话,全部显示为 `UNKNOWN` / `New Chat`
22388
- * (owner:「**我需要是指定CCC的,不然没办法用**」)。
22389
- * ⇒ 🆕 **判据:凡"应当被 CCC 收窄"的读接口,都必须把 `cwd` 钉成 CCC 根,不能依赖客户端传** ——
22390
- * token 已经定了 CCC,让客户端再声明一次既冗余、又可以被静默漏掉。
22391
- */
22392
- const CWD_PINNED_METHODS = /* @__PURE__ */ new Set([
22393
- "session/new",
22394
- "session/resume",
22395
- "session/list"
22396
- ]);
22397
- /**
22398
- * 把一条入站消息的 `cwd` 归一化(**含"没给就补上"**)。
22399
- *
22400
- * | 情形 | 结果 |
22401
- * |---|---|
22402
- * | 方法不在 {@link CWD_PINNED_METHODS} | 原样(不碰别的消息) |
22403
- * | 给了非绝对路径(`serenity` / `a` / 空串) | → 本连接 CCC 根 |
22404
- * | 给了绝对路径 | 原样(越界由 `cwdInsideRoot` 拒) |
22405
- * | **压根没给**(`session/list` 常见) | **→ 本连接 CCC 根**(否则列出全机器) |
22406
- */
22407
- function normalizeInboundCwd(msg, cccRoot) {
22408
- if (typeof msg !== "object" || msg === null) return msg;
22409
- const m = msg;
22410
- if (typeof m.method !== "string" || !CWD_PINNED_METHODS.has(m.method)) return msg;
22411
- const params = typeof m.params === "object" && m.params !== null ? m.params : {};
22412
- return {
22413
- ...msg,
22414
- params: {
22415
- ...params,
22416
- cwd: normalizeClientCwd(params.cwd, cccRoot)
22417
- }
22418
- };
22419
- }
22420
- /** 请求 id 的 Map 键(区分 `1` 与 `"1"` —— JSON-RPC 允许两者,混用也不该串号) */
22421
- function requestIdKey(id) {
22422
- return `${typeof id}:${String(id)}`;
22423
- }
22424
- /** 记账上限(防"客户端发一堆请求又从不收响应"把内存撑大) */
22425
- const MAX_PENDING = 256;
22426
- /** 建一条连接的钉住状态 */
22427
- function createAcpSessionPin(root, accountId) {
22428
- return {
22429
- root,
22430
- accountId,
22431
- pending: /* @__PURE__ */ new Map()
22432
- };
22433
- }
22434
- /**
22435
- * 入站记账:把"这条请求的方法"记住,供出站按 `id` 反查。
22436
- *
22437
- * 判据:**既要有 `id`,又要有 `method`** —— 只满足一个的是响应(`id` 无 `method`)
22438
- * 或通知(`method` 无 `id`)⇒ 与我们无关,不记。
22439
- */
22440
- function recordAcpPending(pin, msg) {
22441
- if (typeof msg !== "object" || msg === null) return;
22442
- const m = msg;
22443
- if (m.id === void 0 || m.id === null) return;
22444
- if (typeof m.method !== "string") return;
22445
- const key = requestIdKey(m.id);
22446
- pin.pending.set(key, m.method);
22447
- while (pin.pending.size > MAX_PENDING) {
22448
- const oldest = pin.pending.keys().next();
22449
- if (oldest.done) break;
22450
- pin.pending.delete(oldest.value);
22451
- }
22452
- }
22453
- /**
22454
- * 出站改写(本功能的落点)。返回**要发给客户端的那条消息**。
22455
- *
22456
- * | 原响应 | 改写后 |
22457
- * |---|---|
22458
- * | `session/list` | `{sessions:[该账户那一条]}`;**没有主会话 ⇒ `{sessions:[]}`** |
22459
- * | `session/new` | 原样,**同时**把 `result.sessionId` 记为该账户的主会话 |
22460
- * | 其余(含所有 error 响应、所有通知) | 原样 |
22461
- *
22462
- * 🔴 **没有主会话时回空数组,绝不回退成全量列表** —— 退回去正是 owner 说
22463
- * 「23 个对我没什么用」的那个东西。空数组会让 App 引导用户新建,而新建即建立主会话。
22464
- *
22465
- * 🔴 `nextCursor` **一律丢掉**:收窄到一条之后,分页游标失去意义;
22466
- * 留着它会让客户端继续翻页(而后续页仍会被收窄成同一条 ⇒ 前端可能出现重复项)。
22467
- */
22468
- function rewriteAcpResponse(pin, msg) {
22469
- if (typeof msg !== "object" || msg === null) return msg;
22470
- const m = msg;
22471
- if (m.method !== void 0) return msg;
22472
- const method = m.id === void 0 || m.id === null ? void 0 : pin.pending.get(requestIdKey(m.id));
22473
- if (method === void 0) return msg;
22474
- if (m.error !== void 0) return msg;
22475
- if (method === "session/new") {
22476
- const sessionId = m.result?.sessionId;
22477
- if (typeof sessionId === "string" && sessionId.trim() !== "") try {
22478
- writeAcpMainSession(pin.root, pin.accountId, sessionId);
22479
- } catch (err) {
22480
- console.warn(`[serenity-hooks] ⚠ 面 E:记录主会话失败(CCC=${pin.root} 账户=${pin.accountId}): ${String(err?.message ?? err)}`);
22481
- }
22482
- return msg;
22483
- }
22484
- if (method === "session/list") {
22485
- const pinned = readAcpMainSession(pin.root, pin.accountId);
22486
- if (!pinned) return {
22487
- ...m,
22488
- result: { sessions: [] }
22489
- };
22490
- const list = m.result?.sessions;
22491
- const hit = Array.isArray(list) ? list.find((s) => s?.sessionId === pinned) : void 0;
22492
- const sessions = hit !== void 0 ? [hit] : [{
22493
- sessionId: pinned,
22494
- cwd: pin.root
22495
- }];
22496
- return {
22497
- ...m,
22498
- result: { sessions }
22499
- };
22500
- }
22501
- return msg;
22502
- }
22503
- function newState() {
22504
- return {
22505
- handle: null,
22506
- wss: null,
22507
- connections: 0,
22508
- applied: 0,
22509
- rejected: 0,
22510
- lastErrors: [],
22511
- disposers: []
22512
- };
22513
- }
22514
- /** 当前面状态(供 `status()` / 诊断读;无监听时 `handle` 为 null) */
22515
- let state = newState();
22516
- /** 保留最近若干条连接错误(诊断窗口;不无限增长) */
22517
- const MAX_ERR_KEEP = 8;
22518
- /**
22519
- * 启动面 E(幂等;重复调用只起一次)。
22520
- *
22521
- * 🔴 **失败语义**:本面**默认关**且是**可选面** ⇒ 起不来**不得**影响插件其余部分
22522
- * (调用方负责 catch;本函数把失败原样抛出,由装配层记日志)。
22523
- */
22524
- async function startAcpFace(ctx) {
22525
- const port = readSimpleSettings().acpFacePort;
22526
- let dshAcp;
22527
- let wsMod;
22528
- try {
22529
- dshAcp = await import("@deepseek-ai/dsh-acp");
22530
- wsMod = await import("ws");
22531
- } catch (err) {
22532
- const msg = String(err?.message ?? err);
22533
- throw new Error(`面 E 依赖不可用(${msg})—— 需要宿主提供 @deepseek-ai/dsh-acp,且本插件能 resolve 到 ws`);
22534
- }
22535
- if (typeof dshAcp.apply !== "function") throw new Error("面 E:@deepseek-ai/dsh-acp 未导出 apply(版本不匹配?)");
22536
- const wss = new wsMod.WebSocketServer({ noServer: true });
22537
- const local = newState();
22538
- local.wss = wss;
22539
- const handle = await startFace({
22540
- name: FACE_ACP,
22541
- port,
22542
- host: ACP_HOST,
22543
- enabled: () => readSimpleSettings().acpFaceEnabled,
22544
- handler: (_req, res) => {
22545
- res.writeHead(426, {
22546
- "content-type": "text/plain; charset=utf-8",
22547
- "upgrade": "websocket",
22548
- "cache-control": "no-store"
22549
- });
22550
- res.end("This is an ACP (Agent Client Protocol) endpoint over WebSocket. Use an ACP client.\n");
22551
- }
22552
- });
22553
- local.handle = handle;
22554
- handle.server.on("upgrade", (req, socket, head) => {
22555
- local.connections += 1;
22556
- const verdict = authorizeUpgrade(req);
22557
- if (!verdict.ok) {
22558
- local.rejected += 1;
22559
- local.lastErrors.push({
22560
- at: Date.now(),
22561
- applied: false,
22562
- error: `auth: ${verdict.reason}`
22563
- });
22564
- if (local.lastErrors.length > MAX_ERR_KEEP) local.lastErrors.shift();
22565
- try {
22566
- socket.write(`HTTP/1.1 401 Unauthorized\r
22567
- content-type: text/plain; charset=utf-8\r
22568
- connection: close\r
22569
- \r
22570
- ACP 面:认证失败(${verdict.reason})。请在该 CCC 的 localstore 生成并使用 IM_ACP_TOKEN。\n`);
22571
- } catch {}
22572
- try {
22573
- socket.destroy();
22574
- } catch {}
22575
- console.warn(`[serenity-hooks] ✗ 面 E:拒绝一条连接(${verdict.reason})`);
22576
- return;
22577
- }
22578
- try {
22579
- wss.handleUpgrade(req, socket, head, (ws) => {
22580
- try {
22581
- const connRoot = verdict.root;
22582
- const connRole = verdict.role;
22583
- const connAccount = verdict.accountId;
22584
- const stream = wsToAcpStream(ws, (line) => {
22585
- console.log(`[serenity-hooks] 面 E:丢弃一条非 JSON 帧(前 120 字): ${line}`);
22586
- }, connRoot, connAccount);
22587
- const fiber = ctx.plugin({
22588
- inject: ACP_REQUIRED_SERVICES,
22589
- apply: (scoped) => {
22590
- const sel = resolveAcpSelection(scoped);
22591
- bindRoleToConnScopedAgent(scoped, connRoot, connRole);
22592
- dshAcp.apply(scoped, {
22593
- stream,
22594
- provider: sel.provider,
22595
- model: sel.model
22596
- });
22597
- }
22598
- });
22599
- local.disposers.push(() => {
22600
- try {
22601
- fiber?.dispose?.();
22602
- } catch {}
22603
- });
22604
- local.applied += 1;
22605
- } catch (err) {
22606
- const msg = String(err?.message ?? err).slice(0, 200);
22607
- local.lastErrors.push({
22608
- at: Date.now(),
22609
- applied: false,
22610
- error: msg
22611
- });
22612
- if (local.lastErrors.length > MAX_ERR_KEEP) local.lastErrors.shift();
22613
- console.warn(`[serenity-hooks] ✗ 面 E:dsh-acp 接线失败: ${msg}`);
22614
- try {
22615
- ws.close(1011, "acp bridge failed");
22616
- } catch {}
22617
- }
22618
- });
22619
- } catch (err) {
22620
- console.warn(`[serenity-hooks] ✗ 面 E:WebSocket 握手失败: ${String(err?.message ?? err)}`);
22621
- try {
22622
- socket.destroy();
22623
- } catch {}
22624
- }
22625
- });
22626
- state = local;
22627
- console.log(`[serenity-hooks] ✓ 面 E(官方 ACP):ws://${ACP_HOST}:${port} ⚠️ 协议无认证,靠外部 Access 兜底`);
22628
- return handle;
22629
- }
22630
- /**
22631
- * 停面 E(幂等)。
22632
- *
22633
- * ⚠️ **与其余各面同款注意**:`stopFace` **不用** `closeAllConnections()`(那会掐断在飞的请求);
22634
- * WS 连接由 `wss.close()` 与 socket 自身生命周期收束。
22635
- */
22636
- function stopAcpFace() {
22637
- const wss = state.wss;
22638
- if (wss) try {
22639
- wss.close();
22640
- } catch {}
22641
- state.disposers = [];
22642
- state.wss = null;
22643
- state.handle = null;
22644
- const stopped = stopFace(FACE_ACP);
22645
- if (stopped) console.log("[serenity-hooks] face acp: 已停止");
22646
- return stopped;
22647
- }
22648
- /** ACP 会话用的 provider(取 CCC 现有路由;留空 = 由宿主/请求监听器决定) */
22649
- function readAcpProvider() {
22650
- return process.env.SERENITY_ACP_PROVIDER || void 0;
22651
- }
22652
- /** ACP 会话用的 model(同上) */
22653
- function readAcpModel() {
22654
- return process.env.SERENITY_ACP_MODEL || void 0;
22655
- }
22656
- /**
22657
- * 解析 ACP 会话该用的 `{provider, model}`。
22658
- *
22659
- * ## 🔴 为什么必须有这个函数(2026-10-16 容器实测抓出的**真缺陷**)
22660
- *
22661
- * 早先我们是**裸传** `provider: readAcpProvider(), model: readAcpModel()` —— 而它们读的是
22662
- * **环境变量**,默认**未设** ⇒ 传下去就是 `undefined`。后果**不是"用默认模型"**:
22663
- * 上游 `dsh-acp` 的 `agentOptions()`(`lib/index.js:1356-1361`)**只在字段非 undefined 时才带上**:
22664
- * ```js
22665
- * ...config.provider !== void 0 ? { provider: config.provider } : {},
22666
- * ...config.model !== void 0 ? { model: config.model } : {}
22667
- * ```
22668
- * ⇒ **我们传 undefined,agent 就真的没有模型** ⇒ 第一条 `session/prompt` 必失败,逐字报:
22669
- * `Internal error: turn failed: prompt variable "{{model}}" has no value for this assembly
22670
- * (section "deployment:persona-prefix")`。
22671
- *
22672
- * ⚠️ **网页端不中招**(它经 `agent-default-model` 层拿到默认值)⇒ **只有 ACP 这条入口坏** ——
22673
- * 也就是说:**手机上第一条消息必然失败,而本机怎么试都正常**。
22674
- * 🔴 这个缺陷 **typecheck / 2812 条单测 / deploy 预检 全绿**(它是运行时合成形态,
22675
- * 静态面结构性地看不见)⇒ **只有"容器 + 真模型"那一跑能抓到它**。
22676
- *
22677
- * ## 修法 = **读宿主自己的默认模型服务**(不另造真相源)
22678
- *
22679
- * 宿主暴露服务 `ctx.agentDefaultModel.currentSelection()`,其契约逐字写着
22680
- * *"Default model selection for Agents created **without an explicit model**"*
22681
- * (`dsh-agent-default-model/lib/types/index.d.ts`)—— 这正是"没显式指定时该用什么"的**权威出处**。
22682
- * ⇒ 我们只是**把它转交给 ACP**,不引入第二套缺省。
22683
- *
22684
- * 优先级(**显式 > 宿主默认**):`SERENITY_ACP_PROVIDER/MODEL` 环境变量(调试用)> 宿主默认。
22685
- * ⚠️ **一处如实标注的弱化**:这里**不打日志**(每次连接都解析一次,打日志会刷屏);
22686
- * 若宿主服务缺失,**退回"两个都 undefined"=旧行为**(即那条已知会失败的形态)——
22687
- * 而不是静默编一个模型。⇒ 副作用是"没有默认模型时仍会失败",但那**与宿主自身缺配置时的行为一致**。
22688
- *
22689
- * 🔵 **公开导出**(而非模块私有):它是一个**纯函数 + 有独立契约**的单元(本仓同类先例 =
22690
- * `host/access.ts` 的一组读取器),且**那个真缺陷正是在它这一层**(裸传 undefined)——
22691
- * 回归钉必须能**直接钉住它**,而不是隔着 WS 握手去间接观察(那样测不出"取到了什么")。
22692
- */
22693
- function resolveAcpSelection(ctx) {
22694
- const envProvider = readAcpProvider();
22695
- const envModel = readAcpModel();
22696
- if (envProvider && envModel) return {
22697
- provider: envProvider,
22698
- model: envModel
22699
- };
22700
- try {
22701
- const sel = hostInjected(ctx, "agentDefaultModel")?.currentSelection?.();
22702
- if (sel?.provider && sel?.model) return {
22703
- provider: sel.provider,
22704
- model: sel.model
22705
- };
22706
- } catch {}
22707
- return {
22708
- provider: envProvider,
22709
- model: envModel
22710
- };
22711
- }
22712
- /**
22713
- * 装配(`index.ts` 的 `apply` 调用):按**面板/部署键**启停面 E,配置变化热同步。
22714
- *
22715
- * 🔴 **W3 接线**(2026-10-16):本轮之前 `startAcpFace` 虽已实现却**没有任何调用点**
22716
- * —— 即"面写好了但从不起"。本函数补上它,形式与两个兄弟面一致
22717
- * (`registerWeixinSendApi` / `registerDiagramTool`):
22718
- *
22719
- * ① **反应式**:面板拨开关**只改配置、不重跑 `apply`**(v1.51.1 实测过的那条铁律)
22720
- * ⇒ 必须监听事件才会"拨了就有反应";
22721
- * ② 监听**宿主的真事件** `settings/document-updated`(按 ns 过滤),
22722
- * ⛔ **不监听** `serenity/settings-changed` —— 那个事件**全仓没有任何 `emit` 点**
22723
- * (=死通道;见 `registerDiagramTool` 的注释),照它接线等于接了个永远不响的铃;
22724
- * ③ 再挂 `agent/created`/`session/created` 兜底(宿主事件面若换名,至少还有这条能自愈);
22725
- * ④ 启动时同步一次(`apply` 时按当前配置定初始态)。
22726
- *
22727
- * ⚠️ **失败语义**:本面**默认关**且**依赖可选**(`dsh-acp` 是 peer)⇒ 起不来**只记日志**,
22728
- * 绝不抛出(抛出会让整个插件加载失败,而它只是一个可选面)。
22729
- */
22730
- function registerAcpFace(ctx) {
22731
- const sync = () => {
22732
- if (!acpFaceEnabled()) {
22733
- if (acpFaceActive()) stopAcpFace();
22734
- return;
22735
- }
22736
- if (acpFaceActive()) return;
22737
- startAcpFace(ctx).catch((err) => {
22738
- console.error(`[serenity-hooks] ✗ 面 E(官方 ACP)启动失败: ${String(err?.message ?? err)}`);
22739
- });
22740
- };
22741
- try {
22742
- ctx.on("settings/document-updated", (ns) => {
22743
- if (String(ns) === "serenity-hooks") sync();
22744
- });
22745
- } catch {}
22746
- for (const eventName of ["agent/created", "session/created"]) try {
22747
- ctx.on(eventName, () => {
22748
- sync();
22749
- });
22750
- } catch {}
22751
- const refresh = () => {
22752
- refreshAcpRoots(ctx).then((n) => {
22753
- console.log(`[serenity-hooks] 面 E:CCC 候选根已枚举(${n} 个)`);
22754
- });
22755
- };
22756
- try {
22757
- ctx.on("settings/document-updated", (ns) => {
22758
- if (String(ns) === "serenity-hooks") refresh();
22759
- });
22760
- } catch {}
22761
- refresh();
22762
- sync();
22763
- }
22764
- //#endregion
22765
21711
  //#region src/seams/lifecycle.ts
22766
21712
  /** 从 Agent / Session / 事件负载中取会话 id(形状宽容:未知结构返回 null) */
22767
21713
  function sessionIdOf(value) {
@@ -25400,10 +24346,6 @@ const Config = z.object({
25400
24346
  opencodeProvider: z.object({ autoConfigure: z.boolean().default(true) }),
25401
24347
  visionPatch: z.object({ enabled: z.boolean().default(true) }),
25402
24348
  diagram: z.object({ enabled: z.boolean().default(false) }),
25403
- acpFace: z.object({
25404
- enabled: z.boolean().default(false),
25405
- port: z.number().min(1024).max(65535).default(ACP_PORT)
25406
- }),
25407
24349
  humanChannel: z.object({
25408
24350
  accounts: z.array(humanChannelAccountSchema).default([]),
25409
24351
  subscriptions: z.array(humanChannelSubscriptionSchema).default([]),
@@ -25415,9 +24357,7 @@ const Config = z.object({
25415
24357
  skiffEnabled: z.boolean().volatile(),
25416
24358
  skiffDebugPort: z.number().min(1024).max(65535).volatile(),
25417
24359
  croEnabled: z.boolean().volatile(),
25418
- diagramEnabled: z.boolean().volatile(),
25419
- acpFaceEnabled: z.boolean().volatile(),
25420
- acpFacePort: z.number().min(1024).max(65535).volatile()
24360
+ diagramEnabled: z.boolean().volatile()
25421
24361
  });
25422
24362
  function apply(ctx, config) {
25423
24363
  try {
@@ -25465,7 +24405,14 @@ function apply(ctx, config) {
25465
24405
  });
25466
24406
  if (config.api) {
25467
24407
  registerStatusApi(ctx, { configPaths: config.serenityConfigPaths });
25468
- registerVoyageApi(ctx);
24408
+ registerVoyageApi(ctx, { resolveRoot: (req) => {
24409
+ const url = new URL(req.url ?? "/", "http://localhost");
24410
+ const ws = resolveWorkspace(ctx, {
24411
+ sessionId: url.searchParams.get("sessionId") ?? void 0,
24412
+ workspace: url.searchParams.get("workspace") ?? void 0
24413
+ });
24414
+ return cccRootForCwd(ws);
24415
+ } });
25469
24416
  }
25470
24417
  registerSettingsSection(ctx, config);
25471
24418
  if (config.tools) registerDiagramTool(ctx);
@@ -25480,7 +24427,6 @@ function apply(ctx, config) {
25480
24427
  registerWakeScheduler(ctx);
25481
24428
  registerWeixinBridge(ctx);
25482
24429
  registerWeixinSendApi(ctx);
25483
- registerAcpFace(ctx);
25484
24430
  registerWeixinOutputGuard(ctx);
25485
24431
  registerLifecycle(ctx);
25486
24432
  if (config.webFetch?.enabled !== false) registerWebFetchProvider(ctx);