@shgroup/dsh-serenity-hooks 1.59.0 → 1.59.2

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,10 +2,11 @@ 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-GIJ3A_tw.js";
3
3
  import { _ as resolveInside, a as findSerenityRoot$1, c as matchBlacklist, d as readCccName$1, f as readCheapWorkerConfig, h as readTrajectorySkills, i as findGitRoot, l as pathInside, m as readSafeModeHiddenTools, n as SAFE_MODE_MARKER, o as isSafeModeOn, p as readExclusiveTools, r as classifyPath, s as loadSerenityConfig, t as DEFAULT_SERENITY_CONFIG_PATHS, u as readBlacklist } from "./ccc-B0ZGL9lx.js";
4
4
  import { A as localIdStamp, C as setActiveSessionInfo, D as isoLocal, E as useSession, O as localFileStamp, S as sessionsRoot, T as summarize, _ as namingTitleFor, a as pruneMissingBindings, b as resolveSessionByTitle, c as setBindingStore, d as clearActiveSessionInfo, f as createSession, g as listSessions, h as getActiveSessionInfo, i as listBoundSessionIds, k as localHuman, l as DEFAULT_SESSION_SCOPE, m as findSession, n as PLUGIN_SOURCE, o as readLastBound, p as extractSessionMdPathFromText, r as appendBound, s as resolveSessionTrajectoryLabel, t as ACC_MESSAGE_KIND, u as TRAJECTORY_ACTIONS, v as parseSessionContextFromEvents, w as showSession, x as sessionEvents$1, y as readActiveSessionMd } from "./message-source-B4P3Uw6D.js";
5
- import { _ as runLocalStore, a as readWeixinCredential, c as weixinInboundDir, d as LOCALSTORE_SCOPES, f as checkLocalstoreGitCompliance, g as readStore, h as readGitTrack, i as matchWeixinRoute, l as weixinSessionIdFor, m as localstorePath, n as extractWeixinText, o as readWeixinSettings, p as getEntry, r as hasVoiceItem, s as sanitizeFileName$1, t as extractWeixinMedia, v as writeEntry } from "./weixin-route-CSDBGLwl.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-DMs_ovHo.js";
6
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-DGUQZo5j.js";
7
7
  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-B615L9MV.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-Aheyq3fN.js";
9
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-BrRcPiQs.js";
10
11
  import { createRequire } from "node:module";
11
12
  import z from "@deepseek-ai/schemastery";
@@ -15004,6 +15005,8 @@ const FILE_UPLOAD_PATH = "/serenity/file-upload";
15004
15005
  const CONFIG_PATH = "/serenity/config";
15005
15006
  const CCCS_PATH = "/serenity/cccs";
15006
15007
  const WEIXIN_PATH = "/serenity/weixin";
15008
+ /** 🔴 v1.59.1:ACP 账户台账(面板数据面;形状照 `/serenity/weixin`) */
15009
+ const ACP_PATH = "/serenity/acp";
15007
15010
  /** 图片落盘目录(CCC 根相对;S142 图片自动识别基础设施——粘贴图片落盘供 agent 经 CCC vlm MSM 自主处理) */
15008
15011
  const IMAGE_UPLOAD_DIR = "_tmp/images_from_user";
15009
15012
  const IMAGE_MEDIA_TYPES = /* @__PURE__ */ new Set([
@@ -15323,11 +15326,11 @@ function registerStatusApi(ctx, opts = {}) {
15323
15326
  sendJson$3(res, 400, { error: chk.error });
15324
15327
  return;
15325
15328
  }
15326
- const { readWeixinSettings } = await import("./weixin-route-CSDBGLwl.js").then((n) => n.u);
15329
+ const { readWeixinSettings } = await import("./weixin-route-Aheyq3fN.js").then((n) => n.u);
15327
15330
  const { weixinBridgeStatus } = await Promise.resolve().then(() => weixin_bridge_exports);
15328
15331
  const settings = readWeixinSettings(chk.root);
15329
15332
  const bridge = weixinBridgeStatus().find((b) => b.ccc === chk.root);
15330
- const { readWeixinCredential } = await import("./weixin-route-CSDBGLwl.js").then((n) => n.u);
15333
+ const { readWeixinCredential } = await import("./weixin-route-Aheyq3fN.js").then((n) => n.u);
15331
15334
  const accounts = (settings.accounts ?? []).map((a) => ({
15332
15335
  accountId: a.accountId,
15333
15336
  name: a.name ?? void 0,
@@ -15359,7 +15362,7 @@ function registerStatusApi(ctx, opts = {}) {
15359
15362
  const { fetchQRCode } = await import("./weixin-api-BrRcPiQs.js").then((n) => n.u);
15360
15363
  const { randomUUID } = await import("node:crypto");
15361
15364
  for (const [key, v] of weixinLogins) if (Date.now() - v.startedAt > WEIXIN_LOGIN_TTL_MS) weixinLogins.delete(key);
15362
- const { readWeixinSettings } = await import("./weixin-route-CSDBGLwl.js").then((n) => n.u);
15365
+ const { readWeixinSettings } = await import("./weixin-route-Aheyq3fN.js").then((n) => n.u);
15363
15366
  const qr = await fetchQRCode({ botType: readWeixinSettings(root).botType ?? void 0 });
15364
15367
  const loginKey = randomUUID();
15365
15368
  weixinLogins.set(loginKey, {
@@ -15380,7 +15383,7 @@ function registerStatusApi(ctx, opts = {}) {
15380
15383
  sendJson$3(res, 400, { error: "missing accountId" });
15381
15384
  return;
15382
15385
  }
15383
- const { removeWeixinAccount } = await import("./weixin-route-CSDBGLwl.js").then((n) => n.u);
15386
+ const { removeWeixinAccount } = await import("./weixin-route-Aheyq3fN.js").then((n) => n.u);
15384
15387
  const { syncCccBridge } = await Promise.resolve().then(() => weixin_bridge_exports);
15385
15388
  removeWeixinAccount(root, body.accountId);
15386
15389
  syncCccBridge(ctx, root);
@@ -15393,7 +15396,7 @@ function registerStatusApi(ctx, opts = {}) {
15393
15396
  sendJson$3(res, 400, { error: "invalid routes (expected [{user, role}, ...])" });
15394
15397
  return;
15395
15398
  }
15396
- const { saveWeixinRoutes } = await import("./weixin-route-CSDBGLwl.js").then((n) => n.u);
15399
+ const { saveWeixinRoutes } = await import("./weixin-route-Aheyq3fN.js").then((n) => n.u);
15397
15400
  const { readSkiffRoles } = await import("./skiff-role-B615L9MV.js").then((n) => n.u);
15398
15401
  const roles = readSkiffRoles(root);
15399
15402
  for (const r of routes) if (!roles.has(r.role)) {
@@ -15406,7 +15409,7 @@ function registerStatusApi(ctx, opts = {}) {
15406
15409
  }
15407
15410
  if (body.action === "set-enabled") {
15408
15411
  const enabled = body.enabled === true;
15409
- const { readWeixinSettings, setWeixinEnabled } = await import("./weixin-route-CSDBGLwl.js").then((n) => n.u);
15412
+ const { readWeixinSettings, setWeixinEnabled } = await import("./weixin-route-Aheyq3fN.js").then((n) => n.u);
15410
15413
  const { syncCccBridge } = await Promise.resolve().then(() => weixin_bridge_exports);
15411
15414
  const settings = readWeixinSettings(root);
15412
15415
  if (enabled && (settings.accounts ?? []).length === 0) {
@@ -15450,7 +15453,7 @@ function registerStatusApi(ctx, opts = {}) {
15450
15453
  return;
15451
15454
  }
15452
15455
  const { pollQRStatus } = await import("./weixin-api-BrRcPiQs.js").then((n) => n.u);
15453
- const { readWeixinSettings } = await import("./weixin-route-CSDBGLwl.js").then((n) => n.u);
15456
+ const { readWeixinSettings } = await import("./weixin-route-Aheyq3fN.js").then((n) => n.u);
15454
15457
  const settings = readWeixinSettings(login.root);
15455
15458
  const status = await pollQRStatus({
15456
15459
  baseUrl: void 0,
@@ -15465,7 +15468,7 @@ function registerStatusApi(ctx, opts = {}) {
15465
15468
  });
15466
15469
  return;
15467
15470
  }
15468
- const { upsertWeixinAccount, writeWeixinCredential, nextWeixinAccountId } = await import("./weixin-route-CSDBGLwl.js").then((n) => n.u);
15471
+ const { upsertWeixinAccount, writeWeixinCredential, nextWeixinAccountId } = await import("./weixin-route-Aheyq3fN.js").then((n) => n.u);
15469
15472
  const { syncCccBridge } = await Promise.resolve().then(() => weixin_bridge_exports);
15470
15473
  const accountId = nextWeixinAccountId(settings);
15471
15474
  upsertWeixinAccount(login.root, {
@@ -15549,6 +15552,7 @@ function registerStatusApi(ctx, opts = {}) {
15549
15552
  }
15550
15553
  }
15551
15554
  });
15555
+ registerAcpAccountsApi(ctx);
15552
15556
  }
15553
15557
  /** 进行中的扫码登录(loginKey → 状态;进程内,5min 有效) */
15554
15558
  const weixinLogins = /* @__PURE__ */ new Map();
@@ -15569,6 +15573,99 @@ function requireCcc(root) {
15569
15573
  root: serenityRoot
15570
15574
  };
15571
15575
  }
15576
+ /** ACP 账户台账端点(面板专用;形状照 `/serenity/weixin`) */
15577
+ function registerAcpAccountsApi(ctx) {
15578
+ ctx.webServer.register({
15579
+ kind: "exact",
15580
+ path: ACP_PATH,
15581
+ handler: async (req, res) => {
15582
+ try {
15583
+ if (!requireWebUi(req, res, "ACP 账户配置仅限 WebUI(client 专用)")) return;
15584
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
15585
+ if (req.method === "GET") {
15586
+ const chk = requireCcc(url.searchParams.get("ccc") ?? void 0);
15587
+ if (!chk.ok) {
15588
+ sendJson$3(res, 400, { error: chk.error });
15589
+ return;
15590
+ }
15591
+ const { acpChannelStatus, listAcpRoots } = await Promise.resolve().then(() => acp_auth_exports);
15592
+ const { listCccs } = await import("./ccc-roots-GIJ3A_tw.js").then((n) => n.a);
15593
+ const st = acpChannelStatus(chk.root);
15594
+ const all = (await listCccs(ctx)).map((e) => e.root);
15595
+ sendJson$3(res, 200, {
15596
+ channel: st.channel,
15597
+ enabled: st.enabled,
15598
+ accounts: st.accounts,
15599
+ roots: listAcpRoots(all)
15600
+ });
15601
+ return;
15602
+ }
15603
+ if (req.method !== "POST") {
15604
+ sendJson$3(res, 405, { error: "method not allowed" });
15605
+ return;
15606
+ }
15607
+ const raw = await readBody$3(req, 65536);
15608
+ const body = JSON.parse(raw);
15609
+ const chk = requireCcc(body.ccc);
15610
+ if (!chk.ok) {
15611
+ sendJson$3(res, 400, { error: chk.error });
15612
+ return;
15613
+ }
15614
+ const root = chk.root;
15615
+ const acp = await Promise.resolve().then(() => acp_auth_exports);
15616
+ if (body.action === "upsert") {
15617
+ const account = acp.normalizeAcpAccount(body.account);
15618
+ if (!account) {
15619
+ sendJson$3(res, 400, { error: "invalid account (accountId 与 role 均必填)" });
15620
+ return;
15621
+ }
15622
+ const { readSkiffRoles } = await import("./skiff-role-B615L9MV.js").then((n) => n.u);
15623
+ const roles = readSkiffRoles(root);
15624
+ if (!roles.has(account.role)) {
15625
+ sendJson$3(res, 400, { error: `role "${account.role}" 不在该 CCC 的 skiff.roles 里(现有:${[...roles.keys()].join(", ") || "无"})` });
15626
+ return;
15627
+ }
15628
+ acp.upsertAcpAccount(root, account);
15629
+ const created = acp.ensureAcpChannelTokens(root).includes(account.accountId);
15630
+ sendJson$3(res, 200, {
15631
+ saved: true,
15632
+ accounts: acp.acpChannelStatus(root).accounts,
15633
+ token: created ? acp.readAcpToken(root, account.accountId) : void 0
15634
+ });
15635
+ return;
15636
+ }
15637
+ if (body.action === "remove") {
15638
+ const accountId = typeof body.accountId === "string" ? body.accountId.trim() : "";
15639
+ if (accountId === "") {
15640
+ sendJson$3(res, 400, { error: "missing accountId" });
15641
+ return;
15642
+ }
15643
+ acp.removeAcpAccount(root, accountId);
15644
+ sendJson$3(res, 200, {
15645
+ removed: accountId,
15646
+ accounts: acp.acpChannelStatus(root).accounts
15647
+ });
15648
+ return;
15649
+ }
15650
+ if (body.action === "rotate") {
15651
+ const accountId = typeof body.accountId === "string" ? body.accountId.trim() : "";
15652
+ if (accountId === "") {
15653
+ sendJson$3(res, 400, { error: "missing accountId" });
15654
+ return;
15655
+ }
15656
+ sendJson$3(res, 200, {
15657
+ rotated: accountId,
15658
+ token: acp.resetAcpToken(root, accountId)
15659
+ });
15660
+ return;
15661
+ }
15662
+ sendJson$3(res, 400, { error: `unknown action: ${String(body.action)}` });
15663
+ } catch (err) {
15664
+ sendJson$3(res, 400, { error: err?.message ?? String(err) });
15665
+ }
15666
+ }
15667
+ });
15668
+ }
15572
15669
  //#endregion
15573
15670
  //#region src/voyage-page.ts
15574
15671
  const VOYAGE_PATH = "/serenity/voyage";
@@ -21853,19 +21950,59 @@ function registerWeixinSendApi(ctx) {
21853
21950
  *
21854
21951
  * 本模块**零 DSH 依赖**(只 `node:crypto` / `node:fs` / `node:path` 与本仓纯逻辑)⇒ 可独立单测。
21855
21952
  */
21856
- /** localstore 凭据键(credential scope ⇒ UPPER_SNAKE) */
21857
- const ACP_TOKEN_KEY = "IM_ACP_TOKEN";
21953
+ var acp_auth_exports = /* @__PURE__ */ __exportAll({
21954
+ acpChannelEnabled: () => acpChannelEnabled,
21955
+ acpChannelStatus: () => acpChannelStatus,
21956
+ acpSessionKeyFor: () => acpSessionKeyFor,
21957
+ acpTokenKeyFor: () => acpTokenKeyFor,
21958
+ cwdInsideRoot: () => cwdInsideRoot,
21959
+ ensureAcpChannelTokens: () => ensureAcpChannelTokens,
21960
+ ensureAcpToken: () => ensureAcpToken,
21961
+ expandAcpAccounts: () => expandAcpAccounts,
21962
+ extractToken: () => extractToken,
21963
+ listAcpRoots: () => listAcpRoots,
21964
+ normalizeAcpAccount: () => normalizeAcpAccount,
21965
+ readAcpMainSession: () => readAcpMainSession,
21966
+ readAcpToken: () => readAcpToken,
21967
+ readImSettings: () => readImSettings,
21968
+ removeAcpAccount: () => removeAcpAccount,
21969
+ resetAcpToken: () => resetAcpToken,
21970
+ resolveTokenAccount: () => resolveTokenAccount,
21971
+ safeEqual: () => safeEqual,
21972
+ saveAcpAccounts: () => saveAcpAccounts,
21973
+ tokenFingerprint: () => tokenFingerprint,
21974
+ upsertAcpAccount: () => upsertAcpAccount,
21975
+ writeAcpMainSession: () => writeAcpMainSession
21976
+ });
21977
+ /** 多账户凭据键前缀(`IM_ACP_TOKEN_<ACCOUNT_ID>`;与微信桥 `WEIXIN_<ID>_<FIELD>` 同构) */
21978
+ const ACP_TOKEN_PREFIX = "IM_ACP_TOKEN_";
21979
+ /**
21980
+ * `accountId` → UPPER_SNAKE 片段(`agmente-phone` → `AGMENTE_PHONE`)。
21981
+ *
21982
+ * 🔴 **与 `weixin-route.ts` 的 `accountKeyPart` 逐字同规则** —— 刻意复制而非共享:
21983
+ * 两者属不同子域(微信 vs ACP),共享会让"改微信键规则"意外改掉 ACP 的既有凭据键,
21984
+ * 而凭据键**改一次就等于全体客户端掉线**。⇒ 用"同规则的两份"换"互不牵连"。
21985
+ */
21986
+ function accountKeyPart(accountId) {
21987
+ return accountId.toUpperCase().replace(/[^A-Z0-9]+/g, "_");
21988
+ }
21989
+ /** 该账户的凭据键(`IM_ACP_TOKEN_<ACCOUNT_ID>`);`accountId` 必填 */
21990
+ function acpTokenKeyFor(accountId) {
21991
+ return `${ACP_TOKEN_PREFIX}${accountKeyPart(accountId)}`;
21992
+ }
21858
21993
  /** token 字节数(32 B = 256 bit;hex 后 64 字符) */
21859
21994
  const TOKEN_BYTES = 32;
21860
21995
  /**
21861
- * 该 CCC 的 ACP token(**读**;不存在返回 null)。
21996
+ * 该**账户**的 ACP token(**读**;不存在返回 null)。
21997
+ *
21998
+ * 🔴 **不自动生成** —— 生成是**写操作**,只在显式要求时做(`ensureAcpToken`)。
21999
+ * 读路径永远无副作用:否则每次判定都会改 localstore。
21862
22000
  *
21863
- * 🔴 **不自动生成** —— 生成是**写操作**,只在 owner/面板显式要求时做
21864
- * (`ensureAcpToken`)。读路径永远无副作用:否则每次启动都会改 localstore。
22001
+ * @param accountId 账户键(必填 —— 凭据按账户分离,没有"CCC 默认 token"这回事)
21865
22002
  */
21866
- function readAcpToken(root) {
22003
+ function readAcpToken(root, accountId) {
21867
22004
  try {
21868
- const value = getEntry(root, "credential", ACP_TOKEN_KEY);
22005
+ const value = getEntry(root, "credential", acpTokenKeyFor(accountId));
21869
22006
  return typeof value === "string" && value.trim() !== "" ? value.trim() : null;
21870
22007
  } catch {
21871
22008
  return null;
@@ -21876,12 +22013,23 @@ function readAcpToken(root) {
21876
22013
  *
21877
22014
  * ⚠️ **幂等**:已有 token 时**原样返回**(不轮换)—— 轮换会让在用的客户端立刻断线,
21878
22015
  * 必须是一次显式动作(`resetAcpToken`)。
22016
+ *
22017
+ * @param accountId 多账户时指明哪个账户;省略 = 单账户简写键
21879
22018
  */
21880
- function ensureAcpToken(root) {
21881
- const existing = readAcpToken(root);
22019
+ function ensureAcpToken(root, accountId) {
22020
+ const existing = readAcpToken(root, accountId);
21882
22021
  if (existing) return existing;
21883
22022
  const token = randomBytes(TOKEN_BYTES).toString("hex");
21884
- writeEntry(root, "credential", ACP_TOKEN_KEY, token);
22023
+ writeEntry(root, "credential", acpTokenKeyFor(accountId), token);
22024
+ return token;
22025
+ }
22026
+ /**
22027
+ * 轮换 token(显式动作;返回新值)。旧 token 立即失效。
22028
+ * @param accountId 账户键
22029
+ */
22030
+ function resetAcpToken(root, accountId) {
22031
+ const token = randomBytes(TOKEN_BYTES).toString("hex");
22032
+ writeEntry(root, "credential", acpTokenKeyFor(accountId), token);
21885
22033
  return token;
21886
22034
  }
21887
22035
  /**
@@ -21900,6 +22048,49 @@ function safeEqual(a, b) {
21900
22048
  function tokenFingerprint(token) {
21901
22049
  return createHash("sha256").update(token).digest("hex").slice(0, 12);
21902
22050
  }
22051
+ /** 主会话键前缀(`IM_ACP_SESSION_<ACCOUNT_ID>`;与 `IM_ACP_TOKEN_<ACCOUNT_ID>` 同族同规则) */
22052
+ const ACP_SESSION_PREFIX = "IM_ACP_SESSION_";
22053
+ /**
22054
+ * 该账户的**主会话**键(`IM_ACP_SESSION_<ACCOUNT_ID>`)。
22055
+ *
22056
+ * 🔴 为什么放在 **credential** 节而不是 config 节:它跟 token 一样是**每账户一值的键控数据**
22057
+ * (键由 `accountId` 派生、随账户生命周期存亡)。放 config 节要另造一层 `section.key`
22058
+ * 结构,而这一层的唯一读者就是本模块 ⇒ 多一层结构 = 多一处会漂的形状。
22059
+ *
22060
+ * ⚠️ 它**不是凭据**(会话 id 不是秘密)—— 共享 credential 节只是为了复用它既有的
22061
+ * 「按 accountId 派生键 + 删账户时一并清理」这套语义,不引入第二套键规则。
22062
+ */
22063
+ function acpSessionKeyFor(accountId) {
22064
+ return `${ACP_SESSION_PREFIX}${accountKeyPart(accountId)}`;
22065
+ }
22066
+ /**
22067
+ * 读该账户的主会话 id(不存在 ⇒ `null`)。
22068
+ *
22069
+ * 🔴 **读路径无副作用**(与 `readAcpToken` 同纪律):不存在的含义是「这个账户还没有主会话」,
22070
+ * 而那时的正确行为是**让 App 去建**(`session/list` 回空数组),不是替它编一个。
22071
+ */
22072
+ function readAcpMainSession(root, accountId) {
22073
+ try {
22074
+ const value = getEntry(root, "credential", acpSessionKeyFor(accountId));
22075
+ return typeof value === "string" && value.trim() !== "" ? value.trim() : null;
22076
+ } catch {
22077
+ return null;
22078
+ }
22079
+ }
22080
+ /**
22081
+ * 记下该账户的主会话 id(**写操作**;由 `session/new` 的出站响应触发)。
22082
+ *
22083
+ * 🔴 **后写覆盖先写**(last-wins):owner 要的是"一条长期对话",而"再建一条"是用户
22084
+ * 在 App 里显式点「新对话」的结果 ⇒ 那时他要的就是**新的那条**。
22085
+ * ⛔ **被否的两种做法**:① 已有主会话就拒建(用户会看到"点了没反应");
22086
+ * ② 把 `session/new` 改写成 `session/resume`(响应形状不同,要让客户端认一条
22087
+ * **不存在于协议里**的响应 —— 那是拿协议兼容性换一个我们本来就有的语义)。
22088
+ */
22089
+ function writeAcpMainSession(root, accountId, sessionId) {
22090
+ const id = String(sessionId ?? "").trim();
22091
+ if (id === "") return;
22092
+ writeEntry(root, "credential", acpSessionKeyFor(accountId), id);
22093
+ }
21903
22094
  /**
21904
22095
  * 从一次 HTTP upgrade 请求里取出候选 token。
21905
22096
  *
@@ -21922,32 +22113,92 @@ function extractToken(headers, url) {
21922
22113
  return null;
21923
22114
  }
21924
22115
  /**
21925
- * 校验一枚候选 token,返回它归属的 CCC 根(**未命中返回 null**)。
22116
+ * 把该 CCC 的配置**展开成账户列表**(读时现算,**无快照**)。
22117
+ *
22118
+ * 🔴 **`im.acp.accounts` 是必填项** —— 没有它就没有账户,**一个都认证不了**。
22119
+ * 刻意**不提供**"单账户简写"退回路径(v1.59.1):那会多出一条永不生效的分支,
22120
+ * 而它的唯一作用是把"配置写错了"伪装成"用了简写"。
22121
+ *
22122
+ * 判据(三条都由调用方以 fail-closed 消费):
22123
+ * - `enabled !== false`(缺省 true)
22124
+ * - `role` 非空(空 ⇒ 该账户**不出现**在列表里 ⇒ 必然登录失败)
22125
+ * - token 存在(不存在 ⇒ 同样不出现)
22126
+ *
22127
+ * ⚠️ 本函数**纯读,绝不生成 token** —— 生成是写操作,归 `ensureAcpChannelTokens`。
22128
+ * 这样 upgrade 路径可以随时现算而不冒"每次连接都写 localstore"的风险。
22129
+ */
22130
+ function expandAcpAccounts(root, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
22131
+ const im = readImSettings(root, paths);
22132
+ if (im.channel !== "acp" || im.acp.enabled !== true) return [];
22133
+ const declared = Array.isArray(im.acp.accounts) ? im.acp.accounts : [];
22134
+ const out = [];
22135
+ for (const a of declared) {
22136
+ if (typeof a !== "object" || a === null) continue;
22137
+ if (a.enabled === false) continue;
22138
+ const accountId = typeof a.accountId === "string" ? a.accountId.trim() : "";
22139
+ if (accountId === "") continue;
22140
+ const role = typeof a.role === "string" ? a.role.trim() : "";
22141
+ if (role === "") continue;
22142
+ const token = readAcpToken(root, accountId);
22143
+ if (!token) continue;
22144
+ out.push({
22145
+ root,
22146
+ accountId,
22147
+ tokenKey: acpTokenKeyFor(accountId),
22148
+ token,
22149
+ role
22150
+ });
22151
+ }
22152
+ return out;
22153
+ }
22154
+ /**
22155
+ * **让配置里声明的账户都拥有 token**(写操作;幂等;由装配层与配置变更时调用)。
22156
+ *
22157
+ * 🔴 owner 令「**随即生成**认证用的 token」的落地点。与 v1.59.0 的差别:
22158
+ * 那时只在**启动**时跑一次 ⇒ 加一台设备要重启;现在**任何一次判定前都可以跑**,
22159
+ * 且撤账户后不会被"复活"(只对**当前声明里**的账户生成)。
22160
+ *
22161
+ * @returns 本次**新生成**的账户键列表(已存在的原样返回,不轮换)
22162
+ */
22163
+ function ensureAcpChannelTokens(root, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
22164
+ const im = readImSettings(root, paths);
22165
+ if (im.channel !== "acp" || im.acp.enabled !== true) return [];
22166
+ const declared = Array.isArray(im.acp.accounts) ? im.acp.accounts : [];
22167
+ const created = [];
22168
+ for (const a of declared) {
22169
+ if (typeof a !== "object" || a === null) continue;
22170
+ if (a.enabled === false) continue;
22171
+ const accountId = typeof a.accountId === "string" ? a.accountId.trim() : "";
22172
+ if (accountId === "") continue;
22173
+ try {
22174
+ if (!readAcpToken(root, accountId)) {
22175
+ ensureAcpToken(root, accountId);
22176
+ created.push(accountId);
22177
+ }
22178
+ } catch {}
22179
+ }
22180
+ return created;
22181
+ }
22182
+ /**
22183
+ * 校验一枚候选 token,返回它归属的**账户**(未命中返回 null)。
21926
22184
  *
21927
22185
  * @param candidate 客户端出示的 token
21928
22186
  * @param roots 候选 CCC 根集合(由调用方枚举——本模块不认识"CCC 在哪")
21929
22187
  */
21930
- function resolveTokenOwner(candidate, roots) {
22188
+ function resolveTokenAccount(candidate, roots, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
21931
22189
  if (!candidate) return null;
21932
- for (const root of roots) {
21933
- const token = readAcpToken(root);
21934
- if (token && safeEqual(candidate, token)) return root;
21935
- }
22190
+ for (const root of roots) for (const acct of expandAcpAccounts(root, paths)) if (safeEqual(candidate, acct.token)) return acct;
21936
22191
  return null;
21937
22192
  }
21938
22193
  /**
21939
- * 枚举**启用了 ACP 通道**的 CCC(供 `resolveTokenOwner` 用)。
22194
+ * 枚举**有可用 ACP 账户**的 CCC(供准入名单用)。
21940
22195
  *
21941
- * 判据(与装配层同源,见 `acpChannelEnabled`):`im.channel === 'acp'` ∧ `im.acp.enabled === true`
21942
- * ∧ token 已生成。⚠️ 三者**缺一即不算**(fail-closed:宁可连不上,不可放行一个半配置的通道)。
22196
+ * 判据:`im.channel === 'acp'` ∧ `im.acp.enabled === true` ∧ **至少一个账户有 token 且配了角色**。
22197
+ * ⚠️ 缺一即不算(fail-closed:宁可连不上,不可放行一个半配置的通道)。
21943
22198
  */
21944
22199
  function listAcpRoots(roots, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
21945
22200
  const out = [];
21946
- for (const root of roots) {
21947
- if (!acpChannelEnabled(root, paths)) continue;
21948
- if (!readAcpToken(root)) continue;
21949
- out.push(root);
21950
- }
22201
+ for (const root of roots) if (expandAcpAccounts(root, paths).length > 0) out.push(root);
21951
22202
  return out;
21952
22203
  }
21953
22204
  /** 读该 CCC 的 `im` 段(未配置 ⇒ 归一缺省;永不 undefined) */
@@ -21980,11 +22231,6 @@ function acpChannelEnabled(root, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
21980
22231
  const im = readImSettings(root, paths);
21981
22232
  return im.channel === "acp" && im.acp.enabled === true;
21982
22233
  }
21983
- /** 该 CCC 绑定的 skiff 角色名(未配置 ⇒ null) */
21984
- function acpRoleFor(root, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
21985
- const role = readImSettings(root, paths).acp.role;
21986
- return typeof role === "string" && role.trim() !== "" ? role.trim() : null;
21987
- }
21988
22234
  /**
21989
22235
  * 客户端 `session/new` 传来的 `cwd` 是否落在该 token 归属的 CCC 内。
21990
22236
  *
@@ -21999,6 +22245,126 @@ function cwdInsideRoot(cwd, root) {
21999
22245
  const rel = relative(resolve(root), resolve(cwd));
22000
22246
  return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
22001
22247
  }
22248
+ /** 该 CCC 的 ACP 通道对外**可见形态**(诊断面用;**绝不含 token 明文**) */
22249
+ function acpChannelStatus(root, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
22250
+ const im = readImSettings(root, paths);
22251
+ const declared = Array.isArray(im.acp.accounts) ? im.acp.accounts : [];
22252
+ const usable = new Set(expandAcpAccounts(root, paths).map((a) => a.accountId));
22253
+ const accounts = [];
22254
+ for (const a of declared) {
22255
+ if (typeof a !== "object" || a === null) continue;
22256
+ const accountId = typeof a.accountId === "string" ? a.accountId.trim() : "";
22257
+ if (accountId === "") continue;
22258
+ const role = typeof a.role === "string" && a.role.trim() !== "" ? a.role.trim() : null;
22259
+ const token = readAcpToken(root, accountId);
22260
+ const enabled = a.enabled !== false;
22261
+ let blockedBy = null;
22262
+ if (!enabled) blockedBy = "该账户 enabled=false";
22263
+ else if (!role) blockedBy = "未配 role";
22264
+ else if (!token) blockedBy = "尚无 token(保存配置后自动生成)";
22265
+ accounts.push({
22266
+ accountId,
22267
+ name: typeof a.name === "string" && a.name.trim() !== "" ? a.name.trim() : null,
22268
+ role,
22269
+ enabled,
22270
+ hasToken: token !== null,
22271
+ tokenFingerprint: token ? tokenFingerprint(token) : null,
22272
+ mainSession: readAcpMainSession(root, accountId),
22273
+ usable: usable.has(accountId),
22274
+ blockedBy
22275
+ });
22276
+ }
22277
+ return {
22278
+ channel: im.channel,
22279
+ enabled: acpChannelEnabled(root, paths),
22280
+ accounts
22281
+ };
22282
+ }
22283
+ /**
22284
+ * 读取 serenity.json 原文(不存在 → 空对象)。
22285
+ *
22286
+ * ⚠️ 与 `weixin-route.ts` 的同名函数**刻意各写一份**:两者写的是**不同段**
22287
+ * (`weixin` vs `im.acp.accounts`),共用一个会在"某天只想改其中一段"时
22288
+ * 意外把另一段也重写一遍。同规则两份 > 一个会互相牵连的共用件。
22289
+ */
22290
+ function readRawConfig(root, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
22291
+ for (const candidate of paths) {
22292
+ const p = resolve(root, candidate);
22293
+ if (existsSync(p)) try {
22294
+ const v = JSON.parse(readFileSync(p, "utf-8").replace(/^\uFEFF/, ""));
22295
+ if (v && typeof v === "object" && !Array.isArray(v)) return v;
22296
+ } catch {
22297
+ return {};
22298
+ }
22299
+ }
22300
+ return {};
22301
+ }
22302
+ /**
22303
+ * 写回 `im.acp.accounts`(保留文件里其余所有字段)。
22304
+ *
22305
+ * 🔴 **只碰这一个键**:`im.channel` / `im.acp.enabled` 归面板总开关与人工配置,
22306
+ * 本函数不越权改它们(否则"加一台设备"会顺手把通道开关也改了)。
22307
+ */
22308
+ function saveAcpAccounts(root, accounts, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
22309
+ const target = resolve(root, paths[0] ?? ".opencode/serenity.json");
22310
+ const raw = readRawConfig(root, paths);
22311
+ const im = raw.im && typeof raw.im === "object" ? raw.im : {};
22312
+ const acp = im.acp && typeof im.acp === "object" ? im.acp : {};
22313
+ acp.accounts = accounts;
22314
+ im.acp = acp;
22315
+ raw.im = im;
22316
+ const dir = resolve(target, "..");
22317
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
22318
+ writeFileSync(target, JSON.stringify(raw, null, 2) + "\n", "utf-8");
22319
+ return accounts;
22320
+ }
22321
+ /**
22322
+ * 规范化一条账户声明(面板传入的任意形状 → 合法声明;非法 ⇒ null)。
22323
+ *
22324
+ * 🔴 返回类型里 `role` 是**必填**(不是 `ImAcpAccount` 的可选形态)——
22325
+ * 本函数的契约就是"要么给一个角色齐备的账户,要么给 null"。
22326
+ * 这样调用方**不必再判一次** `role` 有没有(那正是会漂的第二处判据)。
22327
+ */
22328
+ function normalizeAcpAccount(input) {
22329
+ if (typeof input !== "object" || input === null) return null;
22330
+ const a = input;
22331
+ const accountId = typeof a.accountId === "string" ? a.accountId.trim() : "";
22332
+ if (accountId === "") return null;
22333
+ const role = typeof a.role === "string" ? a.role.trim() : "";
22334
+ if (role === "") return null;
22335
+ const out = {
22336
+ accountId,
22337
+ role
22338
+ };
22339
+ if (typeof a.name === "string" && a.name.trim() !== "") out.name = a.name.trim();
22340
+ if (a.enabled === false) out.enabled = false;
22341
+ return out;
22342
+ }
22343
+ /** 增/改一条账户(按 accountId 定位;写入后返回全表) */
22344
+ function upsertAcpAccount(root, account, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
22345
+ const list = [...readImSettings(root, paths).acp.accounts ?? []];
22346
+ const idx = list.findIndex((a) => a.accountId === account.accountId);
22347
+ if (idx >= 0) list[idx] = {
22348
+ ...list[idx],
22349
+ ...account
22350
+ };
22351
+ else list.push(account);
22352
+ return saveAcpAccounts(root, list, paths);
22353
+ }
22354
+ /**
22355
+ * 删一条账户(**同时清掉它的 token + 主会话**,避免留死键)。
22356
+ *
22357
+ * 🔴 主会话键**必须一起清**:否则日后用同一个 `accountId` 重建账户时,它会**静默继承**
22358
+ * 那条旧会话 —— 而那条会话的 cwd/角色都是上一代账户的。这个失效**没有任何信号**
22359
+ * (列表上它看着就是一条正常会话)。
22360
+ */
22361
+ function removeAcpAccount(root, accountId, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
22362
+ const next = saveAcpAccounts(root, (readImSettings(root, paths).acp.accounts ?? []).filter((a) => a.accountId !== accountId), paths);
22363
+ for (const key of [acpTokenKeyFor(accountId), acpSessionKeyFor(accountId)]) try {
22364
+ unsetEntry(root, "credential", key);
22365
+ } catch {}
22366
+ return next;
22367
+ }
22002
22368
  //#endregion
22003
22369
  //#region src/acp-face.ts
22004
22370
  /** 面名(面宿主幂等键;与 FACE_PORTS 的 id 同名,便于对账) */
@@ -22016,54 +22382,79 @@ function authorizeUpgrade(req) {
22016
22382
  ok: false,
22017
22383
  reason: "未携带 token(用 Authorization: Bearer <t> 或 ?token=<t>)"
22018
22384
  };
22019
- const roots = acpRoots;
22385
+ const roots = knownAcpRoots();
22020
22386
  if (roots.length === 0) return {
22021
22387
  ok: false,
22022
22388
  reason: "本机没有任何启用了 ACP 通道的 CCC"
22023
22389
  };
22024
- const owner = resolveTokenOwner(candidate, roots);
22025
- if (!owner) return {
22390
+ const acct = resolveTokenAccount(candidate, roots);
22391
+ if (!acct) return {
22026
22392
  ok: false,
22027
- reason: "token 不匹配任何已启用 ACP 通道的 CCC"
22393
+ reason: "token 不匹配任何已声明账户的 ACP 通道"
22028
22394
  };
22029
- const role = acpRoleFor(owner);
22395
+ const role = acct.role;
22030
22396
  if (!role) return {
22031
22397
  ok: false,
22032
- reason: `CCC(${owner})未配置 im.acp.role`
22398
+ reason: `CCC(${acct.root})账户 ${acct.accountId} 未配 im.acp.accounts[].role`
22033
22399
  };
22034
22400
  return {
22035
22401
  ok: true,
22036
- root: owner,
22037
- role
22402
+ root: acct.root,
22403
+ role,
22404
+ accountId: acct.accountId
22038
22405
  };
22039
22406
  }
22040
22407
  /**
22041
- * 归属候选 CCC 的**同步**缓存。
22408
+ * CCC 根候选的来源(**只要根,不判配置**)。
22042
22409
  *
22043
- * 🔴 为什么缓存而非每次现算:`listCccs()` 是 **async**(要读 workspace registry +
22044
- * `sessionPersistence`),而 `'upgrade'` 回调是**同步**的 ⇒ 不能在那里 await。
22045
- * ⇒ 启动时算一次 + 配置变更时刷新;这是**准入名单**,宁可用稍旧的名单(新 CCC 多等一次
22046
- * 配置事件即可生效),也不让 upgrade 挂起。
22410
+ * 🔴 v1.59.1 分层变更:**这份名单是"本机有哪些 CCC",不是"谁能连 ACP"**。
22411
+ * "谁能连"由 `expandAcpAccounts` 现算(读各 CCC 的 serenity.json + localstore)。
22412
+ * ⇒ 缓存失效的代价从"配错权限"降为"多看/少看一个候选根",**不再是安全问题**。
22413
+ */
22414
+ let knownRoots = [];
22415
+ /**
22416
+ * 准入名单的现算入口(v1.59.1)。
22417
+ *
22418
+ * 顺序:**先用已知根**(免 async)→ 逐个 `expandAcpAccounts` 现读配置。
22419
+ * 未知根时退回上次枚举的结果(可能稍旧,但只影响"会不会去问它",不影响是否放行)。
22420
+ *
22421
+ * ⚠️ 这里是**同步**的(upgrade 回调用)⇒ 不 await、不扫盘。
22422
+ */
22423
+ function knownAcpRoots() {
22424
+ return listAcpRoots(knownRoots);
22425
+ }
22426
+ /**
22427
+ * 刷新"本机有哪些 CCC"(async,枚举宿主三来源)。
22428
+ *
22429
+ * 🔴 **失败必须响亮**:v1.59.0 这里是 `void refresh(...)` 且没有 catch ——
22430
+ * 枚举失败会让名单清空 ⇒ **所有连接一律 401**,而现场只有一行 stdout 看不到的日志。
22431
+ * 这正是本轨记过的「调用没报错 ≠ 已验证」。
22432
+ *
22433
+ * @returns 本次枚举到的根数(0 = 一个都没发现,日志会说为什么)
22047
22434
  */
22048
- let acpRoots = [];
22049
- /** 刷新准入名单(**只收启用了 ACP 通道且已生成 token 的 CCC**)。返回新名单长度。 */
22050
22435
  async function refreshAcpRoots(ctx) {
22051
22436
  try {
22052
- const all = (await listCccs(ctx)).map((e) => e.root);
22053
- for (const root of all) try {
22054
- if (acpChannelEnabled(root) && !readAcpToken(root)) {
22055
- const token = ensureAcpToken(root);
22056
- console.log(`[serenity-hooks] ✓ 面 E:已为 CCC 生成 ACP token(${root},指纹 ${tokenFingerprint(token)})`);
22057
- }
22437
+ knownRoots = (await listCccs(ctx)).map((e) => e.root);
22438
+ let created = 0;
22439
+ for (const root of knownRoots) try {
22440
+ created += ensureAcpChannelTokens(root).length;
22058
22441
  } catch (err) {
22059
- console.warn(`[serenity-hooks] 面 E:为 CCC 生成 token 失败(${root}): ${String(err?.message ?? err)}`);
22442
+ console.warn(`[serenity-hooks] 面 E:为 CCC(${root})生成 token 失败: ${String(err?.message ?? err)}`);
22060
22443
  }
22061
- acpRoots = listAcpRoots(all);
22444
+ if (created > 0) console.log(`[serenity-hooks] ✓ 面 E:已为 ${created} 个 ACP 账户生成 token`);
22445
+ return knownRoots.length;
22062
22446
  } catch (err) {
22063
- acpRoots = [];
22064
- console.warn(`[serenity-hooks] 面 E:CCC 准入名单刷新失败(已清空,连接将被拒): ${String(err?.message ?? err)}`);
22447
+ knownRoots = [];
22448
+ const msg = `面 E:CCC 枚举失败(准入名单将为空,所有连接会被拒): ${String(err?.message ?? err)}`;
22449
+ console.warn(`[serenity-hooks] ${msg}`);
22450
+ state.lastErrors.push({
22451
+ at: Date.now(),
22452
+ applied: false,
22453
+ error: msg
22454
+ });
22455
+ if (state.lastErrors.length > MAX_ERR_KEEP) state.lastErrors.shift();
22456
+ return 0;
22065
22457
  }
22066
- return acpRoots.length;
22067
22458
  }
22068
22459
  /**
22069
22460
  * 把**本连接归属的 CCC 角色**套到该连接创建的 agent 上(v1.59.0)。
@@ -22162,13 +22553,58 @@ function acpFaceEnabled() {
22162
22553
  return readSimpleSettings().acpFaceEnabled;
22163
22554
  }
22164
22555
  /**
22556
+ * 把客户端给的 `cwd` 归一化成宿主要求的**绝对路径**(v1.59.1)。
22557
+ *
22558
+ * ## 为什么需要
22559
+ * 宿主 `dsh-acp` 在每次 `session/new` 都有这一行(`lib/index.js` 逐字):
22560
+ * ```js
22561
+ * if (!isAbsolute(params.cwd)) throw invalidParams(`cwd must be an absolute path: ${params.cwd}`)
22562
+ * ```
22563
+ * 而**连接归属哪个 CCC 已由 token 决定**(服务端权威)⇒ 让用户再手打一遍绝对路径
22564
+ * 是**同一个信息说两遍**,且那第二遍正是最容易打错的一遍。
22565
+ *
22566
+ * ## 规则(唯一一条)
22567
+ * **非绝对路径 ⇒ 一律解释为「本连接归属 CCC 的根」**。
22568
+ *
22569
+ * | 客户端填 | 送宿主 |
22570
+ * |---|---|
22571
+ * | `serenity` / `a` / `.` / 空 | `/…/home-serenity`(该 token 的 CCC 根) |
22572
+ * | `/…/home-serenity`(已绝对且在根内) | 原样 |
22573
+ * | `/…/home-serenity/sub` | 原样(仍在根内) |
22574
+ * | `/etc`(绝对但在根外) | **原样透传,交给 `cwdInsideRoot` 拒** |
22575
+ *
22576
+ * 🔴 **最后一行为什么不在这里直接拒**:越权判据必须**只有一处**(`cwdInsideRoot`,
22577
+ * 它也管着角色认领),在这里再判一次就是**两份会漂的实现**。归一化只负责"把非绝对变成绝对"。
22578
+ *
22579
+ * @param raw 客户端原样给的 `cwd`(可能是 `undefined`)
22580
+ * @param cccRoot 本连接归属的 CCC 根
22581
+ */
22582
+ function normalizeClientCwd(raw, cccRoot) {
22583
+ const value = typeof raw === "string" ? raw.trim() : "";
22584
+ if (value === "" || !isAbsolute(value)) return cccRoot;
22585
+ return value;
22586
+ }
22587
+ /**
22165
22588
  * 把一条裸 WS socket 适配成 ACP `Stream`。
22166
22589
  *
22167
22590
  * 🔴 与官方 `ndJsonStream` **同语义**:我们自己做「消息 ↔ 文本帧」的分帧(按行)。
22168
22591
  * 实测注意(P0′):`dsh-acp` 写出的每条消息**自带换行**,客户端同样 ⇒ 按行切分即正确。
22169
22592
  * ⚠️ **空行要丢**;**非 JSON 帧要容忍**(桥/客户端可能插入控制帧)。
22593
+ *
22594
+ * 🔴 v1.59.1:**入站消息在这里做一次 `cwd` 归一化**(见 {@link normalizeClientCwd})。
22595
+ * 为什么是这里:`session/new` 与 `session/resume` 都带 `cwd`,而这是**唯一**能同时
22596
+ * 拦到两者、且属于我们自己的缝(再往下就进 `dsh-acp` 了,那里改了就是改宿主)。
22597
+ *
22598
+ * 🔴 v1.59.2:这条缝同时承担 **owner「每账户固定一条长期对话」** 的双向改写 ——
22599
+ * 入站 `recordAcpPending`(记 id→method)+ 出站 `rewriteAcpResponse`(收窄 list / 记 new)。
22600
+ * ⚠️ 两件事都必须在**同一个** `ws` 上做:`pending` 是**每条连接一份**的,
22601
+ * 拆到两处会让它失去"这条连接上请求与响应配对"这个唯一用途。
22602
+ *
22603
+ * @param cccRoot 本连接归属的 CCC 根(token 反查所得)
22604
+ * @param accountId 本连接归属的账户键(主会话键由它派生)
22170
22605
  */
22171
- function wsToAcpStream(ws, onWarn) {
22606
+ function wsToAcpStream(ws, onWarn, cccRoot, accountId) {
22607
+ const pin = createAcpSessionPin(cccRoot, accountId);
22172
22608
  return {
22173
22609
  readable: new ReadableStream({ start(controller) {
22174
22610
  let buf = "";
@@ -22179,11 +22615,15 @@ function wsToAcpStream(ws, onWarn) {
22179
22615
  const line = buf.slice(0, i);
22180
22616
  buf = buf.slice(i + 1);
22181
22617
  if (!line.trim()) continue;
22618
+ let msg;
22182
22619
  try {
22183
- controller.enqueue(JSON.parse(line));
22620
+ msg = JSON.parse(line);
22184
22621
  } catch {
22185
22622
  onWarn(line.slice(0, 120));
22623
+ continue;
22186
22624
  }
22625
+ recordAcpPending(pin, msg);
22626
+ controller.enqueue(normalizeInboundCwd(msg, cccRoot));
22187
22627
  }
22188
22628
  });
22189
22629
  ws.on("close", () => {
@@ -22198,10 +22638,133 @@ function wsToAcpStream(ws, onWarn) {
22198
22638
  });
22199
22639
  } }),
22200
22640
  writable: new WritableStream({ write(msg) {
22201
- ws.send(`${JSON.stringify(msg)}\n`);
22641
+ ws.send(`${JSON.stringify(rewriteAcpResponse(pin, msg))}\n`);
22202
22642
  } })
22203
22643
  };
22204
22644
  }
22645
+ /**
22646
+ * 需要把 `cwd` 钉到本连接 CCC 根的方法(**必须逐个列出**)。
22647
+ *
22648
+ * 🔴 **v1.59.1 的第一版判据错了**:只判「params 里有没有 `cwd`」⇒ 漏掉 `session/list`。
22649
+ * 宿主 `listSessions` 的过滤是
22650
+ * `if (params.cwd !== void 0 && !sameDirectory(header.cwd, params.cwd)) return`
22651
+ * —— **不给 cwd 就返回全机器所有会话**。实测后果:手机 App 列表里冒出
22652
+ * 23 条别的 CCC 的会话,全部显示为 `UNKNOWN` / `New Chat`
22653
+ * (owner:「**我需要是指定CCC的,不然没办法用**」)。
22654
+ * ⇒ 🆕 **判据:凡"应当被 CCC 收窄"的读接口,都必须把 `cwd` 钉成 CCC 根,不能依赖客户端传** ——
22655
+ * token 已经定了 CCC,让客户端再声明一次既冗余、又可以被静默漏掉。
22656
+ */
22657
+ const CWD_PINNED_METHODS = /* @__PURE__ */ new Set([
22658
+ "session/new",
22659
+ "session/resume",
22660
+ "session/list"
22661
+ ]);
22662
+ /**
22663
+ * 把一条入站消息的 `cwd` 归一化(**含"没给就补上"**)。
22664
+ *
22665
+ * | 情形 | 结果 |
22666
+ * |---|---|
22667
+ * | 方法不在 {@link CWD_PINNED_METHODS} | 原样(不碰别的消息) |
22668
+ * | 给了非绝对路径(`serenity` / `a` / 空串) | → 本连接 CCC 根 |
22669
+ * | 给了绝对路径 | 原样(越界由 `cwdInsideRoot` 拒) |
22670
+ * | **压根没给**(`session/list` 常见) | **→ 本连接 CCC 根**(否则列出全机器) |
22671
+ */
22672
+ function normalizeInboundCwd(msg, cccRoot) {
22673
+ if (typeof msg !== "object" || msg === null) return msg;
22674
+ const m = msg;
22675
+ if (typeof m.method !== "string" || !CWD_PINNED_METHODS.has(m.method)) return msg;
22676
+ const params = typeof m.params === "object" && m.params !== null ? m.params : {};
22677
+ return {
22678
+ ...msg,
22679
+ params: {
22680
+ ...params,
22681
+ cwd: normalizeClientCwd(params.cwd, cccRoot)
22682
+ }
22683
+ };
22684
+ }
22685
+ /** 请求 id 的 Map 键(区分 `1` 与 `"1"` —— JSON-RPC 允许两者,混用也不该串号) */
22686
+ function requestIdKey(id) {
22687
+ return `${typeof id}:${String(id)}`;
22688
+ }
22689
+ /** 记账上限(防"客户端发一堆请求又从不收响应"把内存撑大) */
22690
+ const MAX_PENDING = 256;
22691
+ /** 建一条连接的钉住状态 */
22692
+ function createAcpSessionPin(root, accountId) {
22693
+ return {
22694
+ root,
22695
+ accountId,
22696
+ pending: /* @__PURE__ */ new Map()
22697
+ };
22698
+ }
22699
+ /**
22700
+ * 入站记账:把"这条请求的方法"记住,供出站按 `id` 反查。
22701
+ *
22702
+ * 判据:**既要有 `id`,又要有 `method`** —— 只满足一个的是响应(`id` 无 `method`)
22703
+ * 或通知(`method` 无 `id`)⇒ 与我们无关,不记。
22704
+ */
22705
+ function recordAcpPending(pin, msg) {
22706
+ if (typeof msg !== "object" || msg === null) return;
22707
+ const m = msg;
22708
+ if (m.id === void 0 || m.id === null) return;
22709
+ if (typeof m.method !== "string") return;
22710
+ const key = requestIdKey(m.id);
22711
+ pin.pending.set(key, m.method);
22712
+ while (pin.pending.size > MAX_PENDING) {
22713
+ const oldest = pin.pending.keys().next();
22714
+ if (oldest.done) break;
22715
+ pin.pending.delete(oldest.value);
22716
+ }
22717
+ }
22718
+ /**
22719
+ * 出站改写(本功能的落点)。返回**要发给客户端的那条消息**。
22720
+ *
22721
+ * | 原响应 | 改写后 |
22722
+ * |---|---|
22723
+ * | `session/list` | `{sessions:[该账户那一条]}`;**没有主会话 ⇒ `{sessions:[]}`** |
22724
+ * | `session/new` | 原样,**同时**把 `result.sessionId` 记为该账户的主会话 |
22725
+ * | 其余(含所有 error 响应、所有通知) | 原样 |
22726
+ *
22727
+ * 🔴 **没有主会话时回空数组,绝不回退成全量列表** —— 退回去正是 owner 说
22728
+ * 「23 个对我没什么用」的那个东西。空数组会让 App 引导用户新建,而新建即建立主会话。
22729
+ *
22730
+ * 🔴 `nextCursor` **一律丢掉**:收窄到一条之后,分页游标失去意义;
22731
+ * 留着它会让客户端继续翻页(而后续页仍会被收窄成同一条 ⇒ 前端可能出现重复项)。
22732
+ */
22733
+ function rewriteAcpResponse(pin, msg) {
22734
+ if (typeof msg !== "object" || msg === null) return msg;
22735
+ const m = msg;
22736
+ if (m.method !== void 0) return msg;
22737
+ const method = m.id === void 0 || m.id === null ? void 0 : pin.pending.get(requestIdKey(m.id));
22738
+ if (method === void 0) return msg;
22739
+ if (m.error !== void 0) return msg;
22740
+ if (method === "session/new") {
22741
+ const sessionId = m.result?.sessionId;
22742
+ if (typeof sessionId === "string" && sessionId.trim() !== "") try {
22743
+ writeAcpMainSession(pin.root, pin.accountId, sessionId);
22744
+ } catch (err) {
22745
+ console.warn(`[serenity-hooks] ⚠ 面 E:记录主会话失败(CCC=${pin.root} 账户=${pin.accountId}): ${String(err?.message ?? err)}`);
22746
+ }
22747
+ return msg;
22748
+ }
22749
+ if (method === "session/list") {
22750
+ const pinned = readAcpMainSession(pin.root, pin.accountId);
22751
+ if (!pinned) return {
22752
+ ...m,
22753
+ result: { sessions: [] }
22754
+ };
22755
+ const list = m.result?.sessions;
22756
+ const hit = Array.isArray(list) ? list.find((s) => s?.sessionId === pinned) : void 0;
22757
+ const sessions = hit !== void 0 ? [hit] : [{
22758
+ sessionId: pinned,
22759
+ cwd: pin.root
22760
+ }];
22761
+ return {
22762
+ ...m,
22763
+ result: { sessions }
22764
+ };
22765
+ }
22766
+ return msg;
22767
+ }
22205
22768
  function newState() {
22206
22769
  return {
22207
22770
  handle: null,
@@ -22282,9 +22845,10 @@ ACP 面:认证失败(${verdict.reason})。请在该 CCC 的 localstore 生
22282
22845
  try {
22283
22846
  const connRoot = verdict.root;
22284
22847
  const connRole = verdict.role;
22848
+ const connAccount = verdict.accountId;
22285
22849
  const stream = wsToAcpStream(ws, (line) => {
22286
22850
  console.log(`[serenity-hooks] 面 E:丢弃一条非 JSON 帧(前 120 字): ${line}`);
22287
- });
22851
+ }, connRoot, connAccount);
22288
22852
  const fiber = ctx.plugin({
22289
22853
  inject: ACP_REQUIRED_SERVICES,
22290
22854
  apply: (scoped) => {
@@ -22451,7 +23015,7 @@ function registerAcpFace(ctx) {
22451
23015
  } catch {}
22452
23016
  const refresh = () => {
22453
23017
  refreshAcpRoots(ctx).then((n) => {
22454
- console.log(`[serenity-hooks] 面 E:CCC 准入名单已刷新(${n} 个 CCC 启用 ACP 通道)`);
23018
+ console.log(`[serenity-hooks] 面 E:CCC 候选根已枚举(${n} 个)`);
22455
23019
  });
22456
23020
  };
22457
23021
  try {